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. */}
+
+ {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();
+ });
+});