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
200 changes: 200 additions & 0 deletions p2p-safe-swap/app/trades/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"use client";

import * as React from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, ShieldCheck } from "lucide-react";
import { Button } from "@/frontend/components/ui/Button/Button";
import { WalletBadge } from "@/frontend/components/ui/wallet-badge";
import { Reveal } from "@/frontend/components/motion/reveal";
import {
EscrowStepper,
type EscrowStep,
} from "@/frontend/components/trade/escrow-stepper";

interface TradeStatusPageProps {
params: Promise<{ id: string }>;
}

interface MockTrade {
amount: number;
currency: string;
counterparty: { name: string; address: string };
contractId: string;
}

const MOCK_TRADE: MockTrade = {
amount: 240,
currency: "USDC",
counterparty: {
name: "Alice Johnson",
address: "GA5X4B7ZQR3VWJN6UYQF2E8H9K1D3M2L7P0T4C6VXZWQERTY8UIOP12A",
},
contractId: "CDN3B7ZQR3VWJN6UYQF2E8H9K1D3M2L7P0T4C6V",
};

function buildMockSteps(): EscrowStep[] {
const now = Date.now();
const hoursAgo = (h: number) => new Date(now - h * 3600_000).toISOString();

return [
{
key: "initiated",
label: "Initiated",
description: "Escrow contract created on Stellar",
status: "completed",
timestamp: hoursAgo(26),
},
{
key: "funded",
label: "Funded",
description: "USDC locked in the contract",
status: "current",
timestamp: hoursAgo(3),
},
{
key: "released",
label: "Released",
description: "Awaiting counterparty confirmation",
status: "pending",
},
];
}

type PrimaryAction = {
label: string;
action: "fund" | "release" | "noop";
};

function getPrimaryAction(currentKey: string | undefined): PrimaryAction {
switch (currentKey) {
case "initiated":
return { label: "Fund escrow", action: "fund" };
case "funded":
return { label: "Release funds", action: "release" };
default:
return { label: "View details", action: "noop" };
}
}

function shortAddress(value: string): string {
if (value.length <= 10) return value;
return `${value.slice(0, 4)}…${value.slice(-4)}`;
}

export default function TradeStatusPage({ params }: TradeStatusPageProps) {
const { id } = React.use(params);
const router = useRouter();
const [steps] = React.useState<EscrowStep[]>(() => buildMockSteps());

const currentStep = steps.find((s) => s.status === "current");
const primary = getPrimaryAction(currentStep?.key);
const disputeDisabled = currentStep === undefined;

const handlePrimary = () => {
console.log("[escrow] primary action:", {
tradeId: id,
action: primary.action,
});
};

const handleDispute = () => {
console.log("[escrow] dispute opened", { tradeId: id });
};

return (
<div className="mx-auto flex min-h-full w-full max-w-md flex-col bg-background px-4 py-6">
<header className="mb-6 flex items-center justify-between">
<button
type="button"
aria-label="Back"
onClick={() => router.back()}
className="flex size-10 items-center justify-center rounded-full text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<ArrowLeft className="size-5" />
</button>

<h1 className="text-lg font-semibold text-foreground">
Trade status
</h1>

<span className="size-10" aria-hidden />
</header>

<Reveal className="flex flex-col gap-6">
<section
aria-label="Trade summary"
className="rounded-2xl border border-border bg-card p-5"
>
<div className="flex items-center gap-3">
<WalletBadge address={MOCK_TRADE.counterparty.address} />
<div className="flex flex-1 flex-col">
<span className="text-sm font-semibold text-foreground">
{MOCK_TRADE.counterparty.name}
</span>
<span
className="text-xs text-muted-foreground"
title={MOCK_TRADE.counterparty.address}
>
{shortAddress(MOCK_TRADE.counterparty.address)}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-2xl font-bold tabular-nums text-foreground leading-none">
{MOCK_TRADE.amount.toLocaleString("en-US")}
</span>
<span className="mt-1 text-[0.65rem] font-semibold uppercase tracking-widest text-muted-foreground">
{MOCK_TRADE.currency}
</span>
</div>
</div>

<div className="mt-4 flex items-center gap-2 rounded-xl bg-muted px-3 py-2">
<ShieldCheck className="size-4 shrink-0 text-primary" aria-hidden />
<span className="text-xs font-medium text-muted-foreground">
Contract
</span>
<code
className="ml-auto font-mono text-xs text-foreground"
title={MOCK_TRADE.contractId}
>
{shortAddress(MOCK_TRADE.contractId)}
</code>
</div>
</section>

<section
aria-labelledby="escrow-stepper-heading"
className="rounded-2xl border border-border bg-card p-5"
>
<h2
id="escrow-stepper-heading"
className="mb-4 text-sm font-semibold text-foreground"
>
Escrow progress
</h2>
<EscrowStepper steps={steps} />
</section>

<div className="flex flex-col gap-3">
<Button
variant="primary"
size="lg"
label={primary.label}
onClick={handlePrimary}
aria-label={primary.label}
className="w-full"
/>
<Button
variant="danger"
size="lg"
label="Open dispute"
onClick={handleDispute}
aria-label="Open dispute"
disabled={disputeDisabled}
className="w-full"
/>
</div>
</Reveal>
</div>
);
}
159 changes: 159 additions & 0 deletions p2p-safe-swap/frontend/components/trade/escrow-stepper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"use client";

import * as React from "react";
import { AlertTriangle, Check, Circle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";

export type EscrowStepStatus = "completed" | "current" | "pending" | "disputed";

export interface EscrowStep {
key: string;
label: string;
description?: string;
status: EscrowStepStatus;
/** ISO 8601 timestamp. Formatted with `locale` at render time. */
timestamp?: string;
}

export interface EscrowStepperProps {
steps: EscrowStep[];
className?: string;
locale?: string;
"aria-label"?: string;
}

const labelClass: Record<EscrowStepStatus, string> = {
completed: "text-foreground",
current: "text-primary",
pending: "text-muted-foreground",
disputed: "text-destructive",
};

function formatTimestamp(iso: string, locale: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}

function StepIcon({ status }: { status: EscrowStepStatus }) {
const base =
"flex size-8 shrink-0 items-center justify-center rounded-full transition-colors";

if (status === "completed") {
return (
<span className={cn(base, "bg-primary text-primary-foreground")}>
<Check className="size-4" strokeWidth={3} aria-hidden />
</span>
);
}

if (status === "current") {
return (
<span
className={cn(base, "bg-primary/15 text-primary ring-2 ring-primary/40")}
>
<Loader2 className="size-4 animate-spin" aria-hidden />
</span>
);
}

if (status === "disputed") {
return (
<span
className={cn(
base,
"bg-destructive/15 text-destructive ring-2 ring-destructive/40"
)}
>
<AlertTriangle className="size-4" aria-hidden />
</span>
);
}

return (
<span
className={cn(base, "border border-border bg-card text-muted-foreground")}
>
<Circle className="size-2.5 fill-current" aria-hidden />
</span>
);
}

const statusSrLabel: Record<EscrowStepStatus, string> = {
completed: "Completed",
current: "In progress",
pending: "Pending",
disputed: "Disputed",
};

export function EscrowStepper({
steps,
className,
locale = "en-US",
"aria-label": ariaLabel = "Escrow stages",
}: EscrowStepperProps) {
return (
<ol className={cn("flex flex-col", className)} aria-label={ariaLabel}>
{steps.map((step, index) => {
const isLast = index === steps.length - 1;
const railCompleted = step.status === "completed";

return (
<li
key={step.key}
aria-current={step.status === "current" ? "step" : undefined}
className={cn("relative flex gap-4", !isLast && "pb-6")}
>
<div className="flex flex-col items-center">
<StepIcon status={step.status} />
{!isLast && (
<span
aria-hidden
className={cn(
"mt-1 w-px flex-1",
railCompleted ? "bg-primary" : "bg-border"
)}
/>
)}
</div>

<div className="flex flex-1 flex-col gap-1 pb-1">
<div className="flex items-baseline justify-between gap-3">
<span
className={cn(
"text-sm font-semibold",
labelClass[step.status]
)}
>
{step.label}
<span className="sr-only">
{" β€” "}
{statusSrLabel[step.status]}
</span>
</span>
{step.timestamp && (
<time
dateTime={step.timestamp}
className="text-xs text-muted-foreground tabular-nums"
>
{formatTimestamp(step.timestamp, locale)}
</time>
)}
</div>
{step.description && (
<p className="text-sm text-muted-foreground">
{step.description}
</p>
)}
</div>
</li>
);
})}
</ol>
);
}