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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,17 @@ Default branch is `dev`. Open a PR against `dev` for review before merging.
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.
- **Escrow status timeline** (`src/components/escrow/EscrowTimelinePanel.tsx`,
`src/lib/escrowTimeline.ts`, route `/escrow/[bookingRef]/timeline`): a
live, chronological timeline of the on-chain escrow lifecycle
(`funded → completed | cancelled | disputed → resolved`, mirroring the
Soroban `escrow` contract's `Status`). User actions apply **optimistically**
and either confirm into the history or **roll back gracefully** on failure;
a visibility-aware background poll keeps it in sync with the authoritative
status. The chain calls live in an isolated stub (`src/lib/escrowChain.ts`)
for the same reason `fundEscrow()` does — swap its two functions for a real
endpoint when the backend Soroban integration lands. See
[`docs/escrow-status-timeline.md`](docs/escrow-status-timeline.md).
- 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
111 changes: 111 additions & 0 deletions docs/escrow-status-timeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Real-Time Escrow Status Timeline with Optimistic Updates

Implements [#26](https://github.com/workman-labs/guildworkman-web/issues/26):
a live escrow lifecycle timeline that reflects on-chain state changes, applies
user actions optimistically, and rolls back gracefully on failure.

## What it does

Route `/escrow/[bookingRef]/timeline` renders the escrow's lifecycle as a
vertical, chronological timeline. From the funded state a client can **release
funds**, **cancel & refund**, or **raise a dispute**; a dispute can then be
**resolved**. Each action:

1. appears **instantly** as an optimistic node at the end of the timeline,
2. **confirms** into the history (with a tx hash + timestamp) once the chain
settles it, or
3. **rolls back** — the optimistic node disappears, the confirmed history is
untouched, and an inline error offers a retry.

A background poll keeps the timeline in sync with the authoritative on-chain
status, so a change made elsewhere (e.g. a counterparty acting) shows up live.

## Lifecycle model

Mirrors the Soroban `escrow` contract's `Status` enum and entrypoints
(`guildworkman-core/soroban-contracts/contracts/escrow`):

```
funded ──release──▶ completed (terminal)
──cancel───▶ cancelled (terminal)
──dispute──▶ disputed ──resolve──▶ resolved (terminal)
```

`actionsFor(status)` is the single source of truth for which actions are legal
from a given status — it drives both the buttons the UI offers and the
transitions the reducer accepts.

## Architecture decisions

1. **The timeline is an append-only event log, not a mutable `status`.**
`TimelineState.history` is the chronological list of confirmed transitions
(always rooted at `funded`); the current status is just its last entry. This
is what the UI needs to *draw* a timeline, and it makes the optimistic layer
trivially safe: an in-flight action is a single `pending` node appended
after the confirmed history, never a mutation of it — so rollback is "drop
the pending node" and the confirmed history is untouched by construction.

2. **One pure reducer owns every transition.** `timelineReducer` handles
`SUBMIT` / `CONFIRMED` / `FAILED` / `SYNC` / `DISMISS_ERROR` as a pure
function the UI and tests drive directly. Optimistic-apply-then-rollback
isn't ad-hoc `useState` juggling; it's `SUBMIT` then `FAILED`, both tested.
Stale confirmations/failures (superseded by a poll) are ignored by
submission-id matching, and `SYNC` reconciles authoritative on-chain state:
it commits an in-flight action whose target it observes, adopts a legal
external change (dropping any now-impossible optimistic node), and ignores
states it can't reconcile.

3. **The only impure part is an isolated, swappable chain stub.**
`lib/escrowChain.ts` simulates the contract (latency, an occasional failed
submission, an in-memory ledger the poll reads) so the whole feature is
exercised today. Same rationale as the funding wizard's `fundEscrow` stub —
`guildworkman-core`'s Soroban `escrow` contract has the methods, but no
backend REST/RPC endpoint exposes them to the web app yet. Swap the two
functions' bodies for real calls and nothing else changes. Keeping this out
of `escrowTimeline.ts` keeps the reducer pure and singleton-free for tests.

4. **Real-time via visibility-aware polling.** `useEscrowTimeline` polls every
4s, pauses while the tab is hidden, and polls once immediately on becoming
visible again. A future SSE/WebSocket feed can replace the poll behind the
same `SYNC` dispatch without touching the reducer or components.

5. **Accessibility.** The optimistic node is an `aria-live` region; confirmed
status changes are announced through a separate visually-hidden live region;
the rollback error is `role="alert"`. Colours come entirely from the design
tokens, so light/dark both work.

## No new dependencies

Uses React (incl. `useSyncExternalStore` for hydration-safe timestamps),
`react-icons`, and the existing `ui/*` primitives and Tailwind token setup.

## Files

| File | Role |
|---|---|
| `src/lib/escrowTimeline.ts` | Pure lifecycle model: statuses, actions, the reducer, and `buildTimelineNodes`. |
| `src/lib/escrowChain.ts` | Isolated simulated on-chain source (swap for a real endpoint). |
| `src/components/escrow/useEscrowTimeline.ts` | Hook: wires the reducer to the chain (optimistic submit + polling). |
| `src/components/escrow/useHydrated.ts` | Hydration-safe "client only" flag for locale timestamps. |
| `src/components/escrow/EscrowTimeline.tsx` | Presentational vertical timeline. |
| `src/components/escrow/EscrowTimelinePanel.tsx` | Smart panel: timeline + actions + live-sync + rollback UI. |
| `src/app/escrow/[bookingRef]/timeline/page.tsx` | Route. |
| `src/lib/test/escrowTimeline.test.ts` | 22 reducer/metadata unit tests. |

`components/escrow/steps/FundingStatusStep.tsx` gained a "Track escrow status"
link from the funded state of the existing funding wizard.

## Verification

- `npm run lint` — clean (no new warnings)
- `npm run typecheck` — no errors
- `npm test` — 110 passed (incl. 22 new)
- `npm run build` — production build succeeds; `/escrow/[bookingRef]/timeline`
emitted

## CI note

The issue's "add caching for npm dependencies in CI" task is already satisfied:
`.github/workflows/ci.yml` uses `actions/setup-node` with `cache: npm`, and
runs typecheck → lint → test → build, so any PR introducing a type error is
blocked.
30 changes: 30 additions & 0 deletions src/app/escrow/[bookingRef]/timeline/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import EscrowTimelinePanel from "@/components/escrow/EscrowTimelinePanel";

interface EscrowTimelinePageProps {
params: Promise<{ bookingRef: string }>;
searchParams: Promise<{ worker?: string; fundedAt?: string }>;
}

export default async function EscrowTimelinePage({ params, searchParams }: EscrowTimelinePageProps) {
const { bookingRef } = await params;
const { worker, fundedAt } = await searchParams;

return (
<div className="mx-auto max-w-3xl px-6 py-16">
<span className="mb-4 inline-block rounded-full bg-navy/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-navy-2">
Escrow
</span>
<h1 className="font-heading text-3xl font-semibold">Escrow status timeline</h1>
<p className="mb-10 mt-2 max-w-xl text-muted">
Track your booking&apos;s escrow live as it moves through its on-chain lifecycle. Actions
appear instantly and confirm — or roll back — as the Stellar network settles them.
</p>

<EscrowTimelinePanel
bookingRef={decodeURIComponent(bookingRef)}
workerName={worker ? decodeURIComponent(worker) : "your pro"}
fundedAt={fundedAt ?? new Date().toISOString()}
/>
</div>
);
}
87 changes: 87 additions & 0 deletions src/components/escrow/EscrowTimeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";

import { HiCheck } from "react-icons/hi";
import { FaSpinner } from "react-icons/fa6";
import Badge from "@/components/ui/Badge";
import { useHydrated } from "./useHydrated";
import {
ACTION_PENDING_LABELS,
STATUS_TONE,
type TimelineNode,
} from "@/lib/escrowTimeline";

interface EscrowTimelineProps {
nodes: TimelineNode[];
}

function DotConnector({ isLast }: { isLast: boolean }) {
return (
<span
aria-hidden
className={`absolute left-[13px] top-7 w-px bg-line ${isLast ? "hidden" : "bottom-0"}`}
/>
);
}

export default function EscrowTimeline({ nodes }: EscrowTimelineProps) {
const mounted = useHydrated();

return (
<ol className="relative" aria-label="Escrow status timeline">
{nodes.map((node, index) => {
const isLast = index === nodes.length - 1;
const isOptimistic = node.phase === "optimistic";
const tone = STATUS_TONE[node.status];

return (
<li
key={`${node.status}-${node.at}-${node.phase}`}
className="relative flex gap-4 pb-7 last:pb-0"
{...(isOptimistic ? { role: "status", "aria-live": "polite" } : {})}
>
<DotConnector isLast={isLast} />

{/* Rail dot: a check for confirmed steps, a spinner for the
optimistic in-flight one. */}
<span
aria-hidden
className={`relative z-10 mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border-2 ${
isOptimistic
? "animate-pulse border-gold bg-gold/15 text-gold-deep"
: "border-transparent bg-navy text-white"
}`}
>
{isOptimistic ? <FaSpinner className="animate-spin text-xs" /> : <HiCheck className="text-sm" />}
</span>

<div className={`min-w-0 flex-1 ${isOptimistic ? "opacity-90" : ""}`}>
<div className="flex flex-wrap items-center gap-2">
<Badge tone={tone}>{node.label}</Badge>
{isOptimistic && (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-gold-deep">
{ACTION_PENDING_LABELS[node.action ?? "release"]}
</span>
)}
</div>

<p className="mt-1.5 text-sm text-muted">{node.description}</p>

<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
{isOptimistic ? (
<span>Awaiting on-chain confirmation…</span>
) : (
<time dateTime={node.at}>{mounted ? new Date(node.at).toLocaleString() : ""}</time>
)}
{node.txHash && (
<span className="font-mono text-navy-2" title={node.txHash}>
tx&nbsp;{node.txHash.slice(0, 8)}…{node.txHash.slice(-6)}
</span>
)}
</div>
</div>
</li>
);
})}
</ol>
);
}
138 changes: 138 additions & 0 deletions src/components/escrow/EscrowTimelinePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"use client";

import { useEffect, useRef } from "react";
import { FaCircleExclamation, FaArrowsRotate } from "react-icons/fa6";
import Button, { type ButtonVariant } from "@/components/ui/Button";
import Card from "@/components/ui/Card";
import EscrowTimeline from "./EscrowTimeline";
import { useEscrowTimeline } from "./useEscrowTimeline";
import { useHydrated } from "./useHydrated";
import {
ACTION_LABELS,
STATUS_DESCRIPTIONS,
STATUS_LABELS,
TERMINAL_STATUSES,
buildTimelineNodes,
type EscrowAction,
} from "@/lib/escrowTimeline";

interface EscrowTimelinePanelProps {
bookingRef: string;
workerName: string;
/** ISO timestamp the escrow was funded — the timeline's genesis entry. */
fundedAt: string;
}

/** Which button style each action gets: the value-preserving happy paths lead,
refund is a quiet secondary, and a dispute is a deliberate gold accent. */
const ACTION_VARIANT: Record<EscrowAction, ButtonVariant> = {
release: "primary",
resolve: "primary",
cancel: "outline",
dispute: "gold",
};

export default function EscrowTimelinePanel({ bookingRef, workerName, fundedAt }: EscrowTimelinePanelProps) {
const timeline = useEscrowTimeline(bookingRef, fundedAt);
const { state, displayStatus, settledStatus, availableActions, isSubmitting } = timeline;

const mounted = useHydrated();
const announceRef = useRef<HTMLDivElement>(null);
const prevSettledRef = useRef(settledStatus);

// Announce every *confirmed* status change to screen readers — the optimistic
// node already carries its own aria-live, so this is specifically the "it
// actually landed on-chain (or changed underneath us)" signal.
useEffect(() => {
if (prevSettledRef.current !== settledStatus && announceRef.current) {
announceRef.current.textContent = `Escrow status is now ${STATUS_LABELS[settledStatus]}. ${STATUS_DESCRIPTIONS[settledStatus]}`;
}
prevSettledRef.current = settledStatus;
}, [settledStatus]);

const nodes = buildTimelineNodes(state);
const isTerminal = TERMINAL_STATUSES.has(settledStatus) && !isSubmitting;

return (
<Card className="p-6 md:p-8">
<div ref={announceRef} role="status" aria-live="polite" className="sr-only" />

<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-heading text-xl font-semibold">Escrow status</h2>
<p className="mt-1 text-sm text-muted">
Booking <span className="font-mono text-ink">{bookingRef}</span> with {workerName}.
</p>
</div>
<SyncIndicator lastSyncedAt={mounted ? timeline.lastSyncedAt : null} />
</div>

<EscrowTimeline nodes={nodes} />

{state.error && (
<div
role="alert"
className="mt-6 flex flex-col gap-3 rounded-xl border border-err/30 bg-err/8 p-4 sm:flex-row sm:items-center sm:justify-between"
>
<p className="flex items-start gap-2 text-sm text-err">
<FaCircleExclamation className="mt-0.5 shrink-0" aria-hidden />
<span>{state.error}</span>
</p>
<div className="flex shrink-0 gap-2">
<Button size="sm" variant="outline" onClick={timeline.dismissError}>
Dismiss
</Button>
<Button size="sm" onClick={timeline.retry}>
Try again
</Button>
</div>
</div>
)}

{availableActions.length > 0 && (
<div className="mt-7 border-t border-line pt-6">
<p className="mb-3 text-sm font-semibold text-ink">What would you like to do?</p>
<div className="flex flex-wrap gap-3">
{availableActions.map((action) => (
<Button
key={action}
variant={ACTION_VARIANT[action]}
disabled={isSubmitting}
onClick={() => timeline.submit(action)}
>
{ACTION_LABELS[action]}
</Button>
))}
</div>
</div>
)}

{isSubmitting && (
<p className="mt-6 flex items-center gap-2 text-sm text-muted">
<FaArrowsRotate className="animate-spin" aria-hidden />
Submitting to the escrow contract — the timeline will confirm or roll back automatically.
</p>
)}

{isTerminal && (
<p className="mt-7 rounded-xl bg-sand px-4 py-3 text-sm text-muted">
This escrow is settled — <span className="font-semibold text-ink">{STATUS_LABELS[displayStatus]}</span>. No
further action is needed.
</p>
)}
</Card>
);
}

/** Small "live" badge: a pulsing dot plus when the status was last synced. */
function SyncIndicator({ lastSyncedAt }: { lastSyncedAt: string | null }) {
return (
<span className="inline-flex items-center gap-2 rounded-full bg-ok/10 px-3 py-1 text-xs font-semibold text-ok">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-ok opacity-60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-ok" />
</span>
{lastSyncedAt ? `Live · synced ${new Date(lastSyncedAt).toLocaleTimeString()}` : "Live"}
</span>
);
}
Loading
Loading