diff --git a/README.md b/README.md index 69f5578..fc64336 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,19 @@ Default branch is `dev`. Open a PR against `dev` for review before merging. ## Known limitations - timezone.ts and slotLock.ts now have unit tests. +- **Escrow funding wizard** (`src/components/escrow/`, `src/lib/escrowFunding.ts`, + route `/escrow/[bookingRef]`): a standalone, state-machine-driven wizard + (review → connect wallet → confirm → fund → funded/failed) for the "pay + into escrow" step, with full keyboard navigation, `aria-live` + announcements + focus management for screen readers, and resumable + progress via `localStorage` (same save-and-resume pattern as the identity + verification wizard). `fundEscrow()` simulates the round trip for the same + reason `submitIdentityVerification()` does — the Soroban `escrow` contract + isn't called from the backend yet (see + [Web3 / Stellar touches](#web3--stellar-touches)) — swap its body for a + real call once that integration lands. `BookingScreen`'s existing one-shot + "Pay into escrow" button is unchanged; wiring the booking flow to this + wizard is a follow-up. - The wallet-connect button is a real Freighter connection but doesn't yet do anything with the connected address — no contract calls, no signing. That's intentionally scoped to land alongside the backend Soroban diff --git a/src/app/escrow/[bookingRef]/page.tsx b/src/app/escrow/[bookingRef]/page.tsx new file mode 100644 index 0000000..7fe646b --- /dev/null +++ b/src/app/escrow/[bookingRef]/page.tsx @@ -0,0 +1,30 @@ +import EscrowFundingWizard from "@/components/escrow/EscrowFundingWizard"; + +interface EscrowFundingPageProps { + params: Promise<{ bookingRef: string }>; + searchParams: Promise<{ amount?: string; worker?: string }>; +} + +export default async function EscrowFundingPage({ params, searchParams }: EscrowFundingPageProps) { + const { bookingRef } = await params; + const { amount, worker } = await searchParams; + + return ( +
+ + Escrow + +

Fund your booking's escrow

+

+ A few quick steps to lock your payment in escrow. You can save your progress and come back + any time before funding completes. +

+ + +
+ ); +} diff --git a/src/components/escrow/EscrowFundingWizard.tsx b/src/components/escrow/EscrowFundingWizard.tsx new file mode 100644 index 0000000..7738cd1 --- /dev/null +++ b/src/components/escrow/EscrowFundingWizard.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Button from "@/components/ui/Button"; +import Card from "@/components/ui/Card"; +import { useWallet } from "@/lib/wallet"; +import EscrowStepper from "./EscrowStepper"; +import ReviewStep from "./steps/ReviewStep"; +import ConnectWalletStep from "./steps/ConnectWalletStep"; +import ConfirmStep from "./steps/ConfirmStep"; +import FundingStatusStep from "./steps/FundingStatusStep"; +import { + STATE_ANNOUNCEMENTS, + clearEscrowProgress, + fundEscrow, + initialEscrowState, + loadEscrowProgress, + progressIndex, + saveEscrowProgress, + transition, + type EscrowFundingContext, + type EscrowState, +} from "@/lib/escrowFunding"; + +interface EscrowFundingWizardProps { + bookingRef: string; + amount: number; + workerName: string; +} + +export default function EscrowFundingWizard({ bookingRef, amount, workerName }: EscrowFundingWizardProps) { + const baseContext: EscrowFundingContext = { + bookingRef, + amount, + workerName, + walletAddress: null, + errorMessage: null, + escrowReference: null, + }; + + const [state, setState] = useState(() => initialEscrowState(baseContext)); + const [furthestIndex, setFurthestIndex] = useState(0); + const [resumeAvailable, setResumeAvailable] = useState<{ savedAt: string } | null>(null); + const [resumeChecked, setResumeChecked] = useState(false); + + const wallet = useWallet(); + const announceRef = useRef(null); + const headingRef = useRef(null); + + // Offer to resume a saved session once, on mount. + useEffect(() => { + const saved = loadEscrowProgress(bookingRef); + if (saved) { + setResumeAvailable({ savedAt: saved.savedAt }); + } + setResumeChecked(true); + }, [bookingRef]); + + // Persist on every transition (the lib skips terminal/transient states). + useEffect(() => { + if (!resumeChecked) return; + saveEscrowProgress(state); + }, [state, resumeChecked]); + + // Move focus to the new step's heading on every transition, and announce + // it via the aria-live region — this is what makes the flow usable with a + // screen reader: focus alone doesn't guarantee the change is spoken if the + // heading text hasn't visually changed order, and a live region alone + // doesn't move sighted-keyboard-user focus back to the top of the step. + useEffect(() => { + if (announceRef.current) { + announceRef.current.textContent = STATE_ANNOUNCEMENTS[state.name]; + } + headingRef.current?.focus(); + }, [state.name]); + + function dispatch(event: Parameters[1]) { + setState((prev) => { + const next = transition(prev, event); + setFurthestIndex((idx) => Math.max(idx, progressIndex(next.name))); + return next; + }); + } + + function handleResume() { + const saved = loadEscrowProgress(bookingRef); + if (saved) { + setState({ name: saved.name, context: saved.context }); + setFurthestIndex(progressIndex(saved.name)); + } + setResumeAvailable(null); + } + + function handleStartOver() { + clearEscrowProgress(bookingRef); + setState(initialEscrowState(baseContext)); + setFurthestIndex(0); + setResumeAvailable(null); + } + + function handleStepSelect(index: number) { + const target = ["review", "connectWallet", "confirm", "funding", "funded"] as const; + const name = target[index]; + if (index <= furthestIndex && name !== "funding" && name !== "funded") { + setState((prev) => ({ name, context: prev.context })); + } + } + + async function handleConnectWallet() { + await wallet.connect(); + } + + useEffect(() => { + if (wallet.address && state.name === "connectWallet") { + dispatch({ type: "WALLET_CONNECTED", address: wallet.address }); + } + }, [wallet.address, state.name]); + + async function handleFund() { + dispatch({ type: "FUND_START" }); + try { + const result = await fundEscrow(state.context); + dispatch({ type: "FUND_SUCCESS", escrowReference: result.escrowReference }); + clearEscrowProgress(bookingRef); + } catch (error) { + dispatch({ + type: "FUND_ERROR", + message: error instanceof Error ? error.message : "Something went wrong. Please try again.", + }); + } + } + + function handleKeyDown(event: React.KeyboardEvent) { + // Enter anywhere in the wizard body advances the primary action, unless + // focus is on an interactive element that already handles Enter itself + // (a link, or a button — which would otherwise double-fire). + const target = event.target as HTMLElement; + if (event.key === "Enter" && target.tagName !== "BUTTON" && target.tagName !== "A") { + if (state.name === "review") { + event.preventDefault(); + dispatch({ type: "CONTINUE" }); + } else if (state.name === "confirm") { + event.preventDefault(); + handleFund(); + } + } + } + + return ( + + {/* Screen-reader-only live region: announces every state transition. */} +
+ + {resumeAvailable && ( +
+ + You have an unfinished escrow funding session from{" "} + {new Date(resumeAvailable.savedAt).toLocaleString()}. + +
+ + +
+
+ )} + + + + {/* Focus target for each step — tabIndex=-1 makes it programmatically + focusable without adding a tab stop. */} +

+ {STATE_ANNOUNCEMENTS[state.name]} +

+ +
+ {state.name === "review" && } + {state.name === "connectWallet" && ( + + )} + {state.name === "confirm" && } + {(state.name === "funding" || state.name === "funded" || state.name === "failed") && ( + + )} +
+ +
+ {state.name === "review" && ( + <> + + + + )} + + {state.name === "connectWallet" && ( + <> + + + + )} + + {state.name === "confirm" && ( + <> + + + + )} + + {state.name === "failed" && ( + <> + + + + )} + + {state.name === "funded" && } +
+ + ); +} diff --git a/src/components/escrow/EscrowStepper.tsx b/src/components/escrow/EscrowStepper.tsx new file mode 100644 index 0000000..6861c47 --- /dev/null +++ b/src/components/escrow/EscrowStepper.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useRef } from "react"; +import { HiCheck } from "react-icons/hi"; +import { STATE_ORDER, STATE_LABELS, progressIndex, type EscrowStateName } from "@/lib/escrowFunding"; + +interface EscrowStepperProps { + currentState: EscrowStateName; + /** Highest step index reached so far — steps at or before this are + selectable, steps beyond it are disclosed but inert (same progressive + disclosure pattern as IdentityStepper). */ + furthestIndex: number; + onStepSelect: (index: number) => void; +} + +/** Accessible step nav: a roving-tabindex button group (arrow keys move + focus, Enter/Space activates) with `aria-current` on the active step, so + the whole stepper is a single stop in the page's tab order rather than + one stop per step. */ +export default function EscrowStepper({ currentState, furthestIndex, onStepSelect }: EscrowStepperProps) { + const currentIndex = progressIndex(currentState); + const buttonRefs = useRef>([]); + + function focusStep(index: number) { + buttonRefs.current[index]?.focus(); + } + + function handleKeyDown(event: React.KeyboardEvent, index: number) { + if (event.key === "ArrowRight" || event.key === "ArrowDown") { + event.preventDefault(); + const next = Math.min(index + 1, STATE_ORDER.length - 1); + focusStep(next); + } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + event.preventDefault(); + const prev = Math.max(index - 1, 0); + focusStep(prev); + } else if (event.key === "Home") { + event.preventDefault(); + focusStep(0); + } else if (event.key === "End") { + event.preventDefault(); + focusStep(STATE_ORDER.length - 1); + } + } + + return ( +
    + {STATE_ORDER.map((name, index) => { + const isCurrent = index === currentIndex; + const isCompleted = index < furthestIndex || (index === furthestIndex && index < currentIndex); + const isReachable = index <= furthestIndex; + + return ( +
  1. + + + {STATE_LABELS[name]} + +
  2. + ); + })} +
+ ); +} diff --git a/src/components/escrow/steps/ConfirmStep.tsx b/src/components/escrow/steps/ConfirmStep.tsx new file mode 100644 index 0000000..8bae819 --- /dev/null +++ b/src/components/escrow/steps/ConfirmStep.tsx @@ -0,0 +1,28 @@ +import { FaLock } from "react-icons/fa6"; +import { formatNaira } from "@/lib/marketplace"; +import { truncateAddress } from "@/lib/wallet"; +import type { EscrowFundingContext } from "@/lib/escrowFunding"; + +export default function ConfirmStep({ context }: { context: EscrowFundingContext }) { + return ( +
+

Confirm & fund escrow

+

+ Double-check the details below, then fund the escrow contract from your connected wallet. +

+
+
+ {formatNaira(context.amount)} will be locked in escrow +
+

+ Paying from{" "} + + {context.walletAddress ? truncateAddress(context.walletAddress) : "—"} + {" "} + to {context.workerName} for booking{" "} + {context.bookingRef}. +

+
+
+ ); +} diff --git a/src/components/escrow/steps/ConnectWalletStep.tsx b/src/components/escrow/steps/ConnectWalletStep.tsx new file mode 100644 index 0000000..efd1898 --- /dev/null +++ b/src/components/escrow/steps/ConnectWalletStep.tsx @@ -0,0 +1,62 @@ +import { FaWallet } from "react-icons/fa6"; +import Button from "@/components/ui/Button"; +import { truncateAddress } from "@/lib/wallet"; + +interface ConnectWalletStepProps { + address: string | null; + connecting: boolean; + freighterMissing: boolean; + error: string | null; + onConnect: () => void; +} + +export default function ConnectWalletStep({ + address, + connecting, + freighterMissing, + error, + onConnect, +}: ConnectWalletStepProps) { + return ( +
+

Connect your wallet

+

+ We use your Stellar wallet to fund the escrow contract directly — no card details needed. +

+ + {address ? ( +
+ + {truncateAddress(address)} +
+ ) : ( +
+ + + {freighterMissing && ( +

+ Freighter extension not detected.{" "} + + Install Freighter + {" "} + to continue. +

+ )} +
+ )} + + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/components/escrow/steps/FundingStatusStep.tsx b/src/components/escrow/steps/FundingStatusStep.tsx new file mode 100644 index 0000000..7b4f9f8 --- /dev/null +++ b/src/components/escrow/steps/FundingStatusStep.tsx @@ -0,0 +1,49 @@ +import { FaCircleCheck, FaCircleExclamation, FaSpinner } from "react-icons/fa6"; +import { formatNaira } from "@/lib/marketplace"; +import type { EscrowFundingContext, EscrowStateName } from "@/lib/escrowFunding"; + +interface FundingStatusStepProps { + state: Extract; + context: EscrowFundingContext; +} + +export default function FundingStatusStep({ state, context }: FundingStatusStepProps) { + if (state === "funding") { + return ( +
+ +

Funding escrow…

+

+ Locking {formatNaira(context.amount)} on Stellar. This usually takes a few seconds. +

+
+ ); + } + + if (state === "funded") { + return ( +
+ +

Escrow funded

+

+ {formatNaira(context.amount)} is now held in escrow for {context.workerName}. +

+ {context.escrowReference && ( +

+ {context.escrowReference} +

+ )} +
+ ); + } + + return ( +
+ +

Funding failed

+

+ {context.errorMessage ?? "Something went wrong. Please try again."} +

+
+ ); +} diff --git a/src/components/escrow/steps/ReviewStep.tsx b/src/components/escrow/steps/ReviewStep.tsx new file mode 100644 index 0000000..320fd67 --- /dev/null +++ b/src/components/escrow/steps/ReviewStep.tsx @@ -0,0 +1,33 @@ +import { FaLock } from "react-icons/fa6"; +import { formatNaira } from "@/lib/marketplace"; +import type { EscrowFundingContext } from "@/lib/escrowFunding"; + +export default function ReviewStep({ context }: { context: EscrowFundingContext }) { + return ( +
+

Review booking details

+

+ Confirm the amount before locking it in escrow for {context.workerName}. +

+
+
+
Booking reference
+
{context.bookingRef}
+
+
+
Worker
+
{context.workerName}
+
+
+
Amount to fund
+
{formatNaira(context.amount)}
+
+
+

+ + Once funded, this amount is held in escrow on Stellar and released only when you confirm + the job's done — or refunded in full if it falls through. +

+
+ ); +} diff --git a/src/lib/escrowFunding.ts b/src/lib/escrowFunding.ts new file mode 100644 index 0000000..949da3a --- /dev/null +++ b/src/lib/escrowFunding.ts @@ -0,0 +1,236 @@ +/** + * Escrow funding wizard — explicit finite state machine, save-and-resume + * persistence, and the funding call. + * + * WHY AN EXPLICIT STATE MACHINE + * The wizard has real branching (wallet not connected, funding can fail and + * be retried, a step can only be reached once its predecessor succeeds) that + * a bare `stepIndex` counter can't express safely — it's easy to end up with + * a UI showing "funding…" while state actually says "failed", or to let a + * user "continue" past a step that never completed. Modeling it as a + * transition table instead means every reachable (state, event) pair is + * enumerated once here, `transition()` is a pure function the UI/tests can + * drive directly, and invalid transitions (e.g. FUND_SUCCESS from Review) + * are simply absent from the table rather than a runtime state that has to + * be special-cased everywhere. + * + * WHY THIS IS A FRONTEND-ONLY STUB (read before wiring a real endpoint) + * `guildworkman-core`'s Soroban `escrow` contract isn't called from the + * backend yet (see the README's "Web3 / Stellar touches" section and the + * NOTE in `BookingScreen.tsx`) — there's no REST or RPC endpoint today that + * actually locks funds on-chain. Rather than invent a shape the backend + * can't answer, `fundEscrow` below simulates the round trip (latency + a + * success/failure outcome) so the wizard's state transitions, save-and- + * resume, and error-recovery UI are fully exercised today. Swap its body for + * a real call once the backend/contract integration lands — the wizard + * component doesn't need to change, only this function's implementation. + */ + +export type EscrowStateName = + | "review" + | "connectWallet" + | "confirm" + | "funding" + | "funded" + | "failed"; + +export interface EscrowFundingContext { + bookingRef: string; + amount: number; + workerName: string; + /** Set once a wallet has been connected (stubbed — see lib/wallet.ts). */ + walletAddress: string | null; + /** Populated on FUND_ERROR so the Confirm step can show a retry with context. */ + errorMessage: string | null; + /** Populated on FUND_SUCCESS. */ + escrowReference: string | null; +} + +export interface EscrowState { + name: EscrowStateName; + context: EscrowFundingContext; +} + +export type EscrowEvent = + | { type: "CONTINUE" } + | { type: "BACK" } + | { type: "WALLET_CONNECTED"; address: string } + | { type: "FUND_START" } + | { type: "FUND_SUCCESS"; escrowReference: string } + | { type: "FUND_ERROR"; message: string } + | { type: "RETRY" } + | { type: "RESTART" }; + +export const STATE_ORDER: EscrowStateName[] = [ + "review", + "connectWallet", + "confirm", + "funding", + "funded", +]; + +export const STATE_LABELS: Record = { + review: "Review", + connectWallet: "Connect wallet", + confirm: "Confirm & fund", + funding: "Funding escrow", + funded: "Funded", + failed: "Funding failed", +}; + +/** Human-readable sentence announced to screen readers (via an `aria-live` + region) on every transition — written to name the state a user has + *arrived at*, not the event that caused it, since that's what someone + listening needs to know next. */ +export const STATE_ANNOUNCEMENTS: Record = { + review: "Step 1 of 4: Review booking details.", + connectWallet: "Step 2 of 4: Connect your Stellar wallet.", + confirm: "Step 3 of 4: Confirm the amount and fund escrow.", + funding: "Funding escrow, please wait.", + funded: "Escrow funded successfully. Your payment is now held in escrow.", + failed: "Funding failed. You can retry or go back to review your details.", +}; + +/** Pure state transition table. Returns the same state object (no-op) for + any event not valid in the current state, so callers can dispatch freely + without pre-checking validity. */ +export function transition(state: EscrowState, event: EscrowEvent): EscrowState { + const { name, context } = state; + + switch (name) { + case "review": + if (event.type === "CONTINUE") { + return { name: "connectWallet", context }; + } + break; + + case "connectWallet": + if (event.type === "WALLET_CONNECTED") { + return { + name: "confirm", + context: { ...context, walletAddress: event.address }, + }; + } + if (event.type === "BACK") { + return { name: "review", context }; + } + break; + + case "confirm": + if (event.type === "FUND_START") { + return { name: "funding", context: { ...context, errorMessage: null } }; + } + if (event.type === "BACK") { + return { name: "connectWallet", context }; + } + break; + + case "funding": + if (event.type === "FUND_SUCCESS") { + return { + name: "funded", + context: { ...context, escrowReference: event.escrowReference, errorMessage: null }, + }; + } + if (event.type === "FUND_ERROR") { + return { name: "failed", context: { ...context, errorMessage: event.message } }; + } + break; + + case "failed": + if (event.type === "RETRY") { + return { name: "confirm", context: { ...context, errorMessage: null } }; + } + if (event.type === "RESTART") { + return { name: "review", context: { ...context, errorMessage: null } }; + } + break; + + case "funded": + break; + } + + return state; +} + +/** Index into STATE_ORDER for progress display; "failed" maps to the + "confirm" step it can retry from, since it isn't a step of its own. */ +export function progressIndex(name: EscrowStateName): number { + if (name === "failed") return STATE_ORDER.indexOf("confirm"); + return STATE_ORDER.indexOf(name); +} + +export function initialEscrowState(context: EscrowFundingContext): EscrowState { + return { name: "review", context }; +} + +const STORAGE_KEY_PREFIX = "gw-escrow-funding-v1:"; + +export interface PersistedEscrowState { + name: EscrowStateName; + context: EscrowFundingContext; + savedAt: string; +} + +function storageKey(bookingRef: string): string { + return `${STORAGE_KEY_PREFIX}${bookingRef}`; +} + +/** Terminal/transient states are never persisted: "funding" can't be resumed + mid-flight (there's nothing to reconnect to), and "funded" has nothing + left to resume — the wizard just clears storage on success. */ +const RESUMABLE_STATES: EscrowStateName[] = ["review", "connectWallet", "confirm", "failed"]; + +export function saveEscrowProgress(state: EscrowState): void { + if (typeof window === "undefined") return; + if (!RESUMABLE_STATES.includes(state.name)) return; + const record: PersistedEscrowState = { + name: state.name, + context: state.context, + savedAt: new Date().toISOString(), + }; + try { + window.localStorage.setItem(storageKey(state.context.bookingRef), JSON.stringify(record)); + } catch { + // Quota exceeded or storage disabled — resume just won't be available. + } +} + +export function loadEscrowProgress(bookingRef: string): PersistedEscrowState | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(storageKey(bookingRef)); + if (!raw) return null; + const parsed = JSON.parse(raw) as PersistedEscrowState; + if (!RESUMABLE_STATES.includes(parsed.name)) return null; + return parsed; + } catch { + return null; + } +} + +export function clearEscrowProgress(bookingRef: string): void { + if (typeof window === "undefined") return; + window.localStorage.removeItem(storageKey(bookingRef)); +} + +export interface FundEscrowResult { + escrowReference: string; +} + +/** Simulated funding call — see the module doc comment for why this doesn't + call a real contract yet. Fails roughly 1 in 6 tries so the wizard's + error-recovery path (inline error + retry, context left intact) is + reachable without special test hooks. */ +export function fundEscrow(context: EscrowFundingContext): Promise { + void context; // not sent anywhere yet — see module doc comment + return new Promise((resolve, reject) => { + setTimeout(() => { + if (Math.random() < 1 / 6) { + reject(new Error("We couldn't reach the escrow service. Please try again.")); + return; + } + resolve({ escrowReference: `ESC-${Date.now().toString(36).toUpperCase()}` }); + }, 1200); + }); +} diff --git a/src/lib/test/escrowFunding.test.ts b/src/lib/test/escrowFunding.test.ts new file mode 100644 index 0000000..4007779 --- /dev/null +++ b/src/lib/test/escrowFunding.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + STATE_ORDER, + clearEscrowProgress, + initialEscrowState, + loadEscrowProgress, + progressIndex, + saveEscrowProgress, + transition, + type EscrowFundingContext, + type EscrowState, +} from "../escrowFunding"; + +const baseContext: EscrowFundingContext = { + bookingRef: "GW-1234", + amount: 8800, + workerName: "Chidi O.", + walletAddress: null, + errorMessage: null, + escrowReference: null, +}; + +function freshState(): EscrowState { + return initialEscrowState(baseContext); +} + +describe("transition", () => { + it("starts in review", () => { + expect(freshState().name).toBe("review"); + }); + + it("walks the happy path from review to funded", () => { + let state = freshState(); + state = transition(state, { type: "CONTINUE" }); + expect(state.name).toBe("connectWallet"); + + state = transition(state, { type: "WALLET_CONNECTED", address: "GABC...WXYZ" }); + expect(state.name).toBe("confirm"); + expect(state.context.walletAddress).toBe("GABC...WXYZ"); + + state = transition(state, { type: "FUND_START" }); + expect(state.name).toBe("funding"); + + state = transition(state, { type: "FUND_SUCCESS", escrowReference: "ESC-1" }); + expect(state.name).toBe("funded"); + expect(state.context.escrowReference).toBe("ESC-1"); + }); + + it("moves to failed on FUND_ERROR and preserves the message", () => { + let state = freshState(); + state = transition(state, { type: "CONTINUE" }); + state = transition(state, { type: "WALLET_CONNECTED", address: "GABC...WXYZ" }); + state = transition(state, { type: "FUND_START" }); + state = transition(state, { type: "FUND_ERROR", message: "network down" }); + + expect(state.name).toBe("failed"); + expect(state.context.errorMessage).toBe("network down"); + }); + + it("RETRY from failed returns to confirm and clears the error", () => { + const failed: EscrowState = { + name: "failed", + context: { ...baseContext, errorMessage: "boom" }, + }; + const state = transition(failed, { type: "RETRY" }); + expect(state.name).toBe("confirm"); + expect(state.context.errorMessage).toBeNull(); + }); + + it("RESTART from failed returns to review and clears the error", () => { + const failed: EscrowState = { + name: "failed", + context: { ...baseContext, errorMessage: "boom" }, + }; + const state = transition(failed, { type: "RESTART" }); + expect(state.name).toBe("review"); + expect(state.context.errorMessage).toBeNull(); + }); + + it("BACK moves confirm to connectWallet and connectWallet to review", () => { + const confirm: EscrowState = { name: "confirm", context: baseContext }; + expect(transition(confirm, { type: "BACK" }).name).toBe("connectWallet"); + + const connectWallet: EscrowState = { name: "connectWallet", context: baseContext }; + expect(transition(connectWallet, { type: "BACK" }).name).toBe("review"); + }); + + it("ignores events that aren't valid for the current state", () => { + const state = freshState(); + const next = transition(state, { type: "FUND_SUCCESS", escrowReference: "ESC-1" }); + expect(next).toBe(state); + }); + + it("funded is terminal — no event moves it elsewhere", () => { + const funded: EscrowState = { + name: "funded", + context: { ...baseContext, escrowReference: "ESC-1" }, + }; + expect(transition(funded, { type: "CONTINUE" })).toBe(funded); + expect(transition(funded, { type: "BACK" })).toBe(funded); + }); +}); + +describe("progressIndex", () => { + it("matches STATE_ORDER for step states", () => { + expect(progressIndex("review")).toBe(STATE_ORDER.indexOf("review")); + expect(progressIndex("funded")).toBe(STATE_ORDER.indexOf("funded")); + }); + + it("maps failed back onto the confirm step", () => { + expect(progressIndex("failed")).toBe(STATE_ORDER.indexOf("confirm")); + }); +}); + +describe("saveEscrowProgress / loadEscrowProgress / clearEscrowProgress", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("returns null when nothing has been saved", () => { + expect(loadEscrowProgress(baseContext.bookingRef)).toBeNull(); + }); + + it("round-trips a resumable state", () => { + const state: EscrowState = { name: "confirm", context: baseContext }; + saveEscrowProgress(state); + + const loaded = loadEscrowProgress(baseContext.bookingRef); + expect(loaded?.name).toBe("confirm"); + expect(loaded?.context.bookingRef).toBe(baseContext.bookingRef); + expect(loaded?.savedAt).toBeTruthy(); + }); + + it("does not persist the funding state", () => { + saveEscrowProgress({ name: "funding", context: baseContext }); + expect(loadEscrowProgress(baseContext.bookingRef)).toBeNull(); + }); + + it("does not persist the funded state", () => { + saveEscrowProgress({ name: "funded", context: baseContext }); + expect(loadEscrowProgress(baseContext.bookingRef)).toBeNull(); + }); + + it("keeps separate progress per booking reference", () => { + saveEscrowProgress({ name: "review", context: { ...baseContext, bookingRef: "GW-AAAA" } }); + saveEscrowProgress({ name: "confirm", context: { ...baseContext, bookingRef: "GW-BBBB" } }); + + expect(loadEscrowProgress("GW-AAAA")?.name).toBe("review"); + expect(loadEscrowProgress("GW-BBBB")?.name).toBe("confirm"); + }); + + it("clears a saved session", () => { + saveEscrowProgress({ name: "review", context: baseContext }); + expect(loadEscrowProgress(baseContext.bookingRef)).not.toBeNull(); + clearEscrowProgress(baseContext.bookingRef); + expect(loadEscrowProgress(baseContext.bookingRef)).toBeNull(); + }); + + it("recovers gracefully from corrupted storage", () => { + window.localStorage.setItem(`gw-escrow-funding-v1:${baseContext.bookingRef}`, "{not-json"); + expect(loadEscrowProgress(baseContext.bookingRef)).toBeNull(); + }); +});