From d41944b6235a68b6670fc8441286c4dcc257ef84 Mon Sep 17 00:00:00 2001 From: BABAT-CODE Date: Sat, 29 Aug 2026 22:42:50 +0000 Subject: [PATCH] feat: add back navigation to onboarding wizard Add a Back button on every step after the first so merchants can navigate to earlier steps without losing progress on later ones. Key changes: - Back button (aria-label "Go back to step N of 4") is rendered in the footer on steps 2-4, matching the existing wizard-btn--secondary style used elsewhere in the footer. - All per-step state (walletAddress, verified, invoiceAmount, invoiceRecipient, invoiceCreated) is retained in component state and localStorage when navigating backwards; the wizard only clears state on full completion. - Step action handlers (handleConnectWallet, handleVerifyAddress, handleCreateInvoice) now only auto-advance when the action fires on the current step. If a user navigates back to a completed step and re-inspects their data, clicking Next advances normally without overwriting later-step data. Tests added in OnboardingWizard.test.tsx (6 tests): - No Back button on the first step. - Back button present and functional on step 2 (verify). - Invoice data preserved on back/forward round-trip from step 4. - Wallet address preserved on back/forward round-trip from step 2. - Back button present on the last (dashboard) step. - Previously-completed step does not auto-advance or lose later data. Closes #2 --- .../OnboardingWizard.test.tsx | 224 ++++++++++++++++++ .../OnboardingWizard/OnboardingWizard.tsx | 37 ++- 2 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/OnboardingWizard/OnboardingWizard.test.tsx diff --git a/frontend/src/components/OnboardingWizard/OnboardingWizard.test.tsx b/frontend/src/components/OnboardingWizard/OnboardingWizard.test.tsx new file mode 100644 index 0000000..0268b1a --- /dev/null +++ b/frontend/src/components/OnboardingWizard/OnboardingWizard.test.tsx @@ -0,0 +1,224 @@ +/** + * Tests for OnboardingWizard back navigation without data loss (Issue #2). + * + * Verifies that: + * - a Back button is present on every step after the first. + * - navigating back and then forward again preserves data entered on later steps. + * - localStorage state is preserved across back/forward navigation. + * - completed-step badges remain visible when the user returns to a step. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import "@testing-library/jest-dom"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import OnboardingWizard from "./OnboardingWizard"; + +const STORAGE_KEY = "comebackhere_onboarding_state"; + +beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function renderWizard(onComplete = vi.fn()) { + return render(); +} + +/** + * Re-query the wizard-body element each time to avoid stale references after + * state updates re-render the component. + */ +function wizardBody(): HTMLElement { + return document.querySelector(".wizard-body") as HTMLElement; +} + +function clickBack() { + // The Back button's aria-label contains the word "back", so use + // getAllByRole and pick the one whose text content is "Back". + const backButtons = screen.queryAllByRole("button"); + const btn = backButtons.find((b) => b.textContent?.trim() === "Back"); + if (!btn) throw new Error("Back button not found"); + fireEvent.click(btn); +} + +function clickNext() { + // The Next button's aria-label contains "Proceed to step …" + const nextBtn = screen.getByRole("button", { name: /proceed to step/i }); + fireEvent.click(nextBtn); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("OnboardingWizard — back navigation", () => { + it("does not show a Back button on step 1 (first step)", () => { + renderWizard(); + const allButtons = screen.queryAllByRole("button"); + const backBtn = allButtons.find((b) => b.textContent?.trim() === "Back"); + expect(backBtn).toBeUndefined(); + }); + + it("shows the Back button on step 2 (verify) and navigates back to step 1", () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + currentStep: 1, + walletAddress: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + verified: false, + invoiceAmount: "", + invoiceRecipient: "", + invoiceCreated: false, + }), + ); + + renderWizard(); + + // Should be on step 2 (verify): h3 in wizard-body reads "Verify Address" + const stepHeading = within(wizardBody()).getByRole("heading", { level: 3 }); + expect(stepHeading).toHaveTextContent("Verify Address"); + + // Back button must exist + const allButtons = screen.queryAllByRole("button"); + const backBtn = allButtons.find((b) => b.textContent?.trim() === "Back"); + expect(backBtn).toBeDefined(); + + // Click Back → should return to step 1 (wallet) + clickBack(); + const stepHeadingAfter = within(wizardBody()).getByRole("heading", { level: 3 }); + expect(stepHeadingAfter).toHaveTextContent("Connect Wallet"); + + // No Back button on first step + const allButtonsAfter = screen.queryAllByRole("button"); + expect(allButtonsAfter.find((b) => b.textContent?.trim() === "Back")).toBeUndefined(); + }); + + it("preserves invoice data when navigating back from step 4 to step 3 and forward again", () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + currentStep: 3, // dashboard step + walletAddress: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + verified: true, + invoiceAmount: "250", + invoiceRecipient: "customer@example.com", + invoiceCreated: true, + }), + ); + + renderWizard(); + + // On step 4 (dashboard) + expect(within(wizardBody()).getByText(/you're all set/i)).toBeInTheDocument(); + + // Navigate back to step 3 (invoice) + clickBack(); + expect(within(wizardBody()).getByRole("heading", { level: 3 })).toHaveTextContent("Create Invoice"); + + // Invoice data should still be present + expect(screen.getByPlaceholderText("100.00")).toHaveValue(250); + expect(screen.getByDisplayValue("customer@example.com")).toBeInTheDocument(); + expect(within(wizardBody()).getByText(/invoice created/i)).toBeInTheDocument(); + + // Navigate back to step 2 (verify) + clickBack(); + expect(within(wizardBody()).getByRole("heading", { level: 3 })).toHaveTextContent("Verify Address"); + expect(within(wizardBody()).getByText(/address verified/i)).toBeInTheDocument(); + + // Navigate forward to step 3 (invoice) via Next button + clickNext(); + expect(within(wizardBody()).getByRole("heading", { level: 3 })).toHaveTextContent("Create Invoice"); + + // Invoice data must still be intact after round-trip + expect(screen.getByPlaceholderText("100.00")).toHaveValue(250); + expect(screen.getByDisplayValue("customer@example.com")).toBeInTheDocument(); + expect(within(wizardBody()).getByText(/invoice created/i)).toBeInTheDocument(); + }); + + it("preserves wallet address when navigating back from verify to wallet and forward again", () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + currentStep: 1, + walletAddress: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + verified: false, + invoiceAmount: "", + invoiceRecipient: "", + invoiceCreated: false, + }), + ); + + renderWizard(); + + // On verify step — go back to wallet step + clickBack(); + expect(within(wizardBody()).getByRole("heading", { level: 3 })).toHaveTextContent("Connect Wallet"); + + // Wallet address should still be shown (already connected indicator) + expect( + screen.getByText("GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"), + ).toBeInTheDocument(); + + // Go forward to verify again via Next + clickNext(); + expect(within(wizardBody()).getByRole("heading", { level: 3 })).toHaveTextContent("Verify Address"); + + // Address should still be populated in the read-only input + expect( + screen.getByDisplayValue("GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"), + ).toBeInTheDocument(); + }); + + it("shows Back button on the last (dashboard) step", () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + currentStep: 3, + walletAddress: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + verified: true, + invoiceAmount: "100.00", + invoiceRecipient: "test@example.com", + invoiceCreated: true, + }), + ); + + renderWizard(); + expect(within(wizardBody()).getByText(/you're all set/i)).toBeInTheDocument(); + + const allButtons = screen.queryAllByRole("button"); + const backBtn = allButtons.find((b) => b.textContent?.trim() === "Back"); + expect(backBtn).toBeDefined(); + }); + + it("does not auto-advance when the user is on a previously-completed step", () => { + // User navigated back to verify (step 1); verified is already true. + // Clicking Next should move to step 3 (invoice) without losing invoice data. + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + currentStep: 1, + walletAddress: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + verified: true, + invoiceAmount: "100", + invoiceRecipient: "buyer@example.com", + invoiceCreated: false, + }), + ); + + renderWizard(); + + // Step 2 shows "Address verified" badge — no re-verify button + expect(within(wizardBody()).getByText(/address verified/i)).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /verify address/i }), + ).not.toBeInTheDocument(); + + // The user can proceed via Next without losing invoice form data + clickNext(); + expect(within(wizardBody()).getByRole("heading", { level: 3 })).toHaveTextContent("Create Invoice"); + expect(screen.getByDisplayValue("buyer@example.com")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/OnboardingWizard/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard/OnboardingWizard.tsx index af039e6..d7df3cf 100644 --- a/frontend/src/components/OnboardingWizard/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard/OnboardingWizard.tsx @@ -126,7 +126,11 @@ export default function OnboardingWizard({ onComplete }: OnboardingWizardProps) .getPublicKey() .then((key: string) => { setWalletAddress(key); - setCurrentStep(1); + // Only advance when coming from this step — do not jump forward if + // the user navigated back to re-inspect their wallet address. + if (currentStep === 0) { + setCurrentStep(1); + } }) .catch(() => setError("Failed to connect wallet. Is Freighter installed?")); } else { @@ -145,7 +149,10 @@ export default function OnboardingWizard({ onComplete }: OnboardingWizardProps) return; } setVerified(true); - setCurrentStep(2); + // Only advance when coming from this step. + if (currentStep === 1) { + setCurrentStep(2); + } } function handleCreateInvoice() { @@ -160,7 +167,10 @@ export default function OnboardingWizard({ onComplete }: OnboardingWizardProps) return; } setInvoiceCreated(true); - setCurrentStep(3); + // Only advance when coming from this step. + if (currentStep === 2) { + setCurrentStep(3); + } } function handleNext() { @@ -196,9 +206,19 @@ export default function OnboardingWizard({ onComplete }: OnboardingWizardProps) onComplete(); } + /** + * Navigate to the previous step. + * + * All data entered on later steps is preserved in component state and in + * localStorage so that the user can come back to those steps without losing + * their progress. The wizard only clears state when the entire flow is + * completed successfully. + */ function handleBack() { setError(null); - setCurrentStep((prev) => Math.max(0, prev - 1)); + if (currentStep > 0) { + setCurrentStep((prev) => prev - 1); + } } function renderStepContent() { @@ -323,13 +343,20 @@ export default function OnboardingWizard({ onComplete }: OnboardingWizardProps)
+ {/* Back button is shown on every step after the first */} {currentStep > 0 && ( - )} {currentStep < STEPS.length - 1 && (