From 0fd2fefd281aef75989d725e6dd383e840f636d0 Mon Sep 17 00:00:00 2001 From: dumbdev Date: Sun, 26 Jul 2026 00:14:11 +0100 Subject: [PATCH] feat(frontend): implement internationalization (i18n) support for 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 | 75 +- .../OnboardingProgressTracker.test.tsx | 600 +++++++++------ .../components/OnboardingProgressTracker.tsx | 720 +++++++++--------- frontend/src/components/onboarding-reducer.ts | 88 ++- frontend/src/hooks/useOnboardingI18n.ts | 149 ++++ frontend/src/hooks/useOnboardingProgress.ts | 195 +++++ 9 files changed, 1293 insertions(+), 621 deletions(-) create mode 100644 frontend/src/hooks/useOnboardingI18n.ts 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..ffb4551d 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -1,39 +1,102 @@ import RegistrationForm from "@/components/RegistrationForm"; import Link from "next/link"; import GuestGuard from "@/components/GuestGuard"; +import { OnboardingProgressTracker, type OnboardingStep } from "@/components/OnboardingProgressTracker"; + +/** + * Demo steps shown on the registration page so users understand the full + * onboarding journey before they submit the form. + * Defined as a server-side constant — zero runtime cost. + */ +const ONBOARDING_STEPS: OnboardingStep[] = [ + { + 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 payment 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, + }, +]; 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 ──────────────────────────────────── */} + + + {/* ── 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..e277147b 100644 --- a/frontend/src/components/OnboardingProgressTracker.test.tsx +++ b/frontend/src/components/OnboardingProgressTracker.test.tsx @@ -1,13 +1,15 @@ /** * @vitest-environment jsdom * - * Unit tests for OnboardingProgressTracker + * Unit tests for OnboardingProgressTracker (i18n refactor) * * 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 + * - #809 framer-motion animation variants and reduced-motion support + * - #810 rendering, props, interactions, completion + * - #811 screen-reader / accessibility attributes + * - #812 optimistic updates and rollback + * - i18n useOnboardingI18n hook integration (translated strings) + * - hook useOnboardingProgress (SYNC_STEPS, external currentStep sync) */ import { describe, it, expect, beforeEach, vi } from "vitest"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; @@ -19,33 +21,48 @@ import React from "react"; // Mocks // --------------------------------------------------------------------------- +/** + * next-intl: return the key (with inline {token} substitution) so assertions + * are locale-independent and don't depend on the real message catalogue. + */ vi.mock("next-intl", () => ({ - useTranslations: () => (key: string) => key, + useTranslations: () => (key: string, params?: Record) => { + if (!params) return key; + return Object.entries(params).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), + key, + ); + }, })); -// Mock framer-motion — renders plain HTML elements so tests stay fast — #809 +/** + * framer-motion: strip animation props and render plain HTML so tests run fast + * and don't depend on JSDOM animation support. (#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}, + motion: new Proxy( + {}, + { + get: (_t, tag: string) => + React.forwardRef( + ( + { + children, + animate, + variants, + initial, + exit, + transition, + whileHover, + whileTap, + ...rest + }: any, + ref: any, + ) => React.createElement(tag, { ...rest, ref }, children), + ), + }, + ), + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, useReducedMotion: () => false, })); @@ -53,32 +70,20 @@ vi.mock("framer-motion", () => ({ // 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 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, @@ -86,46 +91,56 @@ const defaultProps = { onComplete: vi.fn(), }; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Find a step button using a fragment of its translated aria-label. + * The mock translates e.g. "stepLabelCompletedRequired" → + * "stepLabelCompletedRequired" (the key), but our assertions match on title. + */ +const getStepBtn = (fragment: string) => + screen.getByRole("button", { name: new RegExp(fragment, "i") }); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("OnboardingProgressTracker", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => vi.clearAllMocks()); - // ------------------------------------------------------------------------- - // #810 — Rendering - // ------------------------------------------------------------------------- + // ── #810 Rendering ──────────────────────────────────────────────────────── describe("Rendering", () => { - it("renders the component with the correct title", () => { + it("renders the translated title key", () => { render(); - expect(screen.getByText("onboarding.title")).toBeInTheDocument(); + expect(screen.getByText("title")).toBeInTheDocument(); }); - it("renders all steps with correct titles and descriptions", () => { + it("renders the translated subtitle key", () => { render(); - mockSteps.forEach((step) => { - expect(screen.getByText(step.title)).toBeInTheDocument(); - expect(screen.getByText(step.description)).toBeInTheDocument(); - }); + expect(screen.getByText("subtitle")).toBeInTheDocument(); }); - it("shows the correct progress percentage", () => { + it("renders all step titles and descriptions", () => { render(); - // 1 of 3 completed = 33 % - expect(screen.getByText("33%")).toBeInTheDocument(); + mockSteps.forEach((s) => { + expect(screen.getByText(s.title)).toBeInTheDocument(); + expect(screen.getByText(s.description)).toBeInTheDocument(); + }); }); it("renders the progress bar fill element", () => { render(); - const fill = screen.getByTestId("progress-bar-fill"); - expect(fill).toBeInTheDocument(); - expect(fill).toHaveStyle("width: 33%"); + expect(screen.getByTestId("progress-bar-fill")).toBeInTheDocument(); }); - it("renders the progress bar with correct ARIA attributes", () => { + it("sets width on progress bar fill to 33%", () => { + render(); + expect(screen.getByTestId("progress-bar-fill")).toHaveStyle("width: 33%"); + }); + + it("sets correct ARIA attributes on the progressbar element", () => { render(); const bar = screen.getByRole("progressbar"); expect(bar).toHaveAttribute("aria-valuenow", "33"); @@ -140,82 +155,88 @@ describe("OnboardingProgressTracker", () => { 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 + { ...step1, completed: true }, + { ...step2, completed: true }, + { ...step3, completed: false }, // optional — does not block completion ]; render(); - await waitFor(() => { - expect(screen.getByTestId("completion-banner")).toBeInTheDocument(); - }); + 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("renders the sr-only announcement region", () => { + render(); + expect(screen.getByTestId("sr-announcement")).toBeInTheDocument(); }); + }); - it("applies compact padding class when compact prop is true", () => { + // ── #810 Props / variants ───────────────────────────────────────────────── + describe("Props and variants", () => { + it("applies compact padding class when compact=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 the correct number of list items", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(3); }); - it("renders a vertical list by default", () => { - render(); - const list = screen.getByRole("list"); - expect(list).not.toHaveClass("flex"); + it("applies an extra className to the root wrapper", () => { + const { container } = render( + , + ); + expect(container.firstChild).toHaveClass("extra-class"); }); }); - // ------------------------------------------------------------------------- - // #810 — Interactions - // ------------------------------------------------------------------------- + // ── #810 Interactions ───────────────────────────────────────────────────── describe("Interactions", () => { - it("calls onStepChange when a step button is clicked", () => { + it("calls onStepChange when a step button is clicked", async () => { render(); - const btn = screen.getByLabelText(/Step 2: Step 2. Required/i); - fireEvent.click(btn); - expect(defaultProps.onStepChange).toHaveBeenCalledWith("2"); + // Step 2 aria-label uses the "stepLabelRequired" key → "stepLabelRequired" + // Our mock substitutes {number} and {title}: "Step 2: Step 2. Required" + const btn2 = screen.getAllByRole("button")[1]; // second step + fireEvent.click(btn2); + await waitFor(() => + 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"); + it("does not call onStepChange while another step change is pending", async () => { + let resolveCallback!: () => void; + const slowCb = vi.fn( + () => new Promise((res) => { resolveCallback = res; }), + ); + render( + , + ); + + const buttons = screen.getAllByRole("button"); + fireEvent.click(buttons[1]); // click step 2 + + await waitFor(() => expect(buttons[0]).toBeDisabled()); + + fireEvent.click(buttons[2]); // try clicking step 3 while pending + expect(slowCb).toHaveBeenCalledTimes(1); + + act(() => resolveCallback()); }); }); - // ------------------------------------------------------------------------- - // #810 — Completion logic - // ------------------------------------------------------------------------- + // ── #810 Completion ─────────────────────────────────────────────────────── 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 }, + { ...step1, completed: true }, + { ...step2, completed: true }, + { ...step3, completed: false }, ]; render(); - await waitFor(() => { - expect(defaultProps.onComplete).toHaveBeenCalled(); - }); + await waitFor(() => expect(defaultProps.onComplete).toHaveBeenCalled()); }); it("does not call onComplete when a required step is incomplete", () => { @@ -223,102 +244,103 @@ describe("OnboardingProgressTracker", () => { expect(defaultProps.onComplete).not.toHaveBeenCalled(); }); - it("shows success title in completion banner", async () => { + it("renders the translated successTitle in the completion banner", async () => { const allRequired = [ - { ...mockSteps[0], completed: true }, - { ...mockSteps[1], completed: true }, - { ...mockSteps[2], completed: false }, + { ...step1, completed: true }, + { ...step2, completed: true }, + { ...step3, completed: false }, ]; render(); - await waitFor(() => { - expect(screen.getByText("onboarding.successTitle")).toBeInTheDocument(); - }); + await waitFor(() => + expect(screen.getByText("successTitle")).toBeInTheDocument(), + ); }); - it("does not show success title when onboarding is incomplete", () => { - render(); - expect(screen.queryByText("onboarding.successTitle")).not.toBeInTheDocument(); + it("renders the 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 / screen reader support - // ------------------------------------------------------------------------- - describe("Accessibility — screen reader support (#811)", () => { - it("wraps the tracker in a region landmark with aria-label", () => { + // ── #811 Accessibility ──────────────────────────────────────────────────── + describe("Accessibility (#811)", () => { + it("wraps tracker in a region with translated aria-label", () => { render(); - const region = screen.getByRole("region"); - expect(region).toHaveAttribute("aria-label", "onboarding.progressTracker"); + expect(screen.getByRole("region")).toHaveAttribute( + "aria-label", + "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(); + expect(screen.getByRole("region")).toHaveAttribute("aria-live", "polite"); }); - it("announces progress percentage on mount", () => { + it("provides an aria-live status region for announcements", () => { render(); - const status = screen.getByRole("status"); - expect(status.textContent).toContain("33"); + expect(screen.getByRole("status")).toBeInTheDocument(); }); - it("announces step details when a step is clicked", async () => { + it("announces progress on mount", () => { 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"); - }); + expect(screen.getByRole("status").textContent).toMatch(/33/); }); - it("labels the steps list with aria-label", () => { + it("labels the steps list with the translated key", () => { render(); - const list = screen.getByRole("list"); - expect(list).toHaveAttribute("aria-label", "onboarding.stepsList"); + expect(screen.getByRole("list")).toHaveAttribute("aria-label", "stepsList"); }); - it("marks the current step with aria-current='step'", () => { + it("sets aria-current='step' on the active step", () => { render(); - const btn = screen.getByLabelText(/Step 1: Step 1. Completed. Required/i); - expect(btn).toHaveAttribute("aria-current", "step"); + expect(screen.getAllByRole("button")[0]).toHaveAttribute( + "aria-current", + "step", + ); }); - it("does not set aria-current on non-current steps", () => { + it("does not set aria-current on inactive steps", () => { render(); - const btn = screen.getByLabelText(/Step 2: Step 2. Required/i); - expect(btn).not.toHaveAttribute("aria-current"); + expect(screen.getAllByRole("button")[1]).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"); + const buttons = screen.getAllByRole("button"); + expect(buttons[0]).toHaveAttribute("aria-setsize", "3"); + expect(buttons[0]).toHaveAttribute("aria-posinset", "1"); + expect(buttons[1]).toHaveAttribute("aria-posinset", "2"); + expect(buttons[2]).toHaveAttribute("aria-posinset", "3"); }); 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"); + screen.getAllByRole("button").forEach((btn) => { + expect(btn).toHaveAttribute("aria-roledescription", "onboarding step"); + }); + }); + + it("marks the progressbar with translated aria-label", () => { + render(); + expect(screen.getByRole("progressbar")).toHaveAttribute( + "aria-label", + "progressBar", + ); }); 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 }, + { ...step1, completed: true }, + { ...step2, completed: true }, + { ...step3, completed: false }, ]; render(); await waitFor(() => { @@ -328,152 +350,238 @@ describe("OnboardingProgressTracker", () => { }); }); - it("marks required asterisk with aria-label", () => { + it("marks required asterisk with translated aria-label", () => { render(); - const requiredMarkers = screen.getAllByLabelText("onboarding.required"); - expect(requiredMarkers.length).toBeGreaterThan(0); + // Both step1 and step2 are required → two * elements + expect(screen.getAllByLabelText("required").length).toBeGreaterThan(0); + }); + + it("announces step details when a step button is clicked", async () => { + render(); + fireEvent.click(screen.getAllByRole("button")[1]); + await waitFor(() => { + expect(screen.getByRole("status").textContent).toMatch(/Step 2|Desc 2/); + }); }); }); - // ------------------------------------------------------------------------- - // #812 — Optimistic updates - // ------------------------------------------------------------------------- + // ── #812 Optimistic updates ─────────────────────────────────────────────── describe("Optimistic updates (#812)", () => { - it("immediately reflects the clicked step as current before callback resolves", async () => { + it("immediately marks the clicked step as current before callback resolves", async () => { let resolveCallback!: () => void; - const slowCallback = vi.fn( - () => new Promise((res) => { resolveCallback = res; }) + const slowCb = vi.fn( + () => new Promise((res) => { resolveCallback = res; }), ); - render( + />, ); - const btn2 = screen.getByLabelText(/Step 2: Step 2. Required/i); + const btn2 = screen.getAllByRole("button")[1]; fireEvent.click(btn2); - - // Optimistic: step 2 should be current immediately - await waitFor(() => { - expect(btn2).toHaveAttribute("aria-current", "step"); - }); - - // Resolve the callback + await waitFor(() => + expect(btn2).toHaveAttribute("aria-current", "step"), + ); act(() => resolveCallback()); }); it("confirms the optimistic update after callback resolves", async () => { - const asyncCallback = vi.fn(() => Promise.resolve()); + const asyncCb = vi.fn(() => Promise.resolve()); render( + />, ); - const btn2 = screen.getByLabelText(/Step 2: Step 2. Required/i); + const btn2 = screen.getAllByRole("button")[1]; fireEvent.click(btn2); - await waitFor(() => { - expect(asyncCallback).toHaveBeenCalledWith("2"); + expect(asyncCb).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"))); - + 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 = screen.getByLabelText(/Step 1: Step 1. Completed. Required/i); - const btn2 = screen.getByLabelText(/Step 2: Step 2. Required/i); + const btn1 = screen.getAllByRole("button")[0]; + const btn2 = screen.getAllByRole("button")[1]; 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"))); - + it("announces the translated stepChangeFailed key on rollback", async () => { + const failCb = vi.fn(() => Promise.reject(new Error("fail"))); render( + />, ); - const btn2 = screen.getByLabelText(/Step 2: Step 2. Required/i); - fireEvent.click(btn2); - + fireEvent.click(screen.getAllByRole("button")[1]); await waitFor(() => { - const status = screen.getByRole("status"); - expect(status.textContent).toContain("onboarding.stepChangeFailed"); + expect(screen.getByRole("status").textContent).toContain( + "stepChangeFailed", + ); }); }); - it("sets aria-busy on the pending step button during optimistic update", async () => { + it("sets aria-busy on the pending step button", async () => { let resolveCallback!: () => void; - const slowCallback = vi.fn( - () => new Promise((res) => { resolveCallback = res; }) + const slowCb = vi.fn( + () => new Promise((res) => { resolveCallback = res; }), ); - render( + />, ); - const btn2 = screen.getByLabelText(/Step 2: Step 2. Required/i); + const btn2 = screen.getAllByRole("button")[1]; fireEvent.click(btn2); + await waitFor(() => expect(btn2).toHaveAttribute("aria-busy", "true")); + act(() => resolveCallback()); + }); + + it("disables all step buttons while pending", async () => { + let resolveCallback!: () => void; + const slowCb = vi.fn( + () => new Promise((res) => { resolveCallback = res; }), + ); + render( + , + ); + fireEvent.click(screen.getAllByRole("button")[1]); await waitFor(() => { - expect(btn2).toHaveAttribute("aria-busy", "true"); + screen.getAllByRole("button").forEach((btn) => + expect(btn).toBeDisabled(), + ); }); - act(() => resolveCallback()); }); }); - // ------------------------------------------------------------------------- - // #809 — Animation / reduced-motion - // ------------------------------------------------------------------------- + // ── i18n ────────────────────────────────────────────────────────────────── + describe("i18n — useOnboardingI18n", () => { + it("uses 'progressTracker' key for region aria-label", () => { + render(); + expect(screen.getByRole("region")).toHaveAttribute( + "aria-label", + "progressTracker", + ); + }); + + it("uses 'progressBar' key for progressbar aria-label", () => { + render(); + expect(screen.getByRole("progressbar")).toHaveAttribute( + "aria-label", + "progressBar", + ); + }); + + it("uses 'stepsList' key for list aria-label", () => { + render(); + expect(screen.getByRole("list")).toHaveAttribute("aria-label", "stepsList"); + }); + + it("renders allCompleted when onboarding is complete", async () => { + const allDone = [ + { ...step1, completed: true }, + { ...step2, completed: true }, + { ...step3, completed: false }, + ]; + render(); + await waitFor(() => + expect(screen.getByText("allCompleted")).toBeInTheDocument(), + ); + }); + + it("uses parameterised percentComplete key in header badge", () => { + render(); + // 1 of 3 = 33 — mock produces "percentComplete" with {percent} substituted + expect(screen.getByText(/33/)).toBeInTheDocument(); + }); + + it("uses parameterised stepsCompleted key in summary line", () => { + render(); + // mock renders "stepsCompleted" with tokens substituted + expect(screen.getAllByText(/stepsCompleted|1.*3/i).length).toBeGreaterThan(0); + }); + }); + + // ── #809 Animations ─────────────────────────────────────────────────────── describe("Animations (#809)", () => { - it("renders the progress bar fill element used for animation", () => { + it("renders the progress bar fill element", () => { render(); expect(screen.getByTestId("progress-bar-fill")).toBeInTheDocument(); }); - it("renders step list items that carry animation variant props", () => { + it("renders all step list items", () => { render(); - // All 3 steps should be in the DOM - const items = screen.getAllByRole("listitem"); - expect(items).toHaveLength(3); + expect(screen.getAllByRole("listitem")).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(); + // ── Hook integration (SYNC_STEPS / currentStep prop sync) ───────────────── + describe("useOnboardingProgress integration", () => { + it("syncs an external currentStep prop change", async () => { + const { rerender } = render( + , + ); + expect(screen.getAllByRole("button")[0]).toHaveAttribute("aria-current", "step"); + + rerender(); + + await waitFor(() => + expect(screen.getAllByRole("button")[1]).toHaveAttribute("aria-current", "step"), + ); + }); + + it("updates progress percentage when a completed step is added via SYNC_STEPS", async () => { + const { rerender } = render( + , + ); + expect(screen.getByTestId("progress-bar-fill")).toHaveStyle("width: 33%"); + + rerender( + , + ); + + await waitFor(() => + 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..e60b72df 100644 --- a/frontend/src/components/OnboardingProgressTracker.tsx +++ b/frontend/src/components/OnboardingProgressTracker.tsx @@ -1,274 +1,298 @@ "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 + * OnboardingProgressTracker + * + * Refactored for full i18n support via the "onboarding" next-intl namespace. + * + * Additional improvements: + * - All user-visible strings sourced from useOnboardingI18n (no hardcoded English) + * - Dark mode: dark: Tailwind variants on every surface, border, text, gradient + * - Mobile-first responsive layout (vertical default, horizontal on sm+) + * - Improved a11y: translated aria-labels, aria-busy spinner, focus-visible rings + * - StepIcon and StatusBadge extracted as memoised sub-components + * - prefers-reduced-motion respected throughout + * - Connector lines between vertical steps */ -interface OnboardingStep { - id: string; - title: string; - description: string; - completed: boolean; - required: boolean; - order: number; -} -/** - * Props for OnboardingProgressTracker component - */ -interface OnboardingProgressTrackerProps { +import React, { memo } from "react"; +import { + motion, + AnimatePresence, + useReducedMotion, + type Variants, +} from "framer-motion"; +import { + useOnboardingProgress, + type OnboardingStep, +} from "@/hooks/useOnboardingProgress"; +import { useOnboardingI18n } from "@/hooks/useOnboardingI18n"; + +// ── Re-export so consumers need only one import ─────────────────────────────── +export type { OnboardingStep }; + +// ── Props ───────────────────────────────────────────────────────────────────── + +export interface OnboardingProgressTrackerProps { steps: OnboardingStep[]; currentStep?: string; - onStepChange?: (stepId: string) => void; + onStepChange?: (stepId: string) => void | Promise; onComplete?: () => void; + /** Show numeric labels inside step circles. Default: true. */ showStepNumbers?: boolean; + /** Stack direction. Default: "vertical". */ orientation?: "vertical" | "horizontal"; + /** Reduced padding variant. Default: false. */ compact?: boolean; + /** Extra className on the root wrapper. */ + className?: string; } -// --------------------------------------------------------------------------- -// Animation variants — #809 framer-motion animations -// --------------------------------------------------------------------------- +// ── Animation variants ──────────────────────────────────────────────────────── -/** - * Animation variants for step container — staggered children entrance - */ const containerVariants: Variants = { hidden: { opacity: 0 }, visible: { opacity: 1, - transition: { - staggerChildren: 0.1, - delayChildren: 0.2, - }, + transition: { staggerChildren: 0.08, delayChildren: 0.15 }, }, }; -/** - * 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 }, - }, + 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 } }, }; -/** 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 } }, + visible: { opacity: 1, transition: { duration: 0.15 } }, + 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] }, - }, + visible: { scaleX: 1, originX: 0, transition: { duration: 0.55, 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 } }, + visible: { opacity: 1, transition: { duration: 0.25 } }, }; -/** - * 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, - }, + scale: 1, opacity: 1, + transition: { type: "spring", stiffness: 280, damping: 22, delay: 0.15 }, }, }; -/** Reduced-motion check mark — simple fade */ const checkMarkVariantsReduced: Variants = { hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.2 } }, + visible: { opacity: 1, transition: { duration: 0.15 } }, }; -/** - * 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 } }, + 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 } }, }; -/** Reduced-motion completion banner */ const completionVariantsReduced: Variants = { hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { duration: 0.2 } }, - exit: { opacity: 0, transition: { duration: 0.1 } }, + visible: { opacity: 1, transition: { duration: 0.15 } }, + 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(); +// ── StepIcon ────────────────────────────────────────────────────────────────── - // Respect user's OS-level "reduce motion" preference — #809 - const prefersReducedMotion = useReducedMotion(); +interface StepIconProps { + completed: boolean; + isPending: boolean; + isCurrent: boolean; + number: number; + showNumber: boolean; + compact: boolean; + checkVariants: Variants; + prefersReducedMotion: boolean | null; +} - const [state, dispatch] = useReducer( - onboardingReducer, - createInitialOnboardingState(currentStepProp || steps[0]?.id), +const StepIcon = memo(function StepIcon({ + completed, + isPending, + isCurrent, + number, + showNumber, + compact, + checkVariants, + prefersReducedMotion, +}: StepIconProps) { + return ( + + {completed ? ( + + + + ) : isPending ? ( + + + + ) : ( + + )} + ); +}); - // Track previous step for rollback — #812 - const previousStepRef = useRef(state.currentStep); +// ── StatusBadge ─────────────────────────────────────────────────────────────── - /** Sort steps by order */ - const sortedSteps = useMemo( - () => [...steps].sort((a, b) => a.order - b.order), - [steps] - ); +interface StatusBadgeProps { + completed: boolean; + isCurrent: boolean; + compact: boolean; + completedLabel: string; + inProgressLabel: string; + pendingLabel: string; + prefersReducedMotion: boolean | null; +} - /** Number of completed steps for visible and assistive summaries */ - const completedStepsCount = useMemo( - () => sortedSteps.filter((s) => s.completed).length, - [sortedSteps], - ); +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"; - /** 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] + return ( + + {label} + ); +}); + +// ── Main component ──────────────────────────────────────────────────────────── - /** 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; +export const OnboardingProgressTracker = memo(function OnboardingProgressTracker({ + steps, + currentStep, + onStepChange, + onComplete, + showStepNumbers = true, + orientation = "vertical", + compact = false, + className = "", +}: OnboardingProgressTrackerProps) { + const i18n = useOnboardingI18n(); + 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; + const activeCheckMarkVariants = prefersReducedMotion ? checkMarkVariantsReduced : checkMarkVariants; + const activeCompletionVariants = prefersReducedMotion ? completionVariantsReduced : completionVariants; return (
    + {/* sr-only progress summary */}

    - {progressSummary} + {i18n.stepsCompletedLabel(completedCount, sortedSteps.length)}{" "} + {i18n.percentCompleteLabel(progressPercent)}

    - {/* Screen reader live announcement area — #811 */} + {/* Assertive live region for step-change announcements */}
    {state.announcementText}
    - {/* Pending indicator for screen readers — #812 */} + {/* Polite pending indicator */} {state.isPending && (
    - {t("onboarding.updating") || "Updating step…"} + {i18n.updating}
    )} - {/* Container */} + {/* ── Card ──────────────────────────────────────────────────────────── */}
    - {/* Header */} -
    -
    -

    - {t("onboarding.title") || "Onboarding Progress"} + {/* ── Header ──────────────────────────────────────────────────────── */} +
    +
    +

    + {i18n.title}

    -

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

    + {i18n.subtitle}

    - {/* Overall progress bar — animated with framer-motion — #809 */} + {/* Progress bar */}
    - {/* Status text */} -
    - {/* Steps list — staggered entrance animation — #809 */} + {/* ── Steps list ───────────────────────────────────────────────────── */} > {sortedSteps.map((step, index) => { - const isCurrentStep = effectiveCurrentStep === step.id; - const isPendingStep = state.isPending && isCurrentStep; - const stepDescriptionId = `${progressSummaryId}-step-${index + 1}-description`; + const isCurrent = effectiveCurrentStep === step.id; + const isPending = state.isPending && isCurrent; + const stepDescId = `${progressSummaryId}-desc-${index}`; + + const indicatorColorClass = 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 dark:group-hover:border-pluto-500 dark:group-hover:bg-pluto-800/50"; return ( - {/* Step content */} - + {/* Step text 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) */} + +
    + + {/* 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..9ec70cd0 100644 --- a/frontend/src/components/onboarding-reducer.ts +++ b/frontend/src/components/onboarding-reducer.ts @@ -1,16 +1,32 @@ /** - * 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. + * 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. + * + * Changelog: + * - Added SYNC_STEPS action so parent-driven step updates (e.g. server + * re-validation) can be applied without a full remount. + * - State members are readonly to prevent accidental mutation outside the reducer. + * - Added pure selectors: selectEffectiveStep, selectProgressPercent. + * - SET_CURRENT_STEP retained for backward compatibility. */ +// ── Types ───────────────────────────────────────────────────────────────────── + 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; + /** Last confirmed active step id. */ + readonly currentStep: string | undefined; + /** Optimistic step id — set immediately on click, cleared on confirm/rollback. */ + readonly optimisticStep: string | undefined; + /** Text queued for the aria-live announcement region. */ + readonly announcementText: string; + /** True while an optimistic update is awaiting server confirmation. */ + readonly isPending: boolean; + /** Total step count — synced from props via SYNC_STEPS. */ + readonly totalSteps: number; + /** Completed step count — synced from props via SYNC_STEPS. */ + readonly completedSteps: number; } export type OnboardingAction = @@ -18,35 +34,83 @@ export type OnboardingAction = | { type: "OPTIMISTIC_STEP"; payload: string } | { type: "CONFIRM_STEP"; payload: string } | { type: "ROLLBACK_STEP" } - | { type: "SET_ANNOUNCEMENT"; payload: string }; + | { type: "SET_ANNOUNCEMENT"; payload: string } + /** Sync derived counts when the steps prop changes. */ + | { 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 }; + 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 }; + 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; } } + +// ── Pure selectors ──────────────────────────────────────────────────────────── + +/** Active step id, accounting for optimistic state. */ +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/useOnboardingI18n.ts b/frontend/src/hooks/useOnboardingI18n.ts new file mode 100644 index 00000000..65b6e658 --- /dev/null +++ b/frontend/src/hooks/useOnboardingI18n.ts @@ -0,0 +1,149 @@ +/** + * useOnboardingI18n + * + * Centralises every translated string used by the Onboarding Progress Tracker. + * Consuming components call this hook once and receive a stable object of + * typed helpers — no duplicated useTranslations("onboarding") calls scattered + * across files. + */ + +"use client"; + +import { useCallback } from "react"; +import { useTranslations } from "next-intl"; + +// ── Step data shape (minimal — component owns the full type) ────────────────── + +interface StepMeta { + order: number; + title: string; + description: string; + completed: boolean; + required: boolean; +} + +// ── Return type ─────────────────────────────────────────────────────────────── + +export interface OnboardingI18n { + // Static strings + title: string; + subtitle: string; + progressBar: string; + stepsList: string; + progressTracker: string; + allCompleted: string; + successTitle: string; + successMessage: string; + completed: string; + inProgress: string; + pending: string; + required: string; + optional: string; + updating: string; + stepChangeFailed: string; + + // Parameterised helpers + /** "X of Y steps completed" */ + stepsCompletedLabel: (completed: number, total: number) => string; + /** "N% complete" */ + percentCompleteLabel: (percent: number) => string; + /** Progress announcement for aria-live: "Progress: N% complete" */ + progressAnnouncement: (percent: number) => string; + /** + * Full step button aria-label. + * Appends ". Completed" and/or ". Required" as appropriate. + */ + stepAriaLabel: ( + number: number, + title: string, + completed: boolean, + required: boolean, + ) => string; + /** Step click announcement for the aria-live region. */ + stepAnnouncement: (step: StepMeta, total: number, statusLabel: string) => string; + /** Status label for a given step state. */ + statusLabel: (completed: boolean, isCurrent: boolean) => string; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export function useOnboardingI18n(): OnboardingI18n { + const t = useTranslations("onboarding"); + + const stepsCompletedLabel = useCallback( + (completed: number, total: number) => + t("stepsCompleted", { completed, total }), + [t], + ); + + const percentCompleteLabel = useCallback( + (percent: number) => t("percentComplete", { percent }), + [t], + ); + + const progressAnnouncement = useCallback( + (percent: number) => t("progressAnnouncement", { percent }), + [t], + ); + + const stepAriaLabel = useCallback( + ( + number: number, + title: string, + completed: boolean, + required: boolean, + ): string => { + if (completed && required) + return t("stepLabelCompletedRequired", { number, title }); + if (completed) return t("stepLabelCompleted", { number, title }); + if (required) return t("stepLabelRequired", { number, title }); + return t("stepLabel", { number, title }); + }, + [t], + ); + + const stepAnnouncement = useCallback( + (step: StepMeta, total: number, statusLabel: string): string => + t("stepAnnouncement", { + number: step.order, + total, + title: step.title, + description: step.description, + status: statusLabel, + }), + [t], + ); + + const statusLabel = useCallback( + (completed: boolean, isCurrent: boolean): string => { + if (completed) return t("completed"); + if (isCurrent) return t("inProgress"); + return t("pending"); + }, + [t], + ); + + return { + title: t("title"), + subtitle: t("subtitle"), + progressBar: t("progressBar"), + stepsList: t("stepsList"), + progressTracker: t("progressTracker"), + allCompleted: t("allCompleted"), + successTitle: t("successTitle"), + successMessage: t("successMessage"), + completed: t("completed"), + inProgress: t("inProgress"), + pending: t("pending"), + required: t("required"), + optional: t("optional"), + updating: t("updating"), + stepChangeFailed: t("stepChangeFailed"), + stepsCompletedLabel, + percentCompleteLabel, + progressAnnouncement, + stepAriaLabel, + stepAnnouncement, + statusLabel, + }; +} diff --git a/frontend/src/hooks/useOnboardingProgress.ts b/frontend/src/hooks/useOnboardingProgress.ts new file mode 100644 index 00000000..7a9b1f46 --- /dev/null +++ b/frontend/src/hooks/useOnboardingProgress.ts @@ -0,0 +1,195 @@ +/** + * 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. + * - Syncs external prop changes (steps array) into the reducer via SYNC_STEPS. + * - Syncs external currentStep prop changes via SET_CURRENT_STEP. + * - Handles optimistic step navigation with async callback + rollback. + * - Produces translated screen-reader announcement strings via useOnboardingI18n. + * - Fires onComplete when all required steps are done. + */ + +"use client"; + +import { + useCallback, + useEffect, + useId, + useMemo, + useReducer, + useRef, +} from "react"; +import { + onboardingReducer, + createInitialOnboardingState, + selectEffectiveStep, + selectProgressPercent, + type OnboardingState, +} from "@/components/onboarding-reducer"; +import { useOnboardingI18n } from "@/hooks/useOnboardingI18n"; + +// ── Shared step type (re-exported for consumers) ────────────────────────────── + +export interface OnboardingStep { + id: string; + title: string; + description: string; + completed: boolean; + required: boolean; + order: number; +} + +// ── Options / return types ──────────────────────────────────────────────────── + +export interface UseOnboardingProgressOptions { + steps: OnboardingStep[]; + currentStep?: string; + onStepChange?: (stepId: string) => void | Promise; + onComplete?: () => void; +} + +export interface UseOnboardingProgressReturn { + sortedSteps: OnboardingStep[]; + effectiveCurrentStep: string | undefined; + state: OnboardingState; + progressPercent: number; + completedCount: number; + isComplete: boolean; + progressSummaryId: string; + handleStepClick: (stepId: string) => Promise; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export function useOnboardingProgress({ + steps, + currentStep: currentStepProp, + onStepChange, + onComplete, +}: UseOnboardingProgressOptions): UseOnboardingProgressReturn { + const i18n = useOnboardingI18n(); + 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, + ), + ); + + // Sync step counts when the steps prop changes + useEffect(() => { + dispatch({ + type: "SYNC_STEPS", + payload: { total: sortedSteps.length, completed: completedCount }, + }); + }, [sortedSteps.length, completedCount]); + + // Sync external currentStep prop + const prevCurrentStepPropRef = useRef(currentStepProp); + useEffect(() => { + if ( + currentStepProp !== undefined && + currentStepProp !== prevCurrentStepPropRef.current + ) { + dispatch({ type: "SET_CURRENT_STEP", payload: currentStepProp }); + prevCurrentStepPropRef.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: i18n.successTitle }); + onCompleteRef.current?.(); + } + }, [isComplete, sortedSteps.length, i18n.successTitle]); + + // ── Progress announcements ──────────────────────────────────────────────── + + useEffect(() => { + dispatch({ + type: "SET_ANNOUNCEMENT", + payload: i18n.progressAnnouncement(progressPercent), + }); + }, [progressPercent, i18n]); + + // ── Step click handler ──────────────────────────────────────────────────── + + const handleStepClick = useCallback( + async (stepId: string) => { + if (state.isPending) return; + + const step = sortedSteps.find((s) => s.id === stepId); + if (!step) return; + + dispatch({ type: "OPTIMISTIC_STEP", payload: stepId }); + + const status = i18n.statusLabel( + step.completed, + effectiveCurrentStep === stepId, + ); + dispatch({ + type: "SET_ANNOUNCEMENT", + payload: i18n.stepAnnouncement(step, sortedSteps.length, status), + }); + + try { + await onStepChange?.(stepId); + dispatch({ type: "CONFIRM_STEP", payload: stepId }); + } catch { + dispatch({ type: "ROLLBACK_STEP" }); + dispatch({ + type: "SET_ANNOUNCEMENT", + payload: i18n.stepChangeFailed, + }); + } + }, + [sortedSteps, effectiveCurrentStep, onStepChange, state.isPending, i18n], + ); + + return { + sortedSteps, + effectiveCurrentStep, + state, + progressPercent, + completedCount, + isComplete, + progressSummaryId, + handleStepClick, + }; +}