diff --git a/frontend/__tests__/onboarding.test.tsx b/frontend/__tests__/onboarding.test.tsx
index 6447492c..5e4e3fa3 100644
--- a/frontend/__tests__/onboarding.test.tsx
+++ b/frontend/__tests__/onboarding.test.tsx
@@ -34,7 +34,11 @@ 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,
+ ONBOARDING_STATE_VERSION,
+ 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 +237,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 +263,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());
@@ -367,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();
@@ -435,16 +516,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..c114dd72 100644
--- a/frontend/lib/onboardingState.ts
+++ b/frontend/lib/onboardingState.ts
@@ -4,12 +4,20 @@
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 */
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 +31,85 @@ function saveProgress(progress: OnboardingProgress): void {
} catch {}
}
+const EMPTY_PROGRESS: OnboardingProgress = {
+ version: ONBOARDING_STATE_VERSION,
+ completedSteps: [],
+ completed: false,
+ completedAt: null,
+ dismissed: false,
+ lastSeen: 0,
+ 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 { completedSteps: [], completed: false, lastSeen: 0, featureVersions: {} };
+ return defaultProgress();
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
- if (raw) return JSON.parse(raw);
+ if (raw) {
+ 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;
+ }
+ localStorage.removeItem(STORAGE_KEY);
+ }
} catch {}
- return { completedSteps: [], completed: false, lastSeen: 0, featureVersions: {} };
+ return defaultProgress();
}
export function markStepComplete(stepIndex: number): void {
@@ -46,6 +124,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 +139,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 +180,10 @@ export function useOnboarding() {
markTourComplete();
refresh();
},
+ markTourDismissed: () => {
+ markTourDismissed();
+ refresh();
+ },
resetTour: () => {
resetTour();
refresh();