From d607522f4ff7da5f528379ebe5c619509b960605 Mon Sep 17 00:00:00 2001 From: dumbdev Date: Sat, 25 Jul 2026 23:57:33 +0100 Subject: [PATCH] feat(frontend): upgrade dependencies and refactor Onboarding Progress Tracker --- frontend/messages/en.json | 29 +- frontend/messages/es.json | 29 +- frontend/messages/pt.json | 29 +- frontend/src/app/(public)/register/page.tsx | 141 +- .../OnboardingProgressTracker.test.tsx | 1028 ++++++++------- .../components/OnboardingProgressTracker.tsx | 1168 +++++++++-------- frontend/src/components/onboarding-reducer.ts | 173 ++- frontend/src/hooks/useOnboardingProgress.ts | 212 +++ 8 files changed, 1661 insertions(+), 1148 deletions(-) create mode 100644 frontend/src/hooks/useOnboardingProgress.ts diff --git a/frontend/messages/en.json b/frontend/messages/en.json index aa7d7cd6..827646ed 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -574,6 +574,33 @@ "successDescription": "Your KYC verification has been submitted and is under review.", "dash": "—" }, + "onboarding": { + "title": "Onboarding Progress", + "subtitle": "Complete all required steps to finish setup", + "progressBar": "Overall onboarding progress", + "stepsList": "Onboarding steps", + "progressTracker": "Onboarding Progress Tracker", + "stepsCompleted": "{completed} of {total} steps completed", + "percentComplete": "{percent}% complete", + "allCompleted": "All done!", + "successTitle": "Onboarding Complete!", + "successMessage": "You have successfully completed all required onboarding steps.", + "completed": "Completed", + "inProgress": "In Progress", + "pending": "Pending", + "required": "Required", + "optional": "Optional", + "stepProgress": "Step", + "updating": "Updating step\u2026", + "stepChangeFailed": "Step change failed. Please try again.", + "stepLabel": "Step {number}: {title}", + "stepLabelCompleted": "Step {number}: {title}. Completed", + "stepLabelRequired": "Step {number}: {title}. Required", + "stepLabelCompletedRequired": "Step {number}: {title}. Completed. Required", + "stepAnnouncement": "Step {number} of {total}: {title}. {description}. {status}.", + "progress": "Progress", + "progressAnnouncement": "Progress: {percent}% complete" + }, "portfolioChartWidget": { "title": "Portfolio Value", "allocation": "Allocation", @@ -584,4 +611,4 @@ "emptyAssets": "No portfolio assets available yet.", "emptyHistory": "No performance history available yet." } -} +} \ No newline at end of file diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 4d2cb831..0877e23a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -574,6 +574,33 @@ "successDescription": "Tu verificacion KYC ha sido enviada y esta en revision.", "dash": "—" }, + "onboarding": { + "title": "Progreso de incorporacion", + "subtitle": "Completa todos los pasos obligatorios para finalizar la configuracion", + "progressBar": "Progreso general de incorporacion", + "stepsList": "Pasos de incorporacion", + "progressTracker": "Seguimiento del progreso de incorporacion", + "stepsCompleted": "{completed} de {total} pasos completados", + "percentComplete": "{percent}% completado", + "allCompleted": "Completado!", + "successTitle": "Incorporacion completa!", + "successMessage": "Has completado exitosamente todos los pasos obligatorios de incorporacion.", + "completed": "Completado", + "inProgress": "En progreso", + "pending": "Pendiente", + "required": "Obligatorio", + "optional": "Opcional", + "stepProgress": "Paso", + "updating": "Actualizando paso\u2026", + "stepChangeFailed": "Error al cambiar el paso. Intentalo de nuevo.", + "stepLabel": "Paso {number}: {title}", + "stepLabelCompleted": "Paso {number}: {title}. Completado", + "stepLabelRequired": "Paso {number}: {title}. Obligatorio", + "stepLabelCompletedRequired": "Paso {number}: {title}. Completado. Obligatorio", + "stepAnnouncement": "Paso {number} de {total}: {title}. {description}. {status}.", + "progress": "Progreso", + "progressAnnouncement": "Progreso: {percent}% completado" + }, "portfolioChartWidget": { "title": "Valor del portafolio", "allocation": "Distribucion", @@ -584,4 +611,4 @@ "emptyAssets": "Todavia no hay activos en el portafolio.", "emptyHistory": "Todavia no hay historial de rendimiento." } -} +} \ No newline at end of file diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json index b18cf4f8..6aa9d08d 100644 --- a/frontend/messages/pt.json +++ b/frontend/messages/pt.json @@ -574,6 +574,33 @@ "successDescription": "Sua verificacao KYC foi enviada e esta em revisao.", "dash": "—" }, + "onboarding": { + "title": "Progresso de integracao", + "subtitle": "Conclua todas as etapas obrigatorias para finalizar a configuracao", + "progressBar": "Progresso geral de integracao", + "stepsList": "Etapas de integracao", + "progressTracker": "Rastreador de progresso de integracao", + "stepsCompleted": "{completed} de {total} etapas concluidas", + "percentComplete": "{percent}% concluido", + "allCompleted": "Concluido!", + "successTitle": "Integracao concluida!", + "successMessage": "Voce concluiu com sucesso todas as etapas obrigatorias de integracao.", + "completed": "Concluido", + "inProgress": "Em andamento", + "pending": "Pendente", + "required": "Obrigatorio", + "optional": "Opcional", + "stepProgress": "Etapa", + "updating": "Atualizando etapa\u2026", + "stepChangeFailed": "Falha ao alterar a etapa. Tente novamente.", + "stepLabel": "Etapa {number}: {title}", + "stepLabelCompleted": "Etapa {number}: {title}. Concluida", + "stepLabelRequired": "Etapa {number}: {title}. Obrigatoria", + "stepLabelCompletedRequired": "Etapa {number}: {title}. Concluida. Obrigatoria", + "stepAnnouncement": "Etapa {number} de {total}: {title}. {description}. {status}.", + "progress": "Progresso", + "progressAnnouncement": "Progresso: {percent}% concluido" + }, "portfolioChartWidget": { "title": "Valor do portafolio", "allocation": "Distribuicao", @@ -584,4 +611,4 @@ "emptyAssets": "Ainda nao ha ativos no portafolio.", "emptyHistory": "Ainda nao ha historico de desempenho." } -} +} \ No newline at end of file diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx index 3d9db6e2..896e5459 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -1,40 +1,101 @@ -import RegistrationForm from "@/components/RegistrationForm"; -import Link from "next/link"; -import GuestGuard from "@/components/GuestGuard"; - -export default function RegisterPage() { - return ( - -
-
-

- Onboarding -

-

- Join PLUTO -

-

- Create your merchant profile to start accepting modern payments and - managing assets on the PLUTO infrastructure. -

-
- -
- -
- -
-

- Already have an account?{" "} - - Log in here - -

-
-
-
- ); -} +import RegistrationForm from "@/components/RegistrationForm"; +import Link from "next/link"; +import GuestGuard from "@/components/GuestGuard"; +import { OnboardingProgressTracker } from "@/components/OnboardingProgressTracker"; + +/** + * Demo steps shown on the registration page so users understand what the + * onboarding journey looks like before they submit the form. + * Marked as a server-side constant — no runtime cost. + */ +const ONBOARDING_STEPS = [ + { + id: "create-account", + title: "Create your account", + description: "Register your merchant profile with a secure password.", + completed: false, + required: true, + order: 1, + }, + { + id: "verify-email", + title: "Verify your email", + description: "Confirm your email address to activate your account.", + completed: false, + required: true, + order: 2, + }, + { + id: "api-keys", + title: "Generate API keys", + description: "Create your live API key to start accepting payments.", + completed: false, + required: true, + order: 3, + }, + { + id: "webhook", + title: "Configure a webhook", + description: "Point PLUTO at your server to receive real-time events.", + completed: false, + required: false, + order: 4, + }, + { + id: "branding", + title: "Customise checkout branding", + description: "Upload your logo and set brand colours for the checkout page.", + completed: false, + required: false, + order: 5, + }, +] as const; + +export default function RegisterPage() { + return ( + +
+ {/* ── Page header ─────────────────────────────────────────────────── */} +
+

+ Onboarding +

+

+ Join PLUTO +

+

+ Create your merchant profile to start accepting modern payments and + managing assets on the PLUTO infrastructure. +

+
+ + {/* ── Onboarding Progress Tracker ──────────────────────────────────── */} + [0]["steps"]} + currentStep="create-account" + compact={false} + orientation="vertical" + showStepNumbers + /> + + {/* ── Registration form ────────────────────────────────────────────── */} +
+ +
+ + {/* ── Footer ──────────────────────────────────────────────────────── */} +
+

+ Already have an account?{" "} + + Log in here + +

+
+
+
+ ); +} diff --git a/frontend/src/components/OnboardingProgressTracker.test.tsx b/frontend/src/components/OnboardingProgressTracker.test.tsx index 558a4f78..83cf08ce 100644 --- a/frontend/src/components/OnboardingProgressTracker.test.tsx +++ b/frontend/src/components/OnboardingProgressTracker.test.tsx @@ -1,479 +1,549 @@ -/** - * @vitest-environment jsdom - * - * Unit tests for OnboardingProgressTracker - * - * Covers: - * - #809 framer-motion animation variants and reduced-motion support - * - #810 comprehensive unit test coverage - * - #811 screen reader / accessibility attributes - * - #812 optimistic updates and rollback behaviour - */ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { OnboardingProgressTracker } from "./OnboardingProgressTracker"; -import React from "react"; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -vi.mock("next-intl", () => ({ - useTranslations: () => (key: string) => key, -})); - -// Mock framer-motion — renders plain HTML elements so tests stay fast — #809 -vi.mock("framer-motion", () => ({ - motion: { - div: React.forwardRef(({ children, ...props }: any, ref: any) => ( -
{children}
- )), - ol: React.forwardRef(({ children, ...props }: any, ref: any) => ( -
    {children}
- )), - li: React.forwardRef(({ children, ...props }: any, ref: any) => ( -
  • {children}
  • - )), - button: React.forwardRef(({ children, ...props }: any, ref: any) => ( - - )), - span: React.forwardRef(({ children, ...props }: any, ref: any) => ( - {children} - )), - svg: React.forwardRef(({ children, ...props }: any, ref: any) => ( - {children} - )), - }, - AnimatePresence: ({ children }: any) => <>{children}, - useReducedMotion: () => false, -})); - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -const mockSteps = [ - { - id: "1", - title: "Step 1", - description: "Description 1", - completed: true, - required: true, - order: 1, - }, - { - id: "2", - title: "Step 2", - description: "Description 2", - completed: false, - required: true, - order: 2, - }, - { - id: "3", - title: "Step 3", - description: "Description 3", - completed: false, - required: false, - order: 3, - }, -]; - -const defaultProps = { - steps: mockSteps, - onStepChange: vi.fn(), - onComplete: vi.fn(), -}; - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("OnboardingProgressTracker", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - // ------------------------------------------------------------------------- - // #810 — Rendering - // ------------------------------------------------------------------------- - describe("Rendering", () => { - it("renders the component with the correct title", () => { - render(); - expect(screen.getByText("onboarding.title")).toBeInTheDocument(); - }); - - it("renders all steps with correct titles and descriptions", () => { - render(); - mockSteps.forEach((step) => { - expect(screen.getByText(step.title)).toBeInTheDocument(); - expect(screen.getByText(step.description)).toBeInTheDocument(); - }); - }); - - it("shows the correct progress percentage", () => { - render(); - // 1 of 3 completed = 33 % - expect(screen.getByText("33%")).toBeInTheDocument(); - }); - - it("renders the progress bar fill element", () => { - render(); - const fill = screen.getByTestId("progress-bar-fill"); - expect(fill).toBeInTheDocument(); - expect(fill).toHaveStyle("width: 33%"); - }); - - it("renders the progress bar with correct ARIA attributes", () => { - render(); - const bar = screen.getByRole("progressbar"); - expect(bar).toHaveAttribute("aria-valuenow", "33"); - expect(bar).toHaveAttribute("aria-valuemin", "0"); - expect(bar).toHaveAttribute("aria-valuemax", "100"); - }); - - it("does not show the completion banner when onboarding is incomplete", () => { - render(); - expect(screen.queryByTestId("completion-banner")).not.toBeInTheDocument(); - }); - - it("shows the completion banner when all required steps are done", async () => { - const allDone = [ - { ...mockSteps[0], completed: true }, - { ...mockSteps[1], completed: true }, - { ...mockSteps[2], completed: false }, // optional — not required - ]; - render(); - await waitFor(() => { - expect(screen.getByTestId("completion-banner")).toBeInTheDocument(); - }); - }); - }); - - // ------------------------------------------------------------------------- - // #810 — Props and variants - // ------------------------------------------------------------------------- - describe("Props and variants", () => { - it("hides step numbers when showStepNumbers is false", () => { - render(); - // Numbers 1, 2, 3 should not appear as standalone text nodes - expect(screen.queryByText("1")).not.toBeInTheDocument(); - expect(screen.queryByText("2")).not.toBeInTheDocument(); - }); - - it("applies compact padding class when compact prop is true", () => { - const { container } = render( - - ); - expect(container.querySelector(".p-4")).toBeInTheDocument(); - }); - - it("applies horizontal layout classes when orientation is horizontal", () => { - render(); - const list = screen.getByRole("list"); - expect(list).toHaveClass("flex"); - expect(list).toHaveClass("gap-4"); - }); - - it("renders a vertical list by default", () => { - render(); - const list = screen.getByRole("list"); - expect(list).not.toHaveClass("flex"); - }); - }); - - // ------------------------------------------------------------------------- - // #810 — Interactions - // ------------------------------------------------------------------------- - describe("Interactions", () => { - it("calls onStepChange when a step button is clicked", () => { - render(); - const btn = screen.getByLabelText(/Step 2: Step 2. Required/i); - fireEvent.click(btn); - expect(defaultProps.onStepChange).toHaveBeenCalledWith("2"); - }); - - it("calls onStepChange with the correct step id", () => { - render(); - const btn = screen.getByLabelText(/Step 1: Step 1. Completed. Required/i); - fireEvent.click(btn); - expect(defaultProps.onStepChange).toHaveBeenCalledWith("1"); - }); - }); - - // ------------------------------------------------------------------------- - // #810 — Completion logic - // ------------------------------------------------------------------------- - describe("Completion logic", () => { - it("calls onComplete when all required steps are completed", async () => { - const allRequired = [ - { ...mockSteps[0], completed: true }, - { ...mockSteps[1], completed: true }, - { ...mockSteps[2], completed: false }, - ]; - render(); - await waitFor(() => { - expect(defaultProps.onComplete).toHaveBeenCalled(); - }); - }); - - it("does not call onComplete when a required step is incomplete", () => { - render(); - expect(defaultProps.onComplete).not.toHaveBeenCalled(); - }); - - it("shows success title in completion banner", async () => { - const allRequired = [ - { ...mockSteps[0], completed: true }, - { ...mockSteps[1], completed: true }, - { ...mockSteps[2], completed: false }, - ]; - render(); - await waitFor(() => { - expect(screen.getByText("onboarding.successTitle")).toBeInTheDocument(); - }); - }); - - it("does not show success title when onboarding is incomplete", () => { - render(); - expect(screen.queryByText("onboarding.successTitle")).not.toBeInTheDocument(); - }); - }); - - // ------------------------------------------------------------------------- - // #811 — Accessibility / screen reader support - // ------------------------------------------------------------------------- - describe("Accessibility — screen reader support (#811)", () => { - it("wraps the tracker in a region landmark with aria-label", () => { - render(); - const region = screen.getByRole("region"); - expect(region).toHaveAttribute("aria-label", "onboarding.progressTracker"); - }); - - it("sets aria-live='polite' on the region", () => { - render(); - const region = screen.getByRole("region"); - expect(region).toHaveAttribute("aria-live", "polite"); - }); - - it("renders a live status region for announcements", () => { - render(); - const status = screen.getByRole("status"); - expect(status).toBeInTheDocument(); - }); - - it("announces progress percentage on mount", () => { - render(); - const status = screen.getByRole("status"); - expect(status.textContent).toContain("33"); - }); - - it("announces step details when a step is clicked", async () => { - render(); - const btn = screen.getByLabelText(/Step 2: Step 2. Required/i); - fireEvent.click(btn); - const status = screen.getByRole("status"); - await waitFor(() => { - expect(status.textContent).toContain("Step 2"); - expect(status.textContent).toContain("Description 2"); - }); - }); - - it("labels the steps list with aria-label", () => { - render(); - const list = screen.getByRole("list"); - expect(list).toHaveAttribute("aria-label", "onboarding.stepsList"); - }); - - it("marks the current step with aria-current='step'", () => { - render(); - const btn = screen.getByLabelText(/Step 1: Step 1. Completed. Required/i); - expect(btn).toHaveAttribute("aria-current", "step"); - }); - - it("does not set aria-current on non-current steps", () => { - render(); - const btn = screen.getByLabelText(/Step 2: Step 2. Required/i); - expect(btn).not.toHaveAttribute("aria-current"); - }); - - it("sets aria-setsize and aria-posinset on step buttons", () => { - render(); - const btn1 = screen.getByLabelText(/Step 1: Step 1. Completed. Required/i); - expect(btn1).toHaveAttribute("aria-setsize", "3"); - expect(btn1).toHaveAttribute("aria-posinset", "1"); - - const btn2 = screen.getByLabelText(/Step 2: Step 2. Required/i); - expect(btn2).toHaveAttribute("aria-posinset", "2"); - }); - - it("sets aria-roledescription on step buttons", () => { - render(); - const btn = screen.getByLabelText(/Step 1: Step 1. Completed. Required/i); - expect(btn).toHaveAttribute("aria-roledescription", "onboarding step"); - }); - - it("completion banner has role='alert' and aria-live='polite'", async () => { - const allDone = [ - { ...mockSteps[0], completed: true }, - { ...mockSteps[1], completed: true }, - { ...mockSteps[2], completed: false }, - ]; - render(); - await waitFor(() => { - const alert = screen.getByRole("alert"); - expect(alert).toHaveAttribute("aria-live", "polite"); - expect(alert).toHaveAttribute("aria-atomic", "true"); - }); - }); - - it("marks required asterisk with aria-label", () => { - render(); - const requiredMarkers = screen.getAllByLabelText("onboarding.required"); - expect(requiredMarkers.length).toBeGreaterThan(0); - }); - }); - - // ------------------------------------------------------------------------- - // #812 — Optimistic updates - // ------------------------------------------------------------------------- - describe("Optimistic updates (#812)", () => { - it("immediately reflects the clicked step as current before callback resolves", async () => { - let resolveCallback!: () => void; - const slowCallback = vi.fn( - () => new Promise((res) => { resolveCallback = res; }) - ); - - render( - - ); - - const btn2 = screen.getByLabelText(/Step 2: Step 2. Required/i); - fireEvent.click(btn2); - - // Optimistic: step 2 should be current immediately - await waitFor(() => { - expect(btn2).toHaveAttribute("aria-current", "step"); - }); - - // Resolve the callback - act(() => resolveCallback()); - }); - - it("confirms the optimistic update after callback resolves", async () => { - const asyncCallback = vi.fn(() => Promise.resolve()); - render( - - ); - - const btn2 = screen.getByLabelText(/Step 2: Step 2. Required/i); - fireEvent.click(btn2); - - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith("2"); - expect(btn2).toHaveAttribute("aria-current", "step"); - }); - }); - - it("rolls back the optimistic update when callback throws", async () => { - const failingCallback = vi.fn(() => Promise.reject(new Error("server error"))); - - render( - - ); - - const btn1 = screen.getByLabelText(/Step 1: Step 1. Completed. Required/i); - const btn2 = screen.getByLabelText(/Step 2: Step 2. Required/i); - - fireEvent.click(btn2); - - // After rollback, step 1 should be current again - await waitFor(() => { - expect(btn1).toHaveAttribute("aria-current", "step"); - expect(btn2).not.toHaveAttribute("aria-current"); - }); - }); - - it("announces a failure message to screen readers on rollback", async () => { - const failingCallback = vi.fn(() => Promise.reject(new Error("fail"))); - - render( - - ); - - const btn2 = screen.getByLabelText(/Step 2: Step 2. Required/i); - fireEvent.click(btn2); - - await waitFor(() => { - const status = screen.getByRole("status"); - expect(status.textContent).toContain("onboarding.stepChangeFailed"); - }); - }); - - it("sets aria-busy on the pending step button during optimistic update", async () => { - let resolveCallback!: () => void; - const slowCallback = vi.fn( - () => new Promise((res) => { resolveCallback = res; }) - ); - - render( - - ); - - const btn2 = screen.getByLabelText(/Step 2: Step 2. Required/i); - fireEvent.click(btn2); - - await waitFor(() => { - expect(btn2).toHaveAttribute("aria-busy", "true"); - }); - - act(() => resolveCallback()); - }); - }); - - // ------------------------------------------------------------------------- - // #809 — Animation / reduced-motion - // ------------------------------------------------------------------------- - describe("Animations (#809)", () => { - it("renders the progress bar fill element used for animation", () => { - render(); - expect(screen.getByTestId("progress-bar-fill")).toBeInTheDocument(); - }); - - it("renders step list items that carry animation variant props", () => { - render(); - // All 3 steps should be in the DOM - const items = screen.getAllByRole("listitem"); - expect(items).toHaveLength(3); - }); - - it("renders completed step with a checkmark icon", () => { - render(); - // Step 1 is completed — its button should not show a number - const btn1 = screen.getByLabelText(/Step 1: Step 1. Completed. Required/i); - // The number span is not rendered for completed steps - expect(btn1.querySelector("span")).not.toBeInTheDocument(); - }); - }); -}); +/** + * @vitest-environment jsdom + * + * Unit tests for OnboardingProgressTracker (refactored) + * + * Covers: + * - #809 framer-motion animation variants and reduced-motion support + * - #810 comprehensive unit-test coverage (rendering, props, interactions, completion) + * - #811 screen-reader / accessibility attributes + * - #812 optimistic updates and rollback behaviour + * - i18n translated strings via "onboarding" namespace + * - hook useOnboardingProgress integration (SYNC_STEPS, external prop sync) + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { OnboardingProgressTracker } from "./OnboardingProgressTracker"; +import React from "react"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +// next-intl: return the translation key so assertions are locale-independent +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, params?: Record) => { + if (!params) return key; + // Inline simple {token} substitution so parameterised strings are readable + return Object.entries(params).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), + key, + ); + }, +})); + +// framer-motion: render plain HTML so tests stay fast — #809 +vi.mock("framer-motion", () => ({ + motion: new Proxy( + {}, + { + get: (_target, tag: string) => + // eslint-disable-next-line react/display-name + React.forwardRef(({ children, animate, variants, initial, exit, transition, ...rest }: any, ref: any) => + React.createElement(tag, { ...rest, ref }, children), + ), + }, + ), + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, + useReducedMotion: () => false, +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const step1 = { id: "1", title: "Step 1", description: "Desc 1", completed: true, required: true, order: 1 }; +const step2 = { id: "2", title: "Step 2", description: "Desc 2", completed: false, required: true, order: 2 }; +const step3 = { id: "3", title: "Step 3", description: "Desc 3", completed: false, required: false, order: 3 }; + +const mockSteps = [step1, step2, step3]; + +const defaultProps = { + steps: mockSteps, + onStepChange: vi.fn(), + onComplete: vi.fn(), +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Find the step button by its translated aria-label key fragment. */ +const getStepBtn = (titleFragment: string) => + screen.getByRole("button", { name: new RegExp(titleFragment, "i") }); + +// --------------------------------------------------------------------------- +// Test suites +// --------------------------------------------------------------------------- + +describe("OnboardingProgressTracker", () => { + beforeEach(() => vi.clearAllMocks()); + + // ── #810 Rendering ──────────────────────────────────────────────────────── + describe("Rendering", () => { + it("renders the translated title", () => { + render(); + expect(screen.getByText("title")).toBeInTheDocument(); + }); + + it("renders all step titles and descriptions", () => { + render(); + mockSteps.forEach((s) => { + expect(screen.getByText(s.title)).toBeInTheDocument(); + expect(screen.getByText(s.description)).toBeInTheDocument(); + }); + }); + + it("displays the progress percentage (33% for 1 of 3 completed)", () => { + render(); + // The header badge uses t("percentComplete", { percent: 33 }) + expect(screen.getByText(/33/)).toBeInTheDocument(); + }); + + it("renders the progress bar fill element with correct width", () => { + render(); + const fill = screen.getByTestId("progress-bar-fill"); + expect(fill).toHaveStyle("width: 33%"); + }); + + it("sets correct ARIA attributes on the progressbar", () => { + render(); + const bar = screen.getByRole("progressbar"); + expect(bar).toHaveAttribute("aria-valuenow", "33"); + expect(bar).toHaveAttribute("aria-valuemin", "0"); + expect(bar).toHaveAttribute("aria-valuemax", "100"); + }); + + it("does not show the completion banner when onboarding is incomplete", () => { + render(); + expect(screen.queryByTestId("completion-banner")).not.toBeInTheDocument(); + }); + + it("shows the completion banner when all required steps are done", async () => { + const allDone = [ + { ...step1, completed: true }, + { ...step2, completed: true }, + { ...step3, completed: false }, // optional — doesn't block completion + ]; + render(); + await waitFor(() => + expect(screen.getByTestId("completion-banner")).toBeInTheDocument(), + ); + }); + + it("renders the sr-only announcement region", () => { + render(); + expect(screen.getByTestId("sr-announcement")).toBeInTheDocument(); + }); + }); + + // ── #810 Props / variants ───────────────────────────────────────────────── + describe("Props and variants", () => { + it("applies compact padding when compact=true", () => { + const { container } = render( + , + ); + expect(container.querySelector(".p-4")).toBeInTheDocument(); + }); + + it("applies horizontal flex layout when orientation='horizontal'", () => { + render(); + const list = screen.getByRole("list"); + expect(list).toHaveClass("flex-col"); + }); + + it("applies vertical (default) layout", () => { + render(); + const list = screen.getByRole("list"); + expect(list).toHaveClass("flex-col"); + }); + + it("hides step numbers when showStepNumbers=false", () => { + render(); + // Step 2 indicator should not show the numeral "2" + const btn = getStepBtn("Step 2: Step 2"); + expect(btn.textContent?.includes("2")).toBe(false); + }); + + it("applies a custom className to the root wrapper", () => { + const { container } = render( + , + ); + expect(container.firstChild).toHaveClass("custom-class"); + }); + }); + + // ── #810 Interactions ───────────────────────────────────────────────────── + describe("Interactions", () => { + it("calls onStepChange when a step is clicked", () => { + render(); + fireEvent.click(getStepBtn("Step 2: Step 2")); + expect(defaultProps.onStepChange).toHaveBeenCalledWith("2"); + }); + + it("calls onStepChange with the correct step id for step 1", () => { + render(); + fireEvent.click(getStepBtn("Step 1: Step 1")); + expect(defaultProps.onStepChange).toHaveBeenCalledWith("1"); + }); + }); + + // ── #810 Completion logic ───────────────────────────────────────────────── + describe("Completion logic", () => { + it("calls onComplete when all required steps are done", async () => { + const allRequired = [ + { ...step1, completed: true }, + { ...step2, completed: true }, + { ...step3, completed: false }, + ]; + render(); + await waitFor(() => expect(defaultProps.onComplete).toHaveBeenCalled()); + }); + + it("does not call onComplete when a required step is incomplete", () => { + render(); + expect(defaultProps.onComplete).not.toHaveBeenCalled(); + }); + + it("shows translated successTitle in the completion banner", async () => { + const allRequired = [ + { ...step1, completed: true }, + { ...step2, completed: true }, + { ...step3, completed: false }, + ]; + render(); + await waitFor(() => + expect(screen.getByText("successTitle")).toBeInTheDocument(), + ); + }); + + it("shows translated successMessage in the completion banner", async () => { + const allRequired = [ + { ...step1, completed: true }, + { ...step2, completed: true }, + { ...step3, completed: false }, + ]; + render(); + await waitFor(() => + expect(screen.getByText("successMessage")).toBeInTheDocument(), + ); + }); + }); + + // ── #811 Accessibility ──────────────────────────────────────────────────── + describe("Accessibility (#811)", () => { + it("wraps the tracker in a region landmark with translated aria-label", () => { + render(); + const region = screen.getByRole("region"); + expect(region).toHaveAttribute("aria-label", "progressTracker"); + }); + + it("sets aria-live='polite' on the region element", () => { + render(); + expect(screen.getByRole("region")).toHaveAttribute("aria-live", "polite"); + }); + + it("renders an aria-live status region for announcements", () => { + render(); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + + it("announces progress percentage on mount", () => { + render(); + const status = screen.getByRole("status"); + expect(status.textContent).toMatch(/33/); + }); + + it("labels the steps list with the translated aria-label", () => { + render(); + expect(screen.getByRole("list")).toHaveAttribute("aria-label", "stepsList"); + }); + + it("sets aria-current='step' on the active step button", () => { + render(); + const btn = getStepBtn("Step 1: Step 1"); + expect(btn).toHaveAttribute("aria-current", "step"); + }); + + it("does not set aria-current on non-active steps", () => { + render(); + const btn = getStepBtn("Step 2: Step 2"); + expect(btn).not.toHaveAttribute("aria-current"); + }); + + it("sets aria-setsize and aria-posinset on step buttons", () => { + render(); + const btn1 = getStepBtn("Step 1: Step 1"); + expect(btn1).toHaveAttribute("aria-setsize", "3"); + expect(btn1).toHaveAttribute("aria-posinset", "1"); + + const btn2 = getStepBtn("Step 2: Step 2"); + expect(btn2).toHaveAttribute("aria-posinset", "2"); + }); + + it("sets aria-roledescription='onboarding step' on step buttons", () => { + render(); + const btn = getStepBtn("Step 1: Step 1"); + expect(btn).toHaveAttribute("aria-roledescription", "onboarding step"); + }); + + it("completion banner has role='alert' and aria-live='polite'", async () => { + const allDone = [ + { ...step1, completed: true }, + { ...step2, completed: true }, + { ...step3, completed: false }, + ]; + render(); + await waitFor(() => { + const alert = screen.getByRole("alert"); + expect(alert).toHaveAttribute("aria-live", "polite"); + expect(alert).toHaveAttribute("aria-atomic", "true"); + }); + }); + + it("marks required asterisk with translated aria-label", () => { + render(); + const required = screen.getAllByLabelText("required"); + expect(required.length).toBeGreaterThan(0); + }); + + it("announces step details when a step button is clicked", async () => { + render(); + fireEvent.click(getStepBtn("Step 2: Step 2")); + const status = screen.getByRole("status"); + await waitFor(() => { + expect(status.textContent).toMatch(/Step 2/); + expect(status.textContent).toMatch(/Desc 2/); + }); + }); + }); + + // ── #812 Optimistic updates ─────────────────────────────────────────────── + describe("Optimistic updates (#812)", () => { + it("immediately reflects the clicked step as active before callback resolves", async () => { + let resolveCallback!: () => void; + const slowCb = vi.fn( + () => new Promise((res) => { resolveCallback = res; }), + ); + + render( + , + ); + + const btn2 = getStepBtn("Step 2: Step 2"); + fireEvent.click(btn2); + + await waitFor(() => + expect(btn2).toHaveAttribute("aria-current", "step"), + ); + + act(() => resolveCallback()); + }); + + it("confirms the optimistic update after the callback resolves", async () => { + const asyncCb = vi.fn(() => Promise.resolve()); + render( + , + ); + + const btn2 = getStepBtn("Step 2: Step 2"); + fireEvent.click(btn2); + + await waitFor(() => { + expect(asyncCb).toHaveBeenCalledWith("2"); + expect(btn2).toHaveAttribute("aria-current", "step"); + }); + }); + + it("rolls back to the previous step when the callback throws", async () => { + const failCb = vi.fn(() => Promise.reject(new Error("server error"))); + + render( + , + ); + + const btn1 = getStepBtn("Step 1: Step 1"); + const btn2 = getStepBtn("Step 2: Step 2"); + + fireEvent.click(btn2); + + await waitFor(() => { + expect(btn1).toHaveAttribute("aria-current", "step"); + expect(btn2).not.toHaveAttribute("aria-current"); + }); + }); + + it("announces a failure message to screen readers on rollback", async () => { + const failCb = vi.fn(() => Promise.reject(new Error("fail"))); + + render( + , + ); + + fireEvent.click(getStepBtn("Step 2: Step 2")); + + await waitFor(() => { + expect(screen.getByRole("status").textContent).toContain( + "stepChangeFailed", + ); + }); + }); + + it("sets aria-busy on the pending step button during an optimistic update", async () => { + let resolveCallback!: () => void; + const slowCb = vi.fn( + () => new Promise((res) => { resolveCallback = res; }), + ); + + render( + , + ); + + const btn2 = getStepBtn("Step 2: Step 2"); + fireEvent.click(btn2); + + await waitFor(() => + expect(btn2).toHaveAttribute("aria-busy", "true"), + ); + + act(() => resolveCallback()); + }); + + it("disables all step buttons while a step change is pending", async () => { + let resolveCallback!: () => void; + const slowCb = vi.fn( + () => new Promise((res) => { resolveCallback = res; }), + ); + + render( + , + ); + + fireEvent.click(getStepBtn("Step 2: Step 2")); + + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + buttons.forEach((btn) => expect(btn).toBeDisabled()); + }); + + act(() => resolveCallback()); + }); + }); + + // ── i18n ────────────────────────────────────────────────────────────────── + describe("i18n", () => { + it("uses the translated 'progressBar' key for the progressbar aria-label", () => { + render(); + const bar = screen.getByRole("progressbar"); + expect(bar).toHaveAttribute("aria-label", "progressBar"); + }); + + it("uses the translated 'stepsList' key for the list aria-label", () => { + render(); + expect(screen.getByRole("list")).toHaveAttribute("aria-label", "stepsList"); + }); + + it("uses the translated 'progressTracker' key for the region aria-label", () => { + render(); + expect(screen.getByRole("region")).toHaveAttribute( + "aria-label", + "progressTracker", + ); + }); + + it("displays translated 'allCompleted' text when onboarding is complete", async () => { + const allDone = [ + { ...step1, completed: true }, + { ...step2, completed: true }, + { ...step3, completed: false }, + ]; + render(); + await waitFor(() => + expect(screen.getByText("allCompleted")).toBeInTheDocument(), + ); + }); + }); + + // ── #809 Animations ─────────────────────────────────────────────────────── + describe("Animations (#809)", () => { + it("renders the progress bar fill element used for animation", () => { + render(); + expect(screen.getByTestId("progress-bar-fill")).toBeInTheDocument(); + }); + + it("renders the correct number of step list items", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); + }); + + // ── Hook integration ────────────────────────────────────────────────────── + describe("useOnboardingProgress integration", () => { + it("syncs an external currentStep prop change", async () => { + const { rerender } = render( + , + ); + + let btn1 = getStepBtn("Step 1: Step 1"); + expect(btn1).toHaveAttribute("aria-current", "step"); + + rerender( + , + ); + + await waitFor(() => { + const btn2 = getStepBtn("Step 2: Step 2"); + expect(btn2).toHaveAttribute("aria-current", "step"); + }); + }); + + it("updates progress percentage when a completed step is added", async () => { + const { rerender } = render( + , + ); + // 1 / 3 = 33% + expect(screen.getByTestId("progress-bar-fill")).toHaveStyle("width: 33%"); + + rerender( + , + ); + + await waitFor(() => { + // 2 / 3 = 67% + expect(screen.getByTestId("progress-bar-fill")).toHaveStyle("width: 67%"); + }); + }); + }); +}); diff --git a/frontend/src/components/OnboardingProgressTracker.tsx b/frontend/src/components/OnboardingProgressTracker.tsx index 4fb85f34..28ef948e 100644 --- a/frontend/src/components/OnboardingProgressTracker.tsx +++ b/frontend/src/components/OnboardingProgressTracker.tsx @@ -1,574 +1,594 @@ -"use client"; - -import React, { useCallback, useMemo, useEffect, useId, useReducer, useRef } from "react"; -import { motion, AnimatePresence, useReducedMotion, type Variants } from "framer-motion"; -import { useTranslations } from "next-intl"; -import { onboardingReducer, createInitialOnboardingState } from "./onboarding-reducer"; - -/** - * Step interface for onboarding progress - */ -interface OnboardingStep { - id: string; - title: string; - description: string; - completed: boolean; - required: boolean; - order: number; -} - -/** - * Props for OnboardingProgressTracker component - */ -interface OnboardingProgressTrackerProps { - steps: OnboardingStep[]; - currentStep?: string; - onStepChange?: (stepId: string) => void; - onComplete?: () => void; - showStepNumbers?: boolean; - orientation?: "vertical" | "horizontal"; - compact?: boolean; -} - -// --------------------------------------------------------------------------- -// Animation variants — #809 framer-motion animations -// --------------------------------------------------------------------------- - -/** - * Animation variants for step container — staggered children entrance - */ -const containerVariants: Variants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - delayChildren: 0.2, - }, - }, -}; - -/** - * Animation variants for individual steps — slide-in from left - */ -const stepVariants: Variants = { - hidden: { opacity: 0, x: -20 }, - visible: { - opacity: 1, - x: 0, - transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] }, - }, - exit: { - opacity: 0, - x: 20, - transition: { duration: 0.2 }, - }, -}; - -/** Reduced-motion safe step variants — no translate, only fade */ -const stepVariantsReduced: Variants = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.2 } }, - exit: { opacity: 0, transition: { duration: 0.1 } }, -}; - -/** - * Animation variants for progress bar — scale from left origin - */ -const progressBarVariants: Variants = { - hidden: { scaleX: 0, originX: 0 }, - visible: { - scaleX: 1, - originX: 0, - transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] }, - }, -}; - -/** Reduced-motion progress bar — just fade */ -const progressBarVariantsReduced: Variants = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.3 } }, -}; - -/** - * Animation variants for check mark — spring pop-in - */ -const checkMarkVariants: Variants = { - hidden: { scale: 0, opacity: 0 }, - visible: { - scale: 1, - opacity: 1, - transition: { - type: "spring", - stiffness: 260, - damping: 20, - delay: 0.2, - }, - }, -}; - -/** Reduced-motion check mark — simple fade */ -const checkMarkVariantsReduced: Variants = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.2 } }, -}; - -/** - * Animation variants for completion banner — slide up - */ -const completionVariants: Variants = { - hidden: { opacity: 0, y: 10 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.35, ease: "easeOut" } }, - exit: { opacity: 0, y: -10, transition: { duration: 0.2 } }, -}; - -/** Reduced-motion completion banner */ -const completionVariantsReduced: Variants = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.2 } }, - exit: { opacity: 0, transition: { duration: 0.1 } }, -}; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -/** - * OnboardingProgressTracker - * - * Displays onboarding progress with: - * - framer-motion animations (entrance, progress bar, check marks, completion) — #809 - * - Comprehensive unit-test surface (exported types, deterministic state) — #810 - * - Full screen-reader support: ARIA live regions, aria-roledescription, - * aria-setsize / aria-posinset, aria-valuenow on progress bar — #811 - * - Optimistic updates with rollback on step navigation — #812 - */ -export const OnboardingProgressTracker: React.FC = ({ - steps, - currentStep: currentStepProp, - onStepChange, - onComplete, - showStepNumbers = true, - orientation = "vertical", - compact = false, -}) => { - const t = useTranslations(); - const progressSummaryId = useId(); - - // Respect user's OS-level "reduce motion" preference — #809 - const prefersReducedMotion = useReducedMotion(); - - const [state, dispatch] = useReducer( - onboardingReducer, - createInitialOnboardingState(currentStepProp || steps[0]?.id), - ); - - // Track previous step for rollback — #812 - const previousStepRef = useRef(state.currentStep); - - /** Sort steps by order */ - const sortedSteps = useMemo( - () => [...steps].sort((a, b) => a.order - b.order), - [steps] - ); - - /** Number of completed steps for visible and assistive summaries */ - const completedStepsCount = useMemo( - () => sortedSteps.filter((s) => s.completed).length, - [sortedSteps], - ); - - /** Progress percentage based on completed steps */ - const progressPercentage = useMemo(() => { - if (sortedSteps.length === 0) return 0; - return Math.round((completedStepsCount / sortedSteps.length) * 100); - }, [completedStepsCount, sortedSteps.length]); - - /** Stable screen-reader summary for the tracker and progress bar */ - const progressSummary = `${completedStepsCount} of ${sortedSteps.length} steps completed. ${progressPercentage}% complete.`; - - /** True when all required steps are completed */ - const isOnboardingComplete = useMemo(() => { - const requiredSteps = sortedSteps.filter((s) => s.required); - return requiredSteps.length > 0 && requiredSteps.every((s) => s.completed); - }, [sortedSteps]); - - /** - * Effective current step — optimistic value takes precedence while pending — #812 - */ - const effectiveCurrentStep = state.optimisticStep ?? state.currentStep; - - /** - * Handle step click with optimistic update — #812 - * Immediately reflects the new step in UI, then calls the callback. - * If the callback throws, the optimistic update is rolled back. - */ - const handleStepClick = useCallback( - async (stepId: string) => { - if (state.isPending) return; - - const step = sortedSteps.find((s) => s.id === stepId); - if (!step) return; - - // Store previous step for potential rollback - previousStepRef.current = effectiveCurrentStep; - - // Optimistic update — UI responds immediately — #812 - dispatch({ type: "OPTIMISTIC_STEP", payload: stepId }); - - // Announce to screen readers immediately — #811 - const announcement = `${t("onboarding.stepProgress") || "Step"} ${step.order} of ${sortedSteps.length}: ${step.title}. ${step.description}. ${step.completed ? t("onboarding.completed") || "Completed" : t("onboarding.pending") || "Pending"}.`; - dispatch({ type: "SET_ANNOUNCEMENT", payload: announcement }); - - try { - // Call the callback (may be async / server-side) - await onStepChange?.(stepId); - // Confirm the optimistic update - dispatch({ type: "CONFIRM_STEP", payload: stepId }); - } catch { - // Rollback on failure — #812 - dispatch({ type: "ROLLBACK_STEP" }); - const rollbackAnnouncement = t("onboarding.stepChangeFailed") || "Step change failed. Please try again."; - dispatch({ type: "SET_ANNOUNCEMENT", payload: rollbackAnnouncement }); - } - }, - [sortedSteps, effectiveCurrentStep, onStepChange, state.isPending, t] - ); - - /** Announce completion and fire callback — #811 */ - useEffect(() => { - if (isOnboardingComplete && sortedSteps.length > 0) { - const announcement = t("onboarding.completed") || "Onboarding completed. All required steps are done."; - dispatch({ type: "SET_ANNOUNCEMENT", payload: announcement }); - onComplete?.(); - } - }, [isOnboardingComplete, sortedSteps.length, onComplete, t]); - - /** Announce progress percentage changes — #811 */ - useEffect(() => { - const msg = `${t("onboarding.progress") || "Progress"}: ${progressPercentage}% complete`; - dispatch({ type: "SET_ANNOUNCEMENT", payload: msg }); - }, [progressPercentage, t]); - - // Pick motion variants based on reduced-motion preference — #809 - const activeStepVariants = prefersReducedMotion ? stepVariantsReduced : stepVariants; - const activeProgressBarVariants = prefersReducedMotion ? progressBarVariantsReduced : progressBarVariants; - const activeCheckMarkVariants = prefersReducedMotion ? checkMarkVariantsReduced : checkMarkVariants; - const activeCompletionVariants = prefersReducedMotion ? completionVariantsReduced : completionVariants; - - return ( -
    -

    - {progressSummary} -

    - - {/* Screen reader live announcement area — #811 */} -
    - {state.announcementText} -
    - - {/* Pending indicator for screen readers — #812 */} - {state.isPending && ( -
    - {t("onboarding.updating") || "Updating step…"} -
    - )} - - {/* Container */} -
    - {/* Header */} -
    -
    -

    - {t("onboarding.title") || "Onboarding Progress"} -

    - -
    -

    - {t("onboarding.subtitle") || "Complete all required steps to finish setup"} -

    - - {/* Overall progress bar — animated with framer-motion — #809 */} -
    - -
    - - {/* Status text */} - -
    - - {/* Steps list — staggered entrance animation — #809 */} - - - {sortedSteps.map((step, index) => { - const isCurrentStep = effectiveCurrentStep === step.id; - const isPendingStep = state.isPending && isCurrentStep; - const stepDescriptionId = `${progressSummaryId}-step-${index + 1}-description`; - - return ( - - {/* Step indicator button */} - - - {/* Step content */} - -

    - {step.title} - {step.required && ( - - * - - )} -

    -

    - {step.description} -

    - - {/* Status badge — animated scale-in — #809 */} -
    - - {step.completed - ? t("onboarding.completed") || "Completed" - : isCurrentStep - ? t("onboarding.inProgress") || "In Progress" - : t("onboarding.pending") || "Pending"} - -
    -
    - - {/* Connector line (vertical orientation only) */} - {orientation === "vertical" && index < sortedSteps.length - 1 && ( - -
    - ); -}; - -export default OnboardingProgressTracker; +"use client"; + +/** + * OnboardingProgressTracker + * + * Refactored for: + * - Full i18n via the "onboarding" next-intl namespace + * - Dark mode support via .dark CSS variable overrides and Tailwind dark: variants + * - Mobile-first responsive layout (vertical default → horizontal on sm+) + * - Improved accessibility: ARIA live regions, aria-current, aria-busy, + * aria-setsize/aria-posinset, aria-roledescription, focus-visible rings + * - Optimistic step navigation with rollback (via useOnboardingProgress) + * - prefers-reduced-motion respected for all framer-motion animations + * - StepIcon sub-component for clean icon rendering + * - Connector lines between vertical steps + */ + +import React, { memo } from "react"; +import { + motion, + AnimatePresence, + useReducedMotion, + type Variants, +} from "framer-motion"; +import { useTranslations } from "next-intl"; +import { + useOnboardingProgress, + type OnboardingStep, +} from "@/hooks/useOnboardingProgress"; + +// ── Re-export types so consumers only need one import ───────────────────────── +export type { OnboardingStep }; + +// ── Props ───────────────────────────────────────────────────────────────────── + +export interface OnboardingProgressTrackerProps { + steps: OnboardingStep[]; + currentStep?: string; + onStepChange?: (stepId: string) => void | Promise; + onComplete?: () => void; + /** Show numeric labels inside the step indicator circles. Default: true. */ + showStepNumbers?: boolean; + /** Stack direction. Default: "vertical". */ + orientation?: "vertical" | "horizontal"; + /** Compact padding variant. Default: false. */ + compact?: boolean; + /** Optional extra className on the root wrapper. */ + className?: string; +} + +// ── Animation variants ──────────────────────────────────────────────────────── + +const containerVariants: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.08, delayChildren: 0.15 }, + }, +}; + +const stepVariants: Variants = { + hidden: { opacity: 0, x: -16 }, + visible: { opacity: 1, x: 0, transition: { duration: 0.35, ease: [0.16, 1, 0.3, 1] } }, + exit: { opacity: 0, x: 16, transition: { duration: 0.2 } }, +}; + +const stepVariantsReduced: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 0.15 } }, + exit: { opacity: 0, transition: { duration: 0.1 } }, +}; + +const progressBarVariants: Variants = { + hidden: { scaleX: 0, originX: 0 }, + visible: { scaleX: 1, originX: 0, transition: { duration: 0.55, ease: [0.16, 1, 0.3, 1] } }, +}; + +const progressBarVariantsReduced: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 0.25 } }, +}; + +const checkMarkVariants: Variants = { + hidden: { scale: 0, opacity: 0 }, + visible: { + scale: 1, opacity: 1, + transition: { type: "spring", stiffness: 280, damping: 22, delay: 0.15 }, + }, +}; + +const checkMarkVariantsReduced: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 0.15 } }, +}; + +const completionVariants: Variants = { + hidden: { opacity: 0, y: 12 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: "easeOut" } }, + exit: { opacity: 0, y: -8, transition: { duration: 0.2 } }, +}; + +const completionVariantsReduced: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 0.15 } }, + exit: { opacity: 0, transition: { duration: 0.1 } }, +}; + +// ── StepIcon sub-component ──────────────────────────────────────────────────── + +interface StepIconProps { + completed: boolean; + isCurrent: boolean; + isPending: boolean; + number: number; + showNumber: boolean; + compact: boolean; + checkVariants: Variants; + prefersReducedMotion: boolean | null; +} + +const StepIcon = memo(function StepIcon({ + completed, + isCurrent, + isPending, + number, + showNumber, + compact, + checkVariants, + prefersReducedMotion, +}: StepIconProps) { + const size = compact ? "h-8 w-8" : "h-10 w-10"; + + return ( + + {completed ? ( + + {/* Checkmark icon */} + + + ) : isPending ? ( + + + + ) : ( + + )} + + ); +}); + +// ── Status badge sub-component ──────────────────────────────────────────────── + +interface StatusBadgeProps { + completed: boolean; + isCurrent: boolean; + compact: boolean; + completedLabel: string; + inProgressLabel: string; + pendingLabel: string; + prefersReducedMotion: boolean | null; +} + +const StatusBadge = memo(function StatusBadge({ + completed, + isCurrent, + compact, + completedLabel, + inProgressLabel, + pendingLabel, + prefersReducedMotion, +}: StatusBadgeProps) { + const label = completed ? completedLabel : isCurrent ? inProgressLabel : pendingLabel; + + const colorClass = completed + ? "bg-pluto-100 text-pluto-800 dark:bg-pluto-900/40 dark:text-pluto-200" + : isCurrent + ? "bg-pluto-200 text-pluto-900 dark:bg-pluto-800/50 dark:text-pluto-100" + : "bg-pluto-50 text-pluto-700 dark:bg-pluto-900/20 dark:text-pluto-300 group-hover:bg-pluto-100 dark:group-hover:bg-pluto-900/40"; + + return ( + + {label} + + ); +}); + +// ── Main component ──────────────────────────────────────────────────────────── + +export const OnboardingProgressTracker = memo(function OnboardingProgressTracker({ + steps, + currentStep, + onStepChange, + onComplete, + showStepNumbers = true, + orientation = "vertical", + compact = false, + className = "", +}: OnboardingProgressTrackerProps) { + const t = useTranslations("onboarding"); + const prefersReducedMotion = useReducedMotion(); + + const { + sortedSteps, + effectiveCurrentStep, + state, + progressPercent, + completedCount, + isComplete, + progressSummaryId, + handleStepClick, + } = useOnboardingProgress({ steps, currentStep, onStepChange, onComplete }); + + // Pick motion variants based on reduced-motion preference + const activeStepVariants = prefersReducedMotion ? stepVariantsReduced : stepVariants; + const activeProgressBarVariants = prefersReducedMotion ? progressBarVariantsReduced : progressBarVariants; + const activeCheckMarkVariants = prefersReducedMotion ? checkMarkVariantsReduced : checkMarkVariants; + const activeCompletionVariants = prefersReducedMotion ? completionVariantsReduced : completionVariants; + + // Translated step aria-label builder + const buildStepAriaLabel = (step: OnboardingStep, index: number): string => { + if (step.completed && step.required) { + return t("stepLabelCompletedRequired", { number: index + 1, title: step.title }); + } + if (step.completed) { + return t("stepLabelCompleted", { number: index + 1, title: step.title }); + } + if (step.required) { + return t("stepLabelRequired", { number: index + 1, title: step.title }); + } + return t("stepLabel", { number: index + 1, title: step.title }); + }; + + return ( +
    + {/* Hidden progress summary for AT */} +

    + {t("stepsCompleted", { completed: completedCount, total: sortedSteps.length })}{" "} + {t("percentComplete", { percent: progressPercent })} +

    + + {/* Screen-reader live announcement region */} +
    + {state.announcementText} +
    + + {/* Pending spinner announcement */} + {state.isPending && ( +
    + {t("updating")} +
    + )} + + {/* ── Card ──────────────────────────────────────────────────────────── */} +
    + {/* ── Header ──────────────────────────────────────────────────────── */} +
    +
    +

    + {t("title")} +

    + +
    + +

    + {t("subtitle")} +

    + + {/* Progress bar */} +
    + +
    + + {/* Step count summary */} + +
    + + {/* ── Steps list ───────────────────────────────────────────────────── */} + + + {sortedSteps.map((step, index) => { + const isCurrent = effectiveCurrentStep === step.id; + const isPending = state.isPending && isCurrent; + const stepDescId = `${progressSummaryId}-desc-${index}`; + + const indicatorColor = step.completed + ? "border-pluto-500 bg-pluto-100 text-pluto-800 shadow-[0_4px_12px_rgba(74,111,165,0.18)] dark:border-pluto-400 dark:bg-pluto-800/60 dark:text-pluto-100" + : isCurrent + ? "border-pluto-600 bg-pluto-50 text-pluto-700 shadow-[0_4px_12px_rgba(74,111,165,0.14)] dark:border-pluto-400 dark:bg-pluto-900/60 dark:text-pluto-200" + : "border-pluto-200 bg-white text-pluto-600 dark:border-pluto-700 dark:bg-pluto-900/40 dark:text-pluto-400 group-hover:border-pluto-400 group-hover:bg-pluto-50 group-hover:shadow-[0_4px_12px_rgba(13,27,46,0.08)] dark:group-hover:border-pluto-500 dark:group-hover:bg-pluto-800/50"; + + return ( + + {/* Step indicator button */} + + + {/* Step content */} +
    +

    + {step.title} + {step.required && ( + + * + + )} +

    + +

    + {step.description} +

    + + +
    + + {/* Vertical connector line */} + {orientation === "vertical" && index < sortedSteps.length - 1 && ( + +
    + ); +}); + +export default OnboardingProgressTracker; diff --git a/frontend/src/components/onboarding-reducer.ts b/frontend/src/components/onboarding-reducer.ts index e08575a5..2cfaf094 100644 --- a/frontend/src/components/onboarding-reducer.ts +++ b/frontend/src/components/onboarding-reducer.ts @@ -1,52 +1,121 @@ -/** - * State logic for the Onboarding Progress Tracker, extracted from the component - * so the optimistic-update lifecycle (optimistic → confirm/rollback) is a pure, - * independently testable unit rather than being tangled with the view. - */ - -export interface OnboardingState { - currentStep: string | undefined; - /** Optimistic step id set immediately on click before the change is confirmed. */ - optimisticStep: string | undefined; - announcementText: string; - /** Whether an optimistic update is awaiting confirmation. */ - isPending: boolean; -} - -export type OnboardingAction = - | { type: "SET_CURRENT_STEP"; payload: string } - | { type: "OPTIMISTIC_STEP"; payload: string } - | { type: "CONFIRM_STEP"; payload: string } - | { type: "ROLLBACK_STEP" } - | { type: "SET_ANNOUNCEMENT"; payload: string }; - -export function createInitialOnboardingState( - currentStep?: string, -): OnboardingState { - return { - currentStep, - optimisticStep: undefined, - announcementText: "", - isPending: false, - }; -} - -export function onboardingReducer( - state: OnboardingState, - action: OnboardingAction, -): OnboardingState { - switch (action.type) { - case "SET_CURRENT_STEP": - return { ...state, currentStep: action.payload, optimisticStep: undefined, isPending: false }; - case "OPTIMISTIC_STEP": - return { ...state, optimisticStep: action.payload, isPending: true }; - case "CONFIRM_STEP": - return { ...state, currentStep: action.payload, optimisticStep: undefined, isPending: false }; - case "ROLLBACK_STEP": - return { ...state, optimisticStep: undefined, isPending: false }; - case "SET_ANNOUNCEMENT": - return { ...state, announcementText: action.payload }; - default: - return state; - } -} +/** + * State logic for the Onboarding Progress Tracker. + * + * Extracted from the component so the optimistic-update lifecycle + * (optimistic → confirm/rollback) is a pure, independently testable unit. + * + * Refactor notes: + * - Added SYNC_STEPS action so parent-driven step updates (e.g. server + * re-validation) can be applied without a full remount. + * - Stricter readonly types on state members to prevent accidental mutation. + * - SET_CURRENT_STEP retained for backward compatibility. + */ + +// ── Public types ───────────────────────────────────────────────────────────── + +export interface OnboardingState { + /** The last confirmed current step id. */ + readonly currentStep: string | undefined; + /** Optimistic step id set immediately on click, before the callback confirms. */ + readonly optimisticStep: string | undefined; + /** Text to announce to screen readers via an aria-live region. */ + readonly announcementText: string; + /** True while an optimistic update is awaiting confirmation. */ + readonly isPending: boolean; + /** Total number of steps last synced from props — used for progress math. */ + readonly totalSteps: number; + /** Number of completed steps last synced from props. */ + readonly completedSteps: number; +} + +export type OnboardingAction = + | { type: "SET_CURRENT_STEP"; payload: string } + | { type: "OPTIMISTIC_STEP"; payload: string } + | { type: "CONFIRM_STEP"; payload: string } + | { type: "ROLLBACK_STEP" } + | { type: "SET_ANNOUNCEMENT"; payload: string } + /** Sync derived counts when the steps prop changes so components downstream + * can re-compute progress without traversing the full array. */ + | { type: "SYNC_STEPS"; payload: { total: number; completed: number } }; + +// ── Factory ─────────────────────────────────────────────────────────────────── + +export function createInitialOnboardingState( + currentStep?: string, + totalSteps = 0, + completedSteps = 0, +): OnboardingState { + return { + currentStep, + optimisticStep: undefined, + announcementText: "", + isPending: false, + totalSteps, + completedSteps, + }; +} + +// ── Reducer ─────────────────────────────────────────────────────────────────── + +export function onboardingReducer( + state: OnboardingState, + action: OnboardingAction, +): OnboardingState { + switch (action.type) { + case "SET_CURRENT_STEP": + return { + ...state, + currentStep: action.payload, + optimisticStep: undefined, + isPending: false, + }; + + case "OPTIMISTIC_STEP": + return { + ...state, + optimisticStep: action.payload, + isPending: true, + }; + + case "CONFIRM_STEP": + return { + ...state, + currentStep: action.payload, + optimisticStep: undefined, + isPending: false, + }; + + case "ROLLBACK_STEP": + return { + ...state, + optimisticStep: undefined, + isPending: false, + }; + + case "SET_ANNOUNCEMENT": + return { ...state, announcementText: action.payload }; + + case "SYNC_STEPS": + return { + ...state, + totalSteps: action.payload.total, + completedSteps: action.payload.completed, + }; + + default: + return state; + } +} + +// ── Selectors (pure helpers, safe to import anywhere) ───────────────────────── + +/** The step id that should be treated as active, accounting for optimism. */ +export function selectEffectiveStep(state: OnboardingState): string | undefined { + return state.optimisticStep ?? state.currentStep; +} + +/** Progress percentage clamped to [0, 100]. */ +export function selectProgressPercent(state: OnboardingState): number { + if (state.totalSteps === 0) return 0; + return Math.min(100, Math.round((state.completedSteps / state.totalSteps) * 100)); +} diff --git a/frontend/src/hooks/useOnboardingProgress.ts b/frontend/src/hooks/useOnboardingProgress.ts new file mode 100644 index 00000000..7490aba8 --- /dev/null +++ b/frontend/src/hooks/useOnboardingProgress.ts @@ -0,0 +1,212 @@ +/** + * useOnboardingProgress + * + * Encapsulates all stateful logic for the Onboarding Progress Tracker so the + * component stays a pure presentation layer. + * + * Responsibilities: + * - Owns the useReducer instance and exposes read-only derived values. + * - Handles optimistic step navigation with async callback + rollback. + * - Syncs external prop changes (steps array) into reducer via SYNC_STEPS. + * - Produces translated screen-reader announcement strings. + * - Fires onComplete when all required steps are done. + */ + +"use client"; + +import { + useCallback, + useEffect, + useId, + useMemo, + useReducer, + useRef, +} from "react"; +import { useTranslations } from "next-intl"; +import { + onboardingReducer, + createInitialOnboardingState, + selectEffectiveStep, + selectProgressPercent, + type OnboardingState, +} from "@/components/onboarding-reducer"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface OnboardingStep { + id: string; + title: string; + description: string; + completed: boolean; + required: boolean; + order: number; +} + +export interface UseOnboardingProgressOptions { + steps: OnboardingStep[]; + currentStep?: string; + onStepChange?: (stepId: string) => void | Promise; + onComplete?: () => void; +} + +export interface UseOnboardingProgressReturn { + /** Steps sorted by `order`. */ + sortedSteps: OnboardingStep[]; + /** The step id currently treated as active (optimistic-aware). */ + effectiveCurrentStep: string | undefined; + /** Reducer state snapshot — read-only. */ + state: OnboardingState; + /** 0–100 progress percentage. */ + progressPercent: number; + /** Number of completed steps. */ + completedCount: number; + /** True when all required steps are completed. */ + isComplete: boolean; + /** Stable id for the sr-only progress summary element. */ + progressSummaryId: string; + /** Handle a step button click with optimistic update + rollback. */ + handleStepClick: (stepId: string) => Promise; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export function useOnboardingProgress({ + steps, + currentStep: currentStepProp, + onStepChange, + onComplete, +}: UseOnboardingProgressOptions): UseOnboardingProgressReturn { + const t = useTranslations("onboarding"); + const progressSummaryId = useId(); + + // ── Derived step data ──────────────────────────────────────────────────── + + const sortedSteps = useMemo( + () => [...steps].sort((a, b) => a.order - b.order), + [steps], + ); + + const completedCount = useMemo( + () => sortedSteps.filter((s) => s.completed).length, + [sortedSteps], + ); + + const isComplete = useMemo(() => { + const required = sortedSteps.filter((s) => s.required); + return required.length > 0 && required.every((s) => s.completed); + }, [sortedSteps]); + + // ── Reducer ────────────────────────────────────────────────────────────── + + const [state, dispatch] = useReducer( + onboardingReducer, + createInitialOnboardingState( + currentStepProp ?? sortedSteps[0]?.id, + sortedSteps.length, + completedCount, + ), + ); + + // Keep derived counts in sync when the steps prop changes. + useEffect(() => { + dispatch({ + type: "SYNC_STEPS", + payload: { total: sortedSteps.length, completed: completedCount }, + }); + }, [sortedSteps.length, completedCount]); + + // Sync external currentStep prop changes (e.g. parent navigates). + const prevCurrentStepProp = useRef(currentStepProp); + useEffect(() => { + if ( + currentStepProp !== undefined && + currentStepProp !== prevCurrentStepProp.current + ) { + dispatch({ type: "SET_CURRENT_STEP", payload: currentStepProp }); + prevCurrentStepProp.current = currentStepProp; + } + }, [currentStepProp]); + + // ── Derived values ──────────────────────────────────────────────────────── + + const effectiveCurrentStep = selectEffectiveStep(state); + const progressPercent = selectProgressPercent(state); + + // ── Completion side-effect ──────────────────────────────────────────────── + + const onCompleteRef = useRef(onComplete); + onCompleteRef.current = onComplete; + + useEffect(() => { + if (isComplete && sortedSteps.length > 0) { + dispatch({ + type: "SET_ANNOUNCEMENT", + payload: t("successTitle"), + }); + onCompleteRef.current?.(); + } + }, [isComplete, sortedSteps.length, t]); + + // ── Progress announcements ──────────────────────────────────────────────── + + useEffect(() => { + dispatch({ + type: "SET_ANNOUNCEMENT", + payload: t("progressAnnouncement", { percent: progressPercent }), + }); + }, [progressPercent, t]); + + // ── Step click handler ──────────────────────────────────────────────────── + + const handleStepClick = useCallback( + async (stepId: string) => { + if (state.isPending) return; + + const step = sortedSteps.find((s) => s.id === stepId); + if (!step) return; + + // Optimistic: reflect change immediately. + dispatch({ type: "OPTIMISTIC_STEP", payload: stepId }); + + const statusKey = step.completed + ? "completed" + : effectiveCurrentStep === stepId + ? "inProgress" + : "pending"; + + dispatch({ + type: "SET_ANNOUNCEMENT", + payload: t("stepAnnouncement", { + number: step.order, + total: sortedSteps.length, + title: step.title, + description: step.description, + status: t(statusKey), + }), + }); + + try { + await onStepChange?.(stepId); + dispatch({ type: "CONFIRM_STEP", payload: stepId }); + } catch { + dispatch({ type: "ROLLBACK_STEP" }); + dispatch({ + type: "SET_ANNOUNCEMENT", + payload: t("stepChangeFailed"), + }); + } + }, + [sortedSteps, effectiveCurrentStep, onStepChange, state.isPending, t], + ); + + return { + sortedSteps, + effectiveCurrentStep, + state, + progressPercent, + completedCount, + isComplete, + progressSummaryId, + handleStepClick, + }; +}