Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 86 additions & 5 deletions frontend/__tests__/onboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────────
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand Down Expand Up @@ -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(<OnboardingTour tour={makeTourState()} />);
expect(container).toBeEmptyDOMElement();
Expand Down Expand Up @@ -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(
<OnboardingTour
tour={makeTourState({ isRunning: true, skipTour, setStepIndex })}
tour={makeTourState({ isRunning: true, dismissForever, setStepIndex })}
/>
);
fireEvent.click(screen.getByTestId("joyride-skip"));
expect(skipTour).toHaveBeenCalled();
expect(dismissForever).toHaveBeenCalled();
expect(setStepIndex).toHaveBeenCalled();
});

Expand Down
6 changes: 3 additions & 3 deletions frontend/components/OnboardingTour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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");
}
Expand All @@ -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");
}
Expand Down
13 changes: 11 additions & 2 deletions frontend/hooks/useOnboardingTour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
*/

import { useState, useEffect, useCallback } from "react";
import {
getTourProgress,
markTourComplete,
markTourDismissed,
} from "@/lib/onboardingState";

// ─── localStorage keys ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -167,6 +174,7 @@ export function useOnboardingTour(): OnboardingTourState {
}, []);

const completeTour = useCallback(() => {
markTourComplete();
writeBool(ONBOARDING_KEY_COMPLETED, true);
writeInt(ONBOARDING_KEY_STEP, 0);
setIsCompleted(true);
Expand All @@ -176,6 +184,7 @@ export function useOnboardingTour(): OnboardingTourState {
}, []);

const dismissForever = useCallback(() => {
markTourDismissed();
writeBool(ONBOARDING_KEY_DISMISSED, true);
setIsDismissed(true);
setIsRunning(false);
Expand Down
101 changes: 97 additions & 4 deletions frontend/lib/onboardingState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -23,15 +31,85 @@
} 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<string, unknown>;
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<string, unknown>).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 {
Expand All @@ -46,19 +124,30 @@
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);
}

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;
}
Expand Down Expand Up @@ -91,6 +180,10 @@
markTourComplete();
refresh();
},
markTourDismissed: () => {
markTourDismissed();
refresh();
},
resetTour: () => {
resetTour();
refresh();
Expand Down
Loading