diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx index ffb4551d..007e0ed2 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -1,103 +1,103 @@ -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 ──────────────────────────────────────────────────────── */} - - -
-
- ); -} +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 ──────────────────────────────────────────────────────── */} + + +
+
+ ); +} diff --git a/frontend/src/components/OnboardingProgressTracker.test.tsx b/frontend/src/components/OnboardingProgressTracker.test.tsx index e277147b..fec93157 100644 --- a/frontend/src/components/OnboardingProgressTracker.test.tsx +++ b/frontend/src/components/OnboardingProgressTracker.test.tsx @@ -1,587 +1,587 @@ -/** - * @vitest-environment jsdom - * - * Unit tests for OnboardingProgressTracker (i18n refactor) - * - * Covers: - * - #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"; -import "@testing-library/jest-dom"; -import { OnboardingProgressTracker } from "./OnboardingProgressTracker"; -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, params?: Record) => { - if (!params) return key; - return Object.entries(params).reduce( - (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), - key, - ); - }, -})); - -/** - * 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: 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, -})); - -// --------------------------------------------------------------------------- -// 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 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()); - - // ── #810 Rendering ──────────────────────────────────────────────────────── - describe("Rendering", () => { - it("renders the translated title key", () => { - render(); - expect(screen.getByText("title")).toBeInTheDocument(); - }); - - it("renders the translated subtitle key", () => { - render(); - expect(screen.getByText("subtitle")).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("renders the progress bar fill element", () => { - render(); - expect(screen.getByTestId("progress-bar-fill")).toBeInTheDocument(); - }); - - 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"); - 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 — does not 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 class when compact=true", () => { - const { container } = render( - , - ); - expect(container.querySelector(".p-4")).toBeInTheDocument(); - }); - - it("renders the correct number of list items", () => { - render(); - expect(screen.getAllByRole("listitem")).toHaveLength(3); - }); - - it("applies an extra className to the root wrapper", () => { - const { container } = render( - , - ); - expect(container.firstChild).toHaveClass("extra-class"); - }); - }); - - // ── #810 Interactions ───────────────────────────────────────────────────── - describe("Interactions", () => { - it("calls onStepChange when a step button is clicked", async () => { - render(); - // 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("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 ─────────────────────────────────────────────────────── - describe("Completion logic", () => { - it("calls onComplete when all required steps are completed", 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("renders the 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("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 ──────────────────────────────────────────────────── - describe("Accessibility (#811)", () => { - it("wraps tracker in a region with translated aria-label", () => { - render(); - expect(screen.getByRole("region")).toHaveAttribute( - "aria-label", - "progressTracker", - ); - }); - - it("sets aria-live='polite' on the region", () => { - render(); - expect(screen.getByRole("region")).toHaveAttribute("aria-live", "polite"); - }); - - it("provides an aria-live status region for announcements", () => { - render(); - expect(screen.getByRole("status")).toBeInTheDocument(); - }); - - it("announces progress on mount", () => { - render(); - expect(screen.getByRole("status").textContent).toMatch(/33/); - }); - - it("labels the steps list with the translated key", () => { - render(); - expect(screen.getByRole("list")).toHaveAttribute("aria-label", "stepsList"); - }); - - it("sets aria-current='step' on the active step", () => { - render(); - expect(screen.getAllByRole("button")[0]).toHaveAttribute( - "aria-current", - "step", - ); - }); - - it("does not set aria-current on inactive steps", () => { - render(); - expect(screen.getAllByRole("button")[1]).not.toHaveAttribute("aria-current"); - }); - - it("sets aria-setsize and aria-posinset on step buttons", () => { - render(); - 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(); - 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 = [ - { ...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(); - // 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 ─────────────────────────────────────────────── - describe("Optimistic updates (#812)", () => { - it("immediately marks the clicked step as current before callback resolves", async () => { - let resolveCallback!: () => void; - const slowCb = vi.fn( - () => new Promise((res) => { resolveCallback = res; }), - ); - render( - , - ); - - const btn2 = screen.getAllByRole("button")[1]; - fireEvent.click(btn2); - await waitFor(() => - expect(btn2).toHaveAttribute("aria-current", "step"), - ); - act(() => resolveCallback()); - }); - - it("confirms the optimistic update after callback resolves", async () => { - const asyncCb = vi.fn(() => Promise.resolve()); - render( - , - ); - - const btn2 = screen.getAllByRole("button")[1]; - 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 = screen.getAllByRole("button")[0]; - const btn2 = screen.getAllByRole("button")[1]; - - fireEvent.click(btn2); - await waitFor(() => { - expect(btn1).toHaveAttribute("aria-current", "step"); - expect(btn2).not.toHaveAttribute("aria-current"); - }); - }); - - it("announces the translated stepChangeFailed key on rollback", async () => { - const failCb = vi.fn(() => Promise.reject(new Error("fail"))); - render( - , - ); - - fireEvent.click(screen.getAllByRole("button")[1]); - await waitFor(() => { - expect(screen.getByRole("status").textContent).toContain( - "stepChangeFailed", - ); - }); - }); - - it("sets aria-busy on the pending step button", async () => { - let resolveCallback!: () => void; - const slowCb = vi.fn( - () => new Promise((res) => { resolveCallback = res; }), - ); - render( - , - ); - - 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(() => { - screen.getAllByRole("button").forEach((btn) => - expect(btn).toBeDisabled(), - ); - }); - act(() => resolveCallback()); - }); - }); - - // ── 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", () => { - render(); - expect(screen.getByTestId("progress-bar-fill")).toBeInTheDocument(); - }); - - it("renders all step list items", () => { - render(); - expect(screen.getAllByRole("listitem")).toHaveLength(3); - }); - }); - - // ── 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%"), - ); - }); - }); -}); +/** + * @vitest-environment jsdom + * + * Unit tests for OnboardingProgressTracker (i18n refactor) + * + * Covers: + * - #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"; +import "@testing-library/jest-dom"; +import { OnboardingProgressTracker } from "./OnboardingProgressTracker"; +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, params?: Record) => { + if (!params) return key; + return Object.entries(params).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), + key, + ); + }, +})); + +/** + * 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: 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, +})); + +// --------------------------------------------------------------------------- +// 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 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()); + + // ── #810 Rendering ──────────────────────────────────────────────────────── + describe("Rendering", () => { + it("renders the translated title key", () => { + render(); + expect(screen.getByText("title")).toBeInTheDocument(); + }); + + it("renders the translated subtitle key", () => { + render(); + expect(screen.getByText("subtitle")).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("renders the progress bar fill element", () => { + render(); + expect(screen.getByTestId("progress-bar-fill")).toBeInTheDocument(); + }); + + 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"); + 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 — does not 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 class when compact=true", () => { + const { container } = render( + , + ); + expect(container.querySelector(".p-4")).toBeInTheDocument(); + }); + + it("renders the correct number of list items", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); + + it("applies an extra className to the root wrapper", () => { + const { container } = render( + , + ); + expect(container.firstChild).toHaveClass("extra-class"); + }); + }); + + // ── #810 Interactions ───────────────────────────────────────────────────── + describe("Interactions", () => { + it("calls onStepChange when a step button is clicked", async () => { + render(); + // 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("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 ─────────────────────────────────────────────────────── + describe("Completion logic", () => { + it("calls onComplete when all required steps are completed", 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("renders the 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("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 ──────────────────────────────────────────────────── + describe("Accessibility (#811)", () => { + it("wraps tracker in a region with translated aria-label", () => { + render(); + expect(screen.getByRole("region")).toHaveAttribute( + "aria-label", + "progressTracker", + ); + }); + + it("sets aria-live='polite' on the region", () => { + render(); + expect(screen.getByRole("region")).toHaveAttribute("aria-live", "polite"); + }); + + it("provides an aria-live status region for announcements", () => { + render(); + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + + it("announces progress on mount", () => { + render(); + expect(screen.getByRole("status").textContent).toMatch(/33/); + }); + + it("labels the steps list with the translated key", () => { + render(); + expect(screen.getByRole("list")).toHaveAttribute("aria-label", "stepsList"); + }); + + it("sets aria-current='step' on the active step", () => { + render(); + expect(screen.getAllByRole("button")[0]).toHaveAttribute( + "aria-current", + "step", + ); + }); + + it("does not set aria-current on inactive steps", () => { + render(); + expect(screen.getAllByRole("button")[1]).not.toHaveAttribute("aria-current"); + }); + + it("sets aria-setsize and aria-posinset on step buttons", () => { + render(); + 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(); + 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 = [ + { ...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(); + // 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 ─────────────────────────────────────────────── + describe("Optimistic updates (#812)", () => { + it("immediately marks the clicked step as current before callback resolves", async () => { + let resolveCallback!: () => void; + const slowCb = vi.fn( + () => new Promise((res) => { resolveCallback = res; }), + ); + render( + , + ); + + const btn2 = screen.getAllByRole("button")[1]; + fireEvent.click(btn2); + await waitFor(() => + expect(btn2).toHaveAttribute("aria-current", "step"), + ); + act(() => resolveCallback()); + }); + + it("confirms the optimistic update after callback resolves", async () => { + const asyncCb = vi.fn(() => Promise.resolve()); + render( + , + ); + + const btn2 = screen.getAllByRole("button")[1]; + 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 = screen.getAllByRole("button")[0]; + const btn2 = screen.getAllByRole("button")[1]; + + fireEvent.click(btn2); + await waitFor(() => { + expect(btn1).toHaveAttribute("aria-current", "step"); + expect(btn2).not.toHaveAttribute("aria-current"); + }); + }); + + it("announces the translated stepChangeFailed key on rollback", async () => { + const failCb = vi.fn(() => Promise.reject(new Error("fail"))); + render( + , + ); + + fireEvent.click(screen.getAllByRole("button")[1]); + await waitFor(() => { + expect(screen.getByRole("status").textContent).toContain( + "stepChangeFailed", + ); + }); + }); + + it("sets aria-busy on the pending step button", async () => { + let resolveCallback!: () => void; + const slowCb = vi.fn( + () => new Promise((res) => { resolveCallback = res; }), + ); + render( + , + ); + + 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(() => { + screen.getAllByRole("button").forEach((btn) => + expect(btn).toBeDisabled(), + ); + }); + act(() => resolveCallback()); + }); + }); + + // ── 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", () => { + render(); + expect(screen.getByTestId("progress-bar-fill")).toBeInTheDocument(); + }); + + it("renders all step list items", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); + }); + + // ── 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 e60b72df..f54a27f5 100644 --- a/frontend/src/components/OnboardingProgressTracker.tsx +++ b/frontend/src/components/OnboardingProgressTracker.tsx @@ -1,586 +1,586 @@ -"use client"; - -/** - * 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 - */ - -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 | 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 ──────────────────────────────────────────────────────── - -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 ────────────────────────────────────────────────────────────────── - -interface StepIconProps { - completed: boolean; - isPending: boolean; - isCurrent: boolean; - number: number; - showNumber: boolean; - compact: boolean; - checkVariants: Variants; - prefersReducedMotion: boolean | null; -} - -const StepIcon = memo(function StepIcon({ - completed, - isPending, - isCurrent, - number, - showNumber, - compact, - checkVariants, - prefersReducedMotion, -}: StepIconProps) { - return ( - - {completed ? ( - - - - ) : isPending ? ( - - - - ) : ( - - )} - - ); -}); - -// ── StatusBadge ─────────────────────────────────────────────────────────────── - -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 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; - - return ( -
- {/* sr-only progress summary */} -

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

- - {/* Assertive live region for step-change announcements */} -
- {state.announcementText} -
- - {/* Polite pending indicator */} - {state.isPending && ( -
- {i18n.updating} -
- )} - - {/* ── Card ──────────────────────────────────────────────────────────── */} -
- {/* ── Header ──────────────────────────────────────────────────────── */} -
-
-

- {i18n.title} -

- -
- -

- {i18n.subtitle} -

- - {/* Progress bar */} -
- -
- - {/* Step count summary line */} - -
- - {/* ── Steps list ───────────────────────────────────────────────────── */} - - - {sortedSteps.map((step, index) => { - 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 indicator button */} - - - {/* Step text content */} -
-

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

- -

- {step.description} -

- - -
- - {/* Vertical connector line */} - {orientation === "vertical" && index < sortedSteps.length - 1 && ( - -
- ); -}); - -export default OnboardingProgressTracker; +"use client"; + +/** + * 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 + */ + +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 | 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 ──────────────────────────────────────────────────────── + +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 ────────────────────────────────────────────────────────────────── + +interface StepIconProps { + completed: boolean; + isPending: boolean; + isCurrent: boolean; + number: number; + showNumber: boolean; + compact: boolean; + checkVariants: Variants; + prefersReducedMotion: boolean | null; +} + +const StepIcon = memo(function StepIcon({ + completed, + isPending, + isCurrent, + number, + showNumber, + compact, + checkVariants, + prefersReducedMotion, +}: StepIconProps) { + return ( + + {completed ? ( + + + + ) : isPending ? ( + + + + ) : ( + + )} + + ); +}); + +// ── StatusBadge ─────────────────────────────────────────────────────────────── + +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 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; + + return ( +
+ {/* sr-only progress summary */} +

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

+ + {/* Assertive live region for step-change announcements */} +
+ {state.announcementText} +
+ + {/* Polite pending indicator */} + {state.isPending && ( +
+ {i18n.updating} +
+ )} + + {/* ── Card ──────────────────────────────────────────────────────────── */} +
+ {/* ── Header ──────────────────────────────────────────────────────── */} +
+
+

+ {i18n.title} +

+ +
+ +

+ {i18n.subtitle} +

+ + {/* Progress bar */} +
+ +
+ + {/* Step count summary line */} + +
+ + {/* ── Steps list ───────────────────────────────────────────────────── */} + + + {sortedSteps.map((step, index) => { + 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 indicator button */} + + + {/* Step text 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 819e1687..2fc95782 100644 --- a/frontend/src/components/onboarding-reducer.ts +++ b/frontend/src/components/onboarding-reducer.ts @@ -1,178 +1,178 @@ -/** - * 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. - * - * Loading state enhancements: - * - LOAD_START / LOAD_SUCCESS / LOAD_ERROR actions for initial data fetch - * - SET_ERROR / CLEAR_ERROR for step-level and global errors - * - RETRY action to re-enter loading state from an error state - * - loadingState discriminated union: "idle" | "loading" | "success" | "error" - * - errorMessage field for surfacing translated error text - * - retryCount field so the UI can throttle retry attempts - */ - -// ── Types ───────────────────────────────────────────────────────────────────── - -export type LoadingState = "idle" | "loading" | "success" | "error"; - -export interface OnboardingState { - 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; - /** Discriminated loading state for the initial data fetch. */ - readonly loadingState: LoadingState; - /** Error message to surface in the error banner (null when healthy). */ - readonly errorMessage: string | null; - /** Number of times the user has retried after an error. */ - readonly retryCount: 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 } - /** Begin initial data loading — shows skeleton UI. */ - | { type: "LOAD_START" } - /** Data loaded successfully — clears skeleton. */ - | { type: "LOAD_SUCCESS" } - /** Data loading failed — shows error banner. */ - | { type: "LOAD_ERROR"; payload: string } - /** Set a step-level or general error without going through the load cycle. */ - | { type: "SET_ERROR"; payload: string } - /** Dismiss the error banner. */ - | { type: "CLEAR_ERROR" } - /** Increment retryCount and re-enter loading state. */ - | { type: "RETRY" }; - -// ── Factory ─────────────────────────────────────────────────────────────────── - -export function createInitialOnboardingState( - currentStep?: string, - initialLoadingState: LoadingState = "idle", -): OnboardingState { - return { - currentStep, - optimisticStep: undefined, - announcementText: "", - isPending: false, - loadingState: initialLoadingState, - errorMessage: null, - retryCount: 0, - }; -} - -// ── 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 "LOAD_START": - return { - ...state, - loadingState: "loading", - errorMessage: null, - }; - - case "LOAD_SUCCESS": - return { - ...state, - loadingState: "success", - errorMessage: null, - }; - - case "LOAD_ERROR": - return { - ...state, - loadingState: "error", - errorMessage: action.payload, - isPending: false, - }; - - case "SET_ERROR": - return { - ...state, - errorMessage: action.payload, - loadingState: "error", - isPending: false, - }; - - case "CLEAR_ERROR": - return { - ...state, - errorMessage: null, - loadingState: "idle", - }; - - case "RETRY": - return { - ...state, - loadingState: "loading", - errorMessage: null, - retryCount: state.retryCount + 1, - }; - - default: - return state; - } -} - -// ── 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( - completedSteps: number, - totalSteps: number, -): number { - if (totalSteps === 0) return 0; - return Math.min(100, Math.round((completedSteps / totalSteps) * 100)); -} - -/** True while the component is in any loading-like state. */ -export function selectIsLoading(state: OnboardingState): boolean { - return state.loadingState === "loading"; -} - -/** True when the component is in a recoverable error state. */ -export function selectHasError(state: OnboardingState): boolean { - return state.loadingState === "error" && state.errorMessage !== null; -} +/** + * 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. + * + * Loading state enhancements: + * - LOAD_START / LOAD_SUCCESS / LOAD_ERROR actions for initial data fetch + * - SET_ERROR / CLEAR_ERROR for step-level and global errors + * - RETRY action to re-enter loading state from an error state + * - loadingState discriminated union: "idle" | "loading" | "success" | "error" + * - errorMessage field for surfacing translated error text + * - retryCount field so the UI can throttle retry attempts + */ + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type LoadingState = "idle" | "loading" | "success" | "error"; + +export interface OnboardingState { + 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; + /** Discriminated loading state for the initial data fetch. */ + readonly loadingState: LoadingState; + /** Error message to surface in the error banner (null when healthy). */ + readonly errorMessage: string | null; + /** Number of times the user has retried after an error. */ + readonly retryCount: 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 } + /** Begin initial data loading — shows skeleton UI. */ + | { type: "LOAD_START" } + /** Data loaded successfully — clears skeleton. */ + | { type: "LOAD_SUCCESS" } + /** Data loading failed — shows error banner. */ + | { type: "LOAD_ERROR"; payload: string } + /** Set a step-level or general error without going through the load cycle. */ + | { type: "SET_ERROR"; payload: string } + /** Dismiss the error banner. */ + | { type: "CLEAR_ERROR" } + /** Increment retryCount and re-enter loading state. */ + | { type: "RETRY" }; + +// ── Factory ─────────────────────────────────────────────────────────────────── + +export function createInitialOnboardingState( + currentStep?: string, + initialLoadingState: LoadingState = "idle", +): OnboardingState { + return { + currentStep, + optimisticStep: undefined, + announcementText: "", + isPending: false, + loadingState: initialLoadingState, + errorMessage: null, + retryCount: 0, + }; +} + +// ── 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 "LOAD_START": + return { + ...state, + loadingState: "loading", + errorMessage: null, + }; + + case "LOAD_SUCCESS": + return { + ...state, + loadingState: "success", + errorMessage: null, + }; + + case "LOAD_ERROR": + return { + ...state, + loadingState: "error", + errorMessage: action.payload, + isPending: false, + }; + + case "SET_ERROR": + return { + ...state, + errorMessage: action.payload, + loadingState: "error", + isPending: false, + }; + + case "CLEAR_ERROR": + return { + ...state, + errorMessage: null, + loadingState: "idle", + }; + + case "RETRY": + return { + ...state, + loadingState: "loading", + errorMessage: null, + retryCount: state.retryCount + 1, + }; + + default: + return state; + } +} + +// ── 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( + completedSteps: number, + totalSteps: number, +): number { + if (totalSteps === 0) return 0; + return Math.min(100, Math.round((completedSteps / totalSteps) * 100)); +} + +/** True while the component is in any loading-like state. */ +export function selectIsLoading(state: OnboardingState): boolean { + return state.loadingState === "loading"; +} + +/** True when the component is in a recoverable error state. */ +export function selectHasError(state: OnboardingState): boolean { + return state.loadingState === "error" && state.errorMessage !== null; +}