Skip to content
Merged
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions src/app/escrow/[bookingRef]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="max-w-5xl mx-auto px-6 py-16">
<span className="inline-block bg-navy/10 text-navy-2 text-xs font-semibold uppercase tracking-wide rounded-full px-3 py-1 mb-4">
Escrow
</span>
<h1 className="font-heading text-3xl font-semibold">Fund your booking&apos;s escrow</h1>
<p className="text-muted mt-2 mb-10 max-w-xl">
A few quick steps to lock your payment in escrow. You can save your progress and come back
any time before funding completes.
</p>

<EscrowFundingWizard
bookingRef={decodeURIComponent(bookingRef)}
amount={Number(amount) || 8800}
workerName={worker ? decodeURIComponent(worker) : "your pro"}
/>
</div>
);
}
235 changes: 235 additions & 0 deletions src/components/escrow/EscrowFundingWizard.tsx
Original file line number Diff line number Diff line change
@@ -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<EscrowState>(() => 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<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(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<typeof transition>[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<HTMLDivElement>) {
// 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 (
<Card className="p-6 md:p-8 max-w-xl mx-auto" onKeyDown={handleKeyDown}>
{/* Screen-reader-only live region: announces every state transition. */}
<div ref={announceRef} role="status" aria-live="polite" className="sr-only" />

{resumeAvailable && (
<div className="mb-6 rounded-xl bg-navy-tint text-navy-2 text-sm p-4 flex flex-col sm:flex-row sm:items-center gap-3 justify-between">
<span>
You have an unfinished escrow funding session from{" "}
{new Date(resumeAvailable.savedAt).toLocaleString()}.
</span>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="secondary" onClick={handleResume}>
Resume
</Button>
<Button size="sm" variant="outline" onClick={handleStartOver}>
Start over
</Button>
</div>
</div>
)}

<EscrowStepper currentState={state.name} furthestIndex={furthestIndex} onStepSelect={handleStepSelect} />

{/* Focus target for each step — tabIndex=-1 makes it programmatically
focusable without adding a tab stop. */}
<h1 ref={headingRef} tabIndex={-1} className="sr-only">
{STATE_ANNOUNCEMENTS[state.name]}
</h1>

<div className="mb-6">
{state.name === "review" && <ReviewStep context={state.context} />}
{state.name === "connectWallet" && (
<ConnectWalletStep
address={wallet.address}
connecting={wallet.connecting}
freighterMissing={wallet.freighterMissing}
error={wallet.error}
onConnect={handleConnectWallet}
/>
)}
{state.name === "confirm" && <ConfirmStep context={state.context} />}
{(state.name === "funding" || state.name === "funded" || state.name === "failed") && (
<FundingStatusStep state={state.name} context={state.context} />
)}
</div>

<div className="flex justify-between gap-3">
{state.name === "review" && (
<>
<span />
<Button onClick={() => dispatch({ type: "CONTINUE" })}>Continue</Button>
</>
)}

{state.name === "connectWallet" && (
<>
<Button variant="outline" onClick={() => dispatch({ type: "BACK" })}>
Back
</Button>
<span />
</>
)}

{state.name === "confirm" && (
<>
<Button variant="outline" onClick={() => dispatch({ type: "BACK" })}>
Back
</Button>
<Button onClick={handleFund}>Fund escrow</Button>
</>
)}

{state.name === "failed" && (
<>
<Button variant="outline" onClick={() => dispatch({ type: "RESTART" })}>
Start over
</Button>
<Button onClick={() => dispatch({ type: "RETRY" })}>Back to confirm</Button>
</>
)}

{state.name === "funded" && <span />}
</div>
</Card>
);
}
83 changes: 83 additions & 0 deletions src/components/escrow/EscrowStepper.tsx
Original file line number Diff line number Diff line change
@@ -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<Array<HTMLButtonElement | null>>([]);

function focusStep(index: number) {
buttonRefs.current[index]?.focus();
}

function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, 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 (
<ol className="flex flex-wrap gap-x-6 gap-y-3 mb-8" aria-label="Escrow funding progress">
{STATE_ORDER.map((name, index) => {
const isCurrent = index === currentIndex;
const isCompleted = index < furthestIndex || (index === furthestIndex && index < currentIndex);
const isReachable = index <= furthestIndex;

return (
<li key={name} className="flex items-center gap-2">
<button
ref={(el) => {
buttonRefs.current[index] = el;
}}
type="button"
disabled={!isReachable}
tabIndex={isCurrent ? 0 : -1}
onClick={() => isReachable && onStepSelect(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
aria-current={isCurrent ? "step" : undefined}
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gold ${
isCurrent
? "bg-navy text-white"
: isCompleted
? "bg-ok text-white"
: "bg-line text-muted"
} ${isReachable ? "cursor-pointer" : "cursor-not-allowed"}`}
>
{isCompleted ? <HiCheck /> : index + 1}
</button>
<span className={`text-sm ${isCurrent ? "font-semibold text-ink" : "text-muted"}`}>
{STATE_LABELS[name]}
</span>
</li>
);
})}
</ol>
);
}
28 changes: 28 additions & 0 deletions src/components/escrow/steps/ConfirmStep.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h2 className="font-heading text-xl font-semibold">Confirm & fund escrow</h2>
<p className="text-muted mt-1 mb-6">
Double-check the details below, then fund the escrow contract from your connected wallet.
</p>
<div className="rounded-xl border border-navy/15 bg-navy-tint p-4">
<div className="flex items-center gap-2 font-extrabold text-navy-2">
<FaLock aria-hidden /> {formatNaira(context.amount)} will be locked in escrow
</div>
<p className="mt-2 text-sm text-muted">
Paying from{" "}
<span className="font-mono text-ink">
{context.walletAddress ? truncateAddress(context.walletAddress) : "—"}
</span>{" "}
to {context.workerName} for booking{" "}
<span className="font-mono text-ink">{context.bookingRef}</span>.
</p>
</div>
</div>
);
}
Loading
Loading