From 1d0a8029f8f83221eae13b680e05bd39768497ed Mon Sep 17 00:00:00 2001 From: Abubakar Mashud Date: Thu, 27 Aug 2026 19:49:05 +0100 Subject: [PATCH 1/4] Re-triggers the Tour on Every Mount for Returning Users --- frontend/__tests__/onboarding.test.tsx | 25 ++++++++++++++---- frontend/components/OnboardingTour.tsx | 6 ++--- frontend/hooks/useOnboardingTour.ts | 13 ++++++++-- frontend/lib/onboardingState.ts | 36 +++++++++++++++++++++++--- 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/frontend/__tests__/onboarding.test.tsx b/frontend/__tests__/onboarding.test.tsx index 6447492c..ad0c1904 100644 --- a/frontend/__tests__/onboarding.test.tsx +++ b/frontend/__tests__/onboarding.test.tsx @@ -34,7 +34,7 @@ import { ONBOARDING_KEY_STEP, } from "@/hooks/useOnboardingTour"; import OnboardingTour, { TOUR_STEPS, STEP_COUNT } from "@/components/OnboardingTour"; -import { STORAGE_KEY as ONBOARDING_STATE_STORAGE_KEY } from "@/lib/onboardingState"; +import { getTourProgress, STORAGE_KEY as ONBOARDING_STATE_STORAGE_KEY } from "@/lib/onboardingState"; // ── Mock useWallet (transitively pulls in lib/wallet.ts -> @finchippay/sdk, // which isn't resolvable in this workspace's test environment) ────────────── @@ -233,6 +233,7 @@ describe("useOnboardingTour hook", () => { }); expect(localStorage.getItem(ONBOARDING_KEY_DISMISSED)).toBe("true"); + expect(getTourProgress().dismissed).toBe(true); // A fresh hook instantiation should not auto-start const { result: result2 } = renderHook(() => useOnboardingTour()); @@ -258,9 +259,23 @@ describe("useOnboardingTour hook", () => { }); expect(localStorage.getItem(ONBOARDING_KEY_COMPLETED)).toBe("true"); + expect(getTourProgress().completedAt).toEqual(expect.any(Number)); expect(result.current.stepIndex).toBe(0); }); + it("does not re-trigger after dismissal when the hook remounts", async () => { + const { result, unmount } = renderHook(() => useOnboardingTour()); + + await waitFor(() => expect(result.current.isRunning).toBe(true)); + act(() => result.current.dismissForever()); + unmount(); + + const { result: remounted } = renderHook(() => useOnboardingTour()); + await waitFor(() => expect(remounted.current.isDismissed).toBe(true)); + expect(remounted.current.isRunning).toBe(false); + expect(getTourProgress().dismissed).toBe(true); + }); + // 7. Step progression — nextStep advances correctly, prevStep decrements it("advances and regresses steps correctly", async () => { const { result } = renderHook(() => useOnboardingTour()); @@ -435,16 +450,16 @@ describe("OnboardingTour component", () => { expect(prevStep).toHaveBeenCalledTimes(1); }); - it("calls skipTour and setStepIndex when Joyride fires a skip action", () => { - const skipTour = jest.fn(); + it("calls dismissForever and setStepIndex when Joyride fires a skip action", () => { + const dismissForever = jest.fn(); const setStepIndex = jest.fn(); render( ); fireEvent.click(screen.getByTestId("joyride-skip")); - expect(skipTour).toHaveBeenCalled(); + expect(dismissForever).toHaveBeenCalled(); expect(setStepIndex).toHaveBeenCalled(); }); diff --git a/frontend/components/OnboardingTour.tsx b/frontend/components/OnboardingTour.tsx index 3e52d5d1..30d5ca2f 100644 --- a/frontend/components/OnboardingTour.tsx +++ b/frontend/components/OnboardingTour.tsx @@ -92,7 +92,7 @@ const LOCALE = { next: "Next", nextLabelWithProgress: "Next (Step {step} of {steps})", open: "Open", - skip: "Skip tour", + skip: "Skip and don't show again", }; // ─── Joyride styles ─────────────────────────────────────────────────────────── @@ -200,7 +200,7 @@ export default function OnboardingTour({ tour: externalTour, isVisible, onComple tour.prevStep(); } else if (action === "close" || action === "skip") { tour.setStepIndex(index); - tour.skipTour(); + tour.dismissForever(); onSkip?.(); trackOnboardingEvent("onboarding_skipped"); } @@ -214,7 +214,7 @@ export default function OnboardingTour({ tour: externalTour, isVisible, onComple trackOnboardingEvent("onboarding_completed"); } else if (isSkipped) { tour.setStepIndex(index); - tour.skipTour(); + tour.dismissForever(); onSkip?.(); trackOnboardingEvent("onboarding_skipped"); } diff --git a/frontend/hooks/useOnboardingTour.ts b/frontend/hooks/useOnboardingTour.ts index 6301e2da..c4cd6494 100644 --- a/frontend/hooks/useOnboardingTour.ts +++ b/frontend/hooks/useOnboardingTour.ts @@ -10,6 +10,11 @@ */ import { useState, useEffect, useCallback } from "react"; +import { + getTourProgress, + markTourComplete, + markTourDismissed, +} from "@/lib/onboardingState"; // ─── localStorage keys ──────────────────────────────────────────────────────── @@ -100,8 +105,9 @@ export function useOnboardingTour(): OnboardingTourState { // ── Hydrate from localStorage once the component mounts ────────────────── useEffect(() => { - const completed = readBool(ONBOARDING_KEY_COMPLETED); - const dismissed = readBool(ONBOARDING_KEY_DISMISSED); + const progress = getTourProgress(); + const completed = progress.completed || progress.completedAt !== null || readBool(ONBOARDING_KEY_COMPLETED); + const dismissed = progress.dismissed || readBool(ONBOARDING_KEY_DISMISSED); const savedStep = readInt(ONBOARDING_KEY_STEP, 0); setIsCompleted(completed); @@ -138,6 +144,7 @@ export function useOnboardingTour(): OnboardingTourState { const next = prev + 1; if (next >= total) { // Finished. + markTourComplete(); writeBool(ONBOARDING_KEY_COMPLETED, true); writeInt(ONBOARDING_KEY_STEP, 0); setIsCompleted(true); @@ -167,6 +174,7 @@ export function useOnboardingTour(): OnboardingTourState { }, []); const completeTour = useCallback(() => { + markTourComplete(); writeBool(ONBOARDING_KEY_COMPLETED, true); writeInt(ONBOARDING_KEY_STEP, 0); setIsCompleted(true); @@ -176,6 +184,7 @@ export function useOnboardingTour(): OnboardingTourState { }, []); const dismissForever = useCallback(() => { + markTourDismissed(); writeBool(ONBOARDING_KEY_DISMISSED, true); setIsDismissed(true); setIsRunning(false); diff --git a/frontend/lib/onboardingState.ts b/frontend/lib/onboardingState.ts index 5fa630eb..5c56e4e2 100644 --- a/frontend/lib/onboardingState.ts +++ b/frontend/lib/onboardingState.ts @@ -10,6 +10,10 @@ export interface OnboardingProgress { completedSteps: number[]; /** Whether the entire tour was completed */ completed: boolean; + /** Timestamp when the entire tour was completed, or null when incomplete */ + completedAt: number | null; + /** Whether the user permanently dismissed the tour */ + dismissed: boolean; /** Timestamp of the last interaction */ lastSeen: number; /** Feature‑specific version flags to avoid showing stale announcements */ @@ -23,15 +27,24 @@ function saveProgress(progress: OnboardingProgress): void { } catch {} } +const EMPTY_PROGRESS: OnboardingProgress = { + completedSteps: [], + completed: false, + completedAt: null, + dismissed: false, + lastSeen: 0, + featureVersions: {}, +}; + export function getTourProgress(): OnboardingProgress { if (typeof window === "undefined") { - return { completedSteps: [], completed: false, lastSeen: 0, featureVersions: {} }; + return { ...EMPTY_PROGRESS, featureVersions: {} }; } try { const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + if (raw) return { ...EMPTY_PROGRESS, ...JSON.parse(raw) }; } catch {} - return { completedSteps: [], completed: false, lastSeen: 0, featureVersions: {} }; + return { ...EMPTY_PROGRESS, featureVersions: {} }; } export function markStepComplete(stepIndex: number): void { @@ -46,6 +59,14 @@ export function markStepComplete(stepIndex: number): void { export function markTourComplete(): void { const progress = getTourProgress(); progress.completed = true; + progress.completedAt = Date.now(); + progress.lastSeen = Date.now(); + saveProgress(progress); +} + +export function markTourDismissed(): void { + const progress = getTourProgress(); + progress.dismissed = true; progress.lastSeen = Date.now(); saveProgress(progress); } @@ -53,12 +74,15 @@ export function markTourComplete(): void { export function resetTour(): void { if (typeof window === "undefined") return; localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem("finchippay:onboarding:completed"); + localStorage.removeItem("finchippay:onboarding:dismissed"); + localStorage.removeItem("finchippay:onboarding:step"); } /** Return true if the tour should be shown for the given feature version. */ export function shouldShowTour(featureVersion: string): boolean { const progress = getTourProgress(); - if (progress.completed) return false; + if (progress.completed || progress.dismissed || progress.completedAt !== null) return false; if (progress.featureVersions[featureVersion]) return false; return true; } @@ -91,6 +115,10 @@ export function useOnboarding() { markTourComplete(); refresh(); }, + markTourDismissed: () => { + markTourDismissed(); + refresh(); + }, resetTour: () => { resetTour(); refresh(); From 66b3ee2b78c67ab339dd90883eae4fabe28270e4 Mon Sep 17 00:00:00 2001 From: Abubakar Mashud Date: Thu, 27 Aug 2026 20:04:42 +0100 Subject: [PATCH 2/4] conflicts resolved --- frontend/__tests__/onboarding.test.tsx | 5 +- frontend/lib/onboardingState.ts | 70 ++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/frontend/__tests__/onboarding.test.tsx b/frontend/__tests__/onboarding.test.tsx index ad0c1904..248b1e14 100644 --- a/frontend/__tests__/onboarding.test.tsx +++ b/frontend/__tests__/onboarding.test.tsx @@ -34,7 +34,10 @@ import { ONBOARDING_KEY_STEP, } from "@/hooks/useOnboardingTour"; import OnboardingTour, { TOUR_STEPS, STEP_COUNT } from "@/components/OnboardingTour"; -import { getTourProgress, STORAGE_KEY as ONBOARDING_STATE_STORAGE_KEY } from "@/lib/onboardingState"; +import { + getTourProgress, + STORAGE_KEY as ONBOARDING_STATE_STORAGE_KEY, +} from "@/lib/onboardingState"; // ── Mock useWallet (transitively pulls in lib/wallet.ts -> @finchippay/sdk, // which isn't resolvable in this workspace's test environment) ────────────── diff --git a/frontend/lib/onboardingState.ts b/frontend/lib/onboardingState.ts index 5c56e4e2..770f5d7c 100644 --- a/frontend/lib/onboardingState.ts +++ b/frontend/lib/onboardingState.ts @@ -4,8 +4,12 @@ import { useState } from "react"; export const STORAGE_KEY = "finchippay:onboarding"; +export const ONBOARDING_STATE_VERSION = 1; +export const ONBOARDING_STEP_COUNT = 5; export interface OnboardingProgress { + /** Schema version used to safely evolve persisted onboarding data. */ + version: number; /** Indices of steps that the user has completed */ completedSteps: number[]; /** Whether the entire tour was completed */ @@ -28,6 +32,7 @@ function saveProgress(progress: OnboardingProgress): void { } const EMPTY_PROGRESS: OnboardingProgress = { + version: ONBOARDING_STATE_VERSION, completedSteps: [], completed: false, completedAt: null, @@ -36,15 +41,74 @@ const EMPTY_PROGRESS: OnboardingProgress = { featureVersions: {}, }; +function defaultProgress(): OnboardingProgress { + return { ...EMPTY_PROGRESS, completedSteps: [], featureVersions: {} }; +} + +function coerceBoolean(value: unknown): boolean { + return value === true || value === "true"; +} + +function validateProgress(value: unknown): OnboardingProgress | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + + const raw = value as Record; + if (raw.version !== undefined && raw.version !== ONBOARDING_STATE_VERSION) return null; + + const completedSteps = Array.isArray(raw.completedSteps) + ? [...new Set(raw.completedSteps.filter( + (step): step is number => + typeof step === "number" && + Number.isInteger(step) && + step >= 0 && + step < ONBOARDING_STEP_COUNT, + ))].sort((a, b) => a - b) + : []; + const featureVersions = + raw.featureVersions && + typeof raw.featureVersions === "object" && + !Array.isArray(raw.featureVersions) + ? Object.fromEntries( + Object.entries(raw.featureVersions as Record).map( + ([key, seen]) => [key, coerceBoolean(seen)], + ), + ) + : {}; + + return { + version: ONBOARDING_STATE_VERSION, + completedSteps, + completed: coerceBoolean(raw.completed), + completedAt: + typeof raw.completedAt === "number" && Number.isFinite(raw.completedAt) + ? raw.completedAt + : null, + dismissed: coerceBoolean(raw.dismissed), + lastSeen: + typeof raw.lastSeen === "number" && Number.isFinite(raw.lastSeen) + ? raw.lastSeen + : 0, + featureVersions, + }; +} + export function getTourProgress(): OnboardingProgress { if (typeof window === "undefined") { - return { ...EMPTY_PROGRESS, featureVersions: {} }; + return defaultProgress(); } try { const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return { ...EMPTY_PROGRESS, ...JSON.parse(raw) }; + if (raw) { + const parsed = JSON.parse(raw); + const progress = validateProgress(parsed); + if (progress) { + if (parsed.version !== ONBOARDING_STATE_VERSION) saveProgress(progress); + return progress; + } + localStorage.removeItem(STORAGE_KEY); + } } catch {} - return { ...EMPTY_PROGRESS, featureVersions: {} }; + return defaultProgress(); } export function markStepComplete(stepIndex: number): void { From 10f2cb24864288871d13fa1c5d84c20693db3580 Mon Sep 17 00:00:00 2001 From: Abubakar Mashud Date: Thu, 27 Aug 2026 20:21:23 +0100 Subject: [PATCH 3/4] fixed conflicts --- frontend/__tests__/onboarding.test.tsx | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/frontend/__tests__/onboarding.test.tsx b/frontend/__tests__/onboarding.test.tsx index 248b1e14..5e4e3fa3 100644 --- a/frontend/__tests__/onboarding.test.tsx +++ b/frontend/__tests__/onboarding.test.tsx @@ -36,6 +36,7 @@ import { import OnboardingTour, { TOUR_STEPS, STEP_COUNT } from "@/components/OnboardingTour"; import { getTourProgress, + ONBOARDING_STATE_VERSION, STORAGE_KEY as ONBOARDING_STATE_STORAGE_KEY, } from "@/lib/onboardingState"; @@ -385,6 +386,68 @@ describe("OnboardingTour component", () => { }; } + describe("persisted onboarding state", () => { + beforeEach(clearStorage); + afterEach(clearStorage); + + it("migrates a valid legacy payload to the versioned schema", () => { + localStorage.setItem( + ONBOARDING_STATE_STORAGE_KEY, + JSON.stringify({ + completedSteps: [0, 2], + completed: "true", + lastSeen: 123, + featureVersions: { welcome: "true" }, + }), + ); + + expect(getTourProgress()).toEqual({ + version: ONBOARDING_STATE_VERSION, + completedSteps: [0, 2], + completed: true, + completedAt: null, + dismissed: false, + lastSeen: 123, + featureVersions: { welcome: true }, + }); + }); + + it("resets malformed persisted state to defaults", () => { + localStorage.setItem(ONBOARDING_STATE_STORAGE_KEY, "{not-json"); + + expect(getTourProgress()).toEqual({ + version: ONBOARDING_STATE_VERSION, + completedSteps: [], + completed: false, + completedAt: null, + dismissed: false, + lastSeen: 0, + featureVersions: {}, + }); + }); + + it("drops unknown steps and resets unsupported schema versions", () => { + localStorage.setItem( + ONBOARDING_STATE_STORAGE_KEY, + JSON.stringify({ + version: ONBOARDING_STATE_VERSION, + completedSteps: [-1, 0, 4, 5, 99, 4], + completed: false, + lastSeen: "invalid", + featureVersions: {}, + }), + ); + expect(getTourProgress().completedSteps).toEqual([0, 4]); + + localStorage.setItem( + ONBOARDING_STATE_STORAGE_KEY, + JSON.stringify({ version: ONBOARDING_STATE_VERSION + 1 }), + ); + expect(getTourProgress().completed).toBe(false); + expect(localStorage.getItem(ONBOARDING_STATE_STORAGE_KEY)).toBeNull(); + }); + }); + it("renders nothing when tour is not running and not resumable", () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); From c4ff9daa9c93615527107d17c95439068a74f9d2 Mon Sep 17 00:00:00 2001 From: Abubakar Mashud Date: Thu, 27 Aug 2026 20:30:12 +0100 Subject: [PATCH 4/4] fixed --- frontend/lib/onboardingState.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/lib/onboardingState.ts b/frontend/lib/onboardingState.ts index 770f5d7c..c114dd72 100644 --- a/frontend/lib/onboardingState.ts +++ b/frontend/lib/onboardingState.ts @@ -102,6 +102,7 @@ export function getTourProgress(): OnboardingProgress { const parsed = JSON.parse(raw); const progress = validateProgress(parsed); if (progress) { + // Persist valid legacy data in the current schema after migration. if (parsed.version !== ONBOARDING_STATE_VERSION) saveProgress(progress); return progress; }