diff --git a/.gitignore b/.gitignore
index db9395c..0e239ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -149,3 +149,6 @@ vite.config.ts.timestamp-*
# Pull request description docs (local only, not for version control)
pr-*.md
PULL_REQUEST.md
+
+# Storybook build output
+storybook-static/
diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
new file mode 100644
index 0000000..09ccd81
--- /dev/null
+++ b/frontend/.storybook/main.ts
@@ -0,0 +1,22 @@
+import type { StorybookConfig } from "@storybook/nextjs";
+
+const config: StorybookConfig = {
+ stories: [
+ "../components/ui/**/*.stories.@(js|jsx|mjs|ts|tsx)",
+ "../components/**/*.stories.@(js|jsx|mjs|ts|tsx)",
+ ],
+ addons: [
+ "@storybook/addon-links",
+ "@storybook/addon-essentials",
+ "@storybook/addon-interactions",
+ ],
+ framework: {
+ name: "@storybook/nextjs",
+ options: {},
+ },
+ docs: {
+ autodocs: "tag",
+ },
+};
+
+export default config;
diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts
new file mode 100644
index 0000000..8093bf8
--- /dev/null
+++ b/frontend/.storybook/preview.ts
@@ -0,0 +1,22 @@
+import type { Preview } from "@storybook/react";
+import "../app/globals.css";
+
+const preview: Preview = {
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ backgrounds: {
+ default: "light",
+ values: [
+ { name: "light", value: "#ffffff" },
+ { name: "dark", value: "#0f172a" },
+ ],
+ },
+ },
+};
+
+export default preview;
diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx
index ee1fddb..7803c6e 100644
--- a/frontend/app/admin/page.tsx
+++ b/frontend/app/admin/page.tsx
@@ -17,8 +17,13 @@
*/
import { useCallback, useEffect, useState } from "react";
-
import { useAuth } from "../hooks/useAuth";
+import { Button } from "../../components/ui/Button";
+import { Badge } from "../../components/ui/Badge";
+import { Card } from "../../components/ui/Card";
+import { Spinner } from "../../components/ui/Spinner";
+import { Modal } from "../../components/ui/Modal";
+import { StellarExplorerLink } from "../../components/StellarExplorerLink";
interface Metrics {
totalUsers: number;
@@ -169,7 +174,11 @@ export default function AdminDashboardPage(): JSX.Element {
}
if (isLoading) {
- return Loading…;
+ return (
+
+
+
+ );
}
if (!user || user.role !== "admin") {
@@ -213,13 +222,13 @@ export default function AdminDashboardPage(): JSX.Element {
}),
},
].map((stat) => (
-
-
{stat.label}
+
{stat.label}
{stat.value}
-
+
))}
)}
@@ -256,17 +265,17 @@ export default function AdminDashboardPage(): JSX.Element {
{new Date(trade.created_at).toLocaleDateString()}
-
+
|
))}
@@ -312,7 +321,15 @@ export default function AdminDashboardPage(): JSX.Element {
{lookup.user.fiat_balance ?? "0.00"}
Stellar key
- {lookup.user.stellar_public_key ?? "—"}
+ {lookup.user.stellar_public_key ? (
+
+ ) : (
+ "—"
+ )}
diff --git a/frontend/app/api/trades/[id]/dispute/route.ts b/frontend/app/api/trades/[id]/dispute/route.ts
new file mode 100644
index 0000000..7aad773
--- /dev/null
+++ b/frontend/app/api/trades/[id]/dispute/route.ts
@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from "next/server";
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ const { id } = params;
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL || process.env.API_URL || "http://localhost:3001";
+
+ try {
+ const body = await req.json().catch(() => ({}));
+ const authHeader = req.headers.get("authorization");
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ if (authHeader) {
+ headers["authorization"] = authHeader;
+ }
+
+ const backendRes = await fetch(`${apiUrl}/api/v1/trades/${encodeURIComponent(id)}/dispute`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ });
+
+ const data = await backendRes.json().catch(() => ({}));
+ return NextResponse.json(data, { status: backendRes.status });
+ } catch (error) {
+ return NextResponse.json(
+ { error: "Internal server error connecting to trade dispute service" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/frontend/app/components/StellarExplorerLink.tsx b/frontend/app/components/StellarExplorerLink.tsx
new file mode 100644
index 0000000..cb78c30
--- /dev/null
+++ b/frontend/app/components/StellarExplorerLink.tsx
@@ -0,0 +1,2 @@
+export * from "../../components/StellarExplorerLink";
+export { default } from "../../components/StellarExplorerLink";
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index b601bce..bbfc63a 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -1,5 +1,6 @@
import type { TradeOffer } from "../../server/src/types/trade";
import ThemeToggle from "../components/ThemeToggle";
+import { Card } from "../components/ui/Card";
interface TradesResponse {
data: TradeOffer[];
@@ -47,7 +48,7 @@ function AssetBadge({ assetType }: { assetType: string }) {
function TradeCard({ trade }: { trade: TradeOffer }) {
const sellerAlias = `@seller_${trade.seller_id.slice(-8)}`;
return (
-
+
Seller
@@ -85,7 +86,7 @@ function TradeCard({ trade }: { trade: TradeOffer }) {
>
View & Buy
-
+
);
}
diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx
index 0261f37..a5408db 100644
--- a/frontend/app/profile/page.tsx
+++ b/frontend/app/profile/page.tsx
@@ -3,6 +3,11 @@
import { useState, useEffect, useCallback } from "react";
import { getToken, getUser, isAuthenticated } from "../lib/auth";
import type { TradeOffer, TradeStatus } from "../../../server/src/types/trade";
+import { Badge } from "../../components/ui/Badge";
+import { Button } from "../../components/ui/Button";
+import { Card } from "../../components/ui/Card";
+import { Spinner } from "../../components/ui/Spinner";
+import { StellarExplorerLink } from "../../components/StellarExplorerLink";
// ---------------------------------------------------------------------------
// Types
@@ -81,42 +86,9 @@ function formatDateTime(iso: string): string {
// Sub-components
// ---------------------------------------------------------------------------
-function Spinner({ label = "Loading…" }: { label?: string }) {
- return (
-
- );
-}
-
-/** Status badge — matches design system used in TradeDetailClient */
function StatusBadge({ status }: { status: TradeStatus }) {
- const styles: Record
= {
- Active: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
- Locked: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
- Completed: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
- Cancelled: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400",
- };
-
- // Display "Disputed" in the UI for Locked trades to match filter label
- const label = status === "Locked" ? "Disputed" : status;
-
- return (
-
- {label}
-
- );
+ const variant = status === "Active" ? "Open" : status;
+ return ;
}
/** A single stat card in the profile summary */
@@ -130,11 +102,11 @@ function StatCard({
icon: string;
}) {
return (
-
+
{icon}
{value}
{label}
-
+
);
}
@@ -258,6 +230,15 @@ function TradeRow({
{counterparty}
+ {/* Explorer */}
+
+ {trade.escrow_tx_hash ? (
+
+ ) : (
+ —
+ )}
+ |
+
{/* Status */}
@@ -308,6 +289,13 @@ function TradeMobileCard({
Counterparty
{counterparty}
+
+ {trade.escrow_tx_hash && (
+
+ Explorer
+
+
+ )}
);
}
@@ -497,14 +485,17 @@ export default function ProfilePage() {
{/* Stellar public key */}
{profile.stellarPublicKey && (
-
+
Stellar Public Key
-
- {profile.stellarPublicKey}
-
-
+
+
)}
) : null}
@@ -603,6 +594,9 @@ export default function ProfilePage() {
|
Counterparty
|
+
+ Explorer
+ |
Status
|
diff --git a/frontend/app/trades/[id]/TradeDetailClient.tsx b/frontend/app/trades/[id]/TradeDetailClient.tsx
index 15855d2..b1e0906 100644
--- a/frontend/app/trades/[id]/TradeDetailClient.tsx
+++ b/frontend/app/trades/[id]/TradeDetailClient.tsx
@@ -3,6 +3,13 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { TradeOffer } from "../../../../server/src/types/trade";
import { getToken, getUser, isAuthenticated } from "../../lib/auth";
+import { Button } from "../../../components/ui/Button";
+import { Badge } from "../../../components/ui/Badge";
+import { Spinner } from "../../../components/ui/Spinner";
+import { Card } from "../../../components/ui/Card";
+import { Toast } from "../../../components/ui/Toast";
+import { StellarExplorerLink } from "../../../components/StellarExplorerLink";
+import { DisputeModal } from "./dispute/DisputeModal";
// ---------------------------------------------------------------------------
// Helpers
@@ -30,45 +37,6 @@ function AssetBadge({ assetType }: { assetType: string }) {
);
}
-function StatusBadge({ status }: { status: TradeOffer["status"] }) {
- const styles: Record = {
- Active: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
- Locked: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
- Completed: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
- Cancelled: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400",
- };
- return (
-
-
- {status}
-
- );
-}
-
-function Spinner({ label = "Loading…" }: { label?: string }) {
- return (
-
- );
-}
-
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
@@ -157,11 +125,11 @@ function ConfirmationPanel({ trade, txHash }: { trade: TradeOffer; txHash: strin
{formatAssetType(trade.asset_type)}
₦{trade.amount.toLocaleString()}
-
+
{txHash && (
- {txHash}
+
)}
@@ -192,6 +160,7 @@ interface Props {
export default function TradeDetailClient({ trade }: Props) {
const countdown = useCountdown(trade.expires_at);
+ const [status, setStatus] = useState
(trade.status);
const [authed, setAuthed] = useState(false);
const [currentUserId, setCurrentUserId] = useState(null);
const [buying, setBuying] = useState(false);
@@ -199,7 +168,15 @@ export default function TradeDetailClient({ trade }: Props) {
const [txHash, setTxHash] = useState(null);
const [confirmed, setConfirmed] = useState(false);
- const apiUrl = process.env["NEXT_PUBLIC_API_URL"] ?? "http://localhost:3001";
+ // Dispute state
+ const [isDisputeOpen, setIsDisputeOpen] = useState(false);
+ const [disputeFiled, setDisputeFiled] = useState(trade.status === "Disputed");
+ const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
+
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
+ const escrowContractAddress =
+ process.env.NEXT_PUBLIC_ESCROW_CONTRACT_ADDRESS ||
+ "CCBJ235OCBFZXBFSUUUT4PMG7RRCAXZXMUEB2L7CTTQ5NRSNO4P2SLNP";
useEffect(() => {
setAuthed(isAuthenticated());
@@ -207,7 +184,10 @@ export default function TradeDetailClient({ trade }: Props) {
}, []);
const isSeller = !!currentUserId && currentUserId === trade.seller_id;
- const isActive = trade.status === "Active";
+ const isBuyer = !!currentUserId && currentUserId === trade.buyer_id;
+ const isParticipant = isSeller || isBuyer;
+ const isActive = status === "Active";
+ const isLocked = status === "Locked";
const canBuy = authed && isActive && !countdown.expired && !isSeller;
const sellerAlias = `@seller_${trade.seller_id.slice(-8)}`;
@@ -248,6 +228,7 @@ export default function TradeDetailClient({ trade }: Props) {
return;
}
+ setStatus("Locked");
setTxHash(data.data?.escrow_tx_hash ?? "");
setConfirmed(true);
} catch {
@@ -257,12 +238,39 @@ export default function TradeDetailClient({ trade }: Props) {
}
}
+ function handleDisputeSuccess() {
+ setStatus("Disputed");
+ setDisputeFiled(true);
+ setToast({
+ type: "success",
+ message: "Dispute submitted successfully. An administrator will review within 24 hours.",
+ });
+ }
+
+ function handleDisputeError(msg: string) {
+ setToast({
+ type: "error",
+ message: msg,
+ });
+ }
+
if (confirmed) {
- return ;
+ return ;
}
return (
+ {/* Toast Notification */}
+ {toast && (
+
+ setToast(null)}
+ />
+
+ )}
+
{/* Page heading */}
@@ -280,14 +288,11 @@ export default function TradeDetailClient({ trade }: Props) {
{/* Summary card */}
-
+
{/* Coloured header strip */}
{/* Detail rows */}
@@ -333,8 +338,26 @@ export default function TradeDetailClient({ trade }: Props) {
year: "numeric",
})}
+
+ {/* Escrow Contract Deep-Link (Issue #66) */}
+
+
+
+
+ {/* Escrow Tx Deep-Link if present */}
+ {trade.escrow_tx_hash && (
+
+
+
+ )}
-
+
{/* How it works */}
@@ -360,30 +383,23 @@ export default function TradeDetailClient({ trade }: Props) {
{/* CTA area */}
+ {/* Buy button */}
{authed && isActive && !isSeller && (
-
+ {countdown.expired ? "Offer expired" : "Buy Now"}
+
)}
{!authed && isActive && !countdown.expired && (
@@ -396,7 +412,56 @@ export default function TradeDetailClient({ trade }: Props) {
)}
- {isSeller && (
+ {/* Dispute Section for Buyer and Seller (Issue #61) */}
+ {isLocked && isParticipant && (
+
+
+
+
+ Trade in progress (Escrow locked)
+
+
+ If the transaction cannot be completed, you may raise a dispute.
+
+
+
+ {disputeFiled ? (
+
+ Dispute Filed
+
+ ) : (
+
+ )}
+
+
+ {disputeFiled && (
+
+ An AirFlex administrator will review this dispute within 24 hours.
+
+ )}
+
+ )}
+
+ {/* Status messages for other states */}
+ {status === "Disputed" && !isLocked && (
+
+
+ Dispute Filed
+
+
+ This trade is under active review. An administrator will resolve it within 24 hours.
+
+
+ )}
+
+ {isSeller && isActive && (
)}
- {!isActive && (
+ {!isActive && !isLocked && status !== "Disputed" && (
- This offer is no longer available ({trade.status.toLowerCase()}).
+ This offer is no longer available ({status.toLowerCase()}).
)}
@@ -423,13 +488,23 @@ export default function TradeDetailClient({ trade }: Props) {
)}
-
(window.location.href = "/")}
+ className="mt-2"
>
← Back to marketplace
-
+
+
+ {/* Accessible Dispute Modal Dialog */}
+
setIsDisputeOpen(false)}
+ tradeId={trade.id}
+ onDisputeSuccess={handleDisputeSuccess}
+ onError={handleDisputeError}
+ />
);
}
diff --git a/frontend/app/trades/[id]/dispute/DisputeModal.tsx b/frontend/app/trades/[id]/dispute/DisputeModal.tsx
new file mode 100644
index 0000000..7b36fca
--- /dev/null
+++ b/frontend/app/trades/[id]/dispute/DisputeModal.tsx
@@ -0,0 +1,229 @@
+"use client";
+
+import React, { useState } from "react";
+import { getToken } from "../../../lib/auth";
+import { Modal } from "../../../../components/ui/Modal";
+import { Button } from "../../../../components/ui/Button";
+
+export interface DisputeModalProps {
+ /**
+ * Whether the dispute modal is open.
+ */
+ isOpen: boolean;
+
+ /**
+ * Callback fired when closing the modal without submitting.
+ */
+ onClose: () => void;
+
+ /**
+ * Unique ID of the trade being disputed.
+ */
+ tradeId: string;
+
+ /**
+ * Callback fired when the dispute is successfully submitted to the server.
+ */
+ onDisputeSuccess: () => void;
+
+ /**
+ * Optional callback to emit error messages for external toast notifications.
+ */
+ onError?: (errorMessage: string) => void;
+}
+
+const MAX_REASON_CHARS = 500;
+
+export function DisputeModal({
+ isOpen,
+ onClose,
+ tradeId,
+ onDisputeSuccess,
+ onError,
+}: DisputeModalProps) {
+ const [reason, setReason] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [validationError, setValidationError] = useState(null);
+
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
+
+ const handleReasonChange = (e: React.ChangeEvent) => {
+ const val = e.target.value;
+ if (val.length <= MAX_REASON_CHARS) {
+ setReason(val);
+ if (validationError) setValidationError(null);
+ }
+ };
+
+ const handleClose = () => {
+ if (isSubmitting) return;
+ setReason("");
+ setValidationError(null);
+ onClose();
+ };
+
+ const handleSubmit = async (e?: React.FormEvent) => {
+ if (e) e.preventDefault();
+
+ const trimmed = reason.trim();
+ if (!trimmed) {
+ setValidationError("Please describe why you are raising a dispute.");
+ return;
+ }
+
+ if (trimmed.length > MAX_REASON_CHARS) {
+ setValidationError(`Dispute reason cannot exceed ${MAX_REASON_CHARS} characters.`);
+ return;
+ }
+
+ setIsSubmitting(true);
+ setValidationError(null);
+
+ const token = getToken();
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+
+ try {
+ // Primary: POST /api/trades/:id/dispute as specified in criteria.
+ // Fallback: `${apiUrl}/api/v1/trades/:id/dispute` if running against standalone backend.
+ let res: Response;
+ try {
+ res = await fetch(`/api/trades/${encodeURIComponent(tradeId)}/dispute`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ reason: trimmed }),
+ });
+ if (res.status === 404) {
+ // If Next.js internal route isn't hit, try the backend API url
+ res = await fetch(`${apiUrl}/api/v1/trades/${encodeURIComponent(tradeId)}/dispute`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ reason: trimmed }),
+ });
+ }
+ } catch {
+ res = await fetch(`${apiUrl}/api/v1/trades/${encodeURIComponent(tradeId)}/dispute`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ reason: trimmed }),
+ });
+ }
+
+ const data = await res.json().catch(() => ({}));
+
+ if (!res.ok) {
+ const errorMsg =
+ data.error ||
+ (res.status === 409
+ ? "This trade has already been disputed."
+ : "Failed to submit dispute. Please try again.");
+ setValidationError(errorMsg);
+ onError?.(errorMsg);
+ return;
+ }
+
+ setReason("");
+ onDisputeSuccess();
+ onClose();
+ } catch {
+ const networkMsg = "Network error. Please check your connection and try again.";
+ setValidationError(networkMsg);
+ onError?.(networkMsg);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ );
+}
+
+export default DisputeModal;
diff --git a/frontend/app/trades/[id]/dispute/index.ts b/frontend/app/trades/[id]/dispute/index.ts
new file mode 100644
index 0000000..2150863
--- /dev/null
+++ b/frontend/app/trades/[id]/dispute/index.ts
@@ -0,0 +1,2 @@
+export * from "./DisputeModal";
+export { default } from "./DisputeModal";
diff --git a/frontend/app/wallet/page.tsx b/frontend/app/wallet/page.tsx
index 3581fee..51cc996 100644
--- a/frontend/app/wallet/page.tsx
+++ b/frontend/app/wallet/page.tsx
@@ -4,6 +4,11 @@ import { useState, useEffect } from "react";
import { getToken, isAuthenticated } from "../lib/auth";
import DepositModal from "./DepositModal";
import WithdrawModal from "./WithdrawModal";
+import { Button } from "../../components/ui/Button";
+import { Badge } from "../../components/ui/Badge";
+import { Card, CardHeader, CardTitle, CardContent } from "../../components/ui/Card";
+import { Spinner } from "../../components/ui/Spinner";
+import { StellarExplorerLink } from "../../components/StellarExplorerLink";
// ---------------------------------------------------------------------------
// Types
@@ -24,6 +29,15 @@ interface WalletResponse {
error?: string;
}
+interface WalletTransaction {
+ id: string;
+ asset_type: string;
+ amount: number;
+ status: "Active" | "Locked" | "Completed" | "Cancelled" | "Disputed";
+ escrow_tx_hash: string | null;
+ created_at: string;
+}
+
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -33,6 +47,7 @@ export default function WalletPage() {
const [authChecked, setAuthChecked] = useState(false);
const [wallet, setWallet] = useState(null);
+ const [transactions, setTransactions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -47,7 +62,7 @@ export default function WalletPage() {
setAuthChecked(true);
}, []);
- // Fetch wallet data
+ // Fetch wallet data & transactions
useEffect(() => {
if (!authChecked) return;
@@ -57,11 +72,17 @@ export default function WalletPage() {
setLoading(true);
setError(null);
- fetch(`${apiUrl}/api/v1/wallet`, {
- headers: { Authorization: `Bearer ${token}` },
- })
- .then((r) => r.json() as Promise)
- .then((data) => {
+ Promise.all([
+ fetch(`${apiUrl}/api/v1/wallet`, {
+ headers: { Authorization: `Bearer ${token}` },
+ }).then((r) => r.json() as Promise),
+ fetch(`${apiUrl}/api/v1/profile/trades?limit=10`, {
+ headers: { Authorization: `Bearer ${token}` },
+ })
+ .then((r) => (r.ok ? r.json() : { data: [] }))
+ .catch(() => ({ data: [] })),
+ ])
+ .then(([data, tradesData]) => {
if (data.error || !data.publicKey) {
setError(data.error ?? "Failed to load wallet.");
} else {
@@ -71,6 +92,9 @@ export default function WalletPage() {
asset: data.asset ?? "XLM",
network: data.network ?? "testnet",
});
+ if (tradesData && Array.isArray(tradesData.data)) {
+ setTransactions(tradesData.data);
+ }
}
})
.catch(() => setError("Network error. Check your connection."))
@@ -104,15 +128,7 @@ export default function WalletPage() {
if (!authChecked || loading) {
return (
);
}
@@ -141,7 +157,7 @@ export default function WalletPage() {
{/* Wallet card */}
{wallet && (
-
+
Available Balance
@@ -153,12 +169,17 @@ export default function WalletPage() {
- Stellar Public Key
-
-
- {wallet.publicKey}
+ Stellar Public Key (On-chain Account)
+
+
+
{wallet.network}
@@ -169,26 +190,85 @@ export default function WalletPage() {
-
-
-
+
)}
- {/* Deposit Modal (Issue #25) — reuses the same balance refresh the
- withdraw flow uses, so a credited deposit shows up without a reload. */}
+ {/* Transaction Rows with Stellar Explorer Deep-Links (Issue #66) */}
+
+
+ Recent Transactions
+
+
+ {transactions.length === 0 ? (
+
+ No transactions found on this account yet.
+
+ ) : (
+
+
+
+
+ | Date |
+ Asset |
+ Amount |
+ Status |
+ Stellar Transaction |
+
+
+
+ {transactions.map((tx) => (
+
+ |
+ {new Date(tx.created_at).toLocaleDateString("en-NG", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ })}
+ |
+
+ {tx.asset_type}
+ |
+
+ ₦{tx.amount.toLocaleString()}
+ |
+
+
+ |
+
+ {tx.escrow_tx_hash ? (
+
+ ) : (
+ —
+ )}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ {/* Deposit Modal (Issue #25) */}
setIsDepositOpen(false)}
diff --git a/frontend/components/StellarExplorerLink.test.tsx b/frontend/components/StellarExplorerLink.test.tsx
new file mode 100644
index 0000000..981153c
--- /dev/null
+++ b/frontend/components/StellarExplorerLink.test.tsx
@@ -0,0 +1,126 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import {
+ StellarExplorerLink,
+ getStellarExpertUrl,
+ formatExplorerValue,
+} from "./StellarExplorerLink";
+
+describe("formatExplorerValue", () => {
+ it("leaves short values intact", () => {
+ expect(formatExplorerValue("1234567890")).toBe("1234567890");
+ });
+
+ it("truncates values longer than 12 characters", () => {
+ expect(formatExplorerValue("CCBJ235OCBFZXBFSUUUT4PMG7RRCAXZXMUEB2L7CTTQ5NRSNO4P2SLNP")).toBe(
+ "CCBJ23…P2SLNP"
+ );
+ });
+});
+
+describe("getStellarExpertUrl", () => {
+ const prevNetwork = process.env.NEXT_PUBLIC_STELLAR_NETWORK;
+
+ afterEach(() => {
+ process.env.NEXT_PUBLIC_STELLAR_NETWORK = prevNetwork;
+ });
+
+ it("builds testnet transaction URL by default", () => {
+ delete process.env.NEXT_PUBLIC_STELLAR_NETWORK;
+ expect(getStellarExpertUrl("transaction", "abc123tx")).toBe(
+ "https://stellar.expert/explorer/testnet/tx/abc123tx"
+ );
+ });
+
+ it("builds testnet account URL", () => {
+ process.env.NEXT_PUBLIC_STELLAR_NETWORK = "testnet";
+ expect(getStellarExpertUrl("account", "GABCDEF")).toBe(
+ "https://stellar.expert/explorer/testnet/account/GABCDEF"
+ );
+ });
+
+ it("builds testnet contract URL", () => {
+ process.env.NEXT_PUBLIC_STELLAR_NETWORK = "testnet";
+ expect(getStellarExpertUrl("contract", "CCBJ123")).toBe(
+ "https://stellar.expert/explorer/testnet/contract/CCBJ123"
+ );
+ });
+
+ it("builds mainnet URL when NEXT_PUBLIC_STELLAR_NETWORK is mainnet", () => {
+ process.env.NEXT_PUBLIC_STELLAR_NETWORK = "mainnet";
+ expect(getStellarExpertUrl("transaction", "tx999")).toBe(
+ "https://stellar.expert/explorer/public/tx/tx999"
+ );
+ expect(getStellarExpertUrl("contract", "contract777")).toBe(
+ "https://stellar.expert/explorer/public/contract/contract777"
+ );
+ expect(getStellarExpertUrl("account", "acc111")).toBe(
+ "https://stellar.expert/explorer/public/account/acc111"
+ );
+ });
+
+ it("builds public URL when NEXT_PUBLIC_STELLAR_NETWORK is public", () => {
+ process.env.NEXT_PUBLIC_STELLAR_NETWORK = "public";
+ expect(getStellarExpertUrl("contract", "CCBJ123")).toBe(
+ "https://stellar.expert/explorer/public/contract/CCBJ123"
+ );
+ });
+});
+
+describe("StellarExplorerLink", () => {
+ const prevNetwork = process.env.NEXT_PUBLIC_STELLAR_NETWORK;
+
+ beforeEach(() => {
+ process.env.NEXT_PUBLIC_STELLAR_NETWORK = "testnet";
+ });
+
+ afterEach(() => {
+ process.env.NEXT_PUBLIC_STELLAR_NETWORK = prevNetwork;
+ });
+
+ it("renders null gracefully when value is empty string", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders null gracefully when value is undefined", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders null gracefully when value is whitespace only", () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders anchor tag with correct URL, target, rel, and external icon", () => {
+ const contract = "CCBJ235OCBFZXBFSUUUT4PMG7RRCAXZXMUEB2L7CTTQ5NRSNO4P2SLNP";
+ render();
+
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute(
+ "href",
+ `https://stellar.expert/explorer/testnet/contract/${contract}`
+ );
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+
+ expect(screen.getByTestId("external-link-icon")).toBeInTheDocument();
+ expect(link).toHaveTextContent("CCBJ23…P2SLNP");
+ });
+
+ it("renders custom children when provided", () => {
+ render(
+
+ View Contract Transaction
+
+ );
+
+ const link = screen.getByRole("link");
+ expect(link).toHaveTextContent("View Contract Transaction");
+ expect(link).toHaveAttribute(
+ "href",
+ "https://stellar.expert/explorer/testnet/tx/hash123"
+ );
+ });
+});
diff --git a/frontend/components/StellarExplorerLink.tsx b/frontend/components/StellarExplorerLink.tsx
new file mode 100644
index 0000000..1c3288b
--- /dev/null
+++ b/frontend/components/StellarExplorerLink.tsx
@@ -0,0 +1,85 @@
+import React from "react";
+
+export type StellarExplorerType = "transaction" | "account" | "contract";
+
+export interface StellarExplorerLinkProps {
+ /** The kind of Stellar entity to explore */
+ type: StellarExplorerType;
+ /** The transaction hash, account public key, or contract address */
+ value?: string | null;
+ /** Optional custom link text or content. If omitted, value is displayed */
+ children?: React.ReactNode;
+ /** Additional CSS class names */
+ className?: string;
+ /** Whether to truncate long addresses/hashes when displaying as default text. Defaults to true */
+ truncate?: boolean;
+}
+
+/**
+ * Truncates a long Stellar address or transaction hash for readable inline display.
+ */
+export function formatExplorerValue(val: string): string {
+ if (val.length <= 12) return val;
+ return `${val.slice(0, 6)}…${val.slice(-6)}`;
+}
+
+/**
+ * Constructs the canonical Stellar Expert explorer URL for a given type, value, and network.
+ */
+export function getStellarExpertUrl(type: StellarExplorerType, value: string): string {
+ const network = (process.env.NEXT_PUBLIC_STELLAR_NETWORK || "testnet").toLowerCase();
+ const networkSegment = network === "mainnet" || network === "public" ? "public" : "testnet";
+ const typeSegment = type === "transaction" ? "tx" : type;
+ return `https://stellar.expert/explorer/${networkSegment}/${typeSegment}/${encodeURIComponent(value.trim())}`;
+}
+
+/**
+ * Reusable deep-link component that links Stellar contracts, accounts, and transactions
+ * to the Stellar Expert block explorer.
+ */
+export function StellarExplorerLink({
+ type,
+ value,
+ children,
+ className = "",
+ truncate = true,
+}: StellarExplorerLinkProps) {
+ // Acceptance criteria: The component renders null gracefully when value is empty or undefined.
+ if (!value || typeof value !== "string" || !value.trim()) {
+ return null;
+ }
+
+ const trimmed = value.trim();
+ const url = getStellarExpertUrl(type, trimmed);
+ const displayText = children ?? (truncate ? formatExplorerValue(trimmed) : trimmed);
+
+ return (
+
+ {displayText}
+
+
+ );
+}
+
+export default StellarExplorerLink;
diff --git a/frontend/components/ui/Badge.stories.tsx b/frontend/components/ui/Badge.stories.tsx
new file mode 100644
index 0000000..2a8d265
--- /dev/null
+++ b/frontend/components/ui/Badge.stories.tsx
@@ -0,0 +1,68 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Badge } from "./Badge";
+
+const meta: Meta = {
+ title: "UI/Badge",
+ component: Badge,
+ tags: ["autodocs"],
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: ["Open", "Locked", "Completed", "Cancelled", "Disputed"],
+ description: "Trade lifecycle status variant",
+ },
+ showDot: {
+ control: "boolean",
+ description: "Toggles the status indicator dot",
+ },
+ children: {
+ control: "text",
+ description: "Custom label inside the badge",
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Open: Story = {
+ args: {
+ variant: "Open",
+ },
+};
+
+export const Locked: Story = {
+ args: {
+ variant: "Locked",
+ },
+};
+
+export const Completed: Story = {
+ args: {
+ variant: "Completed",
+ },
+};
+
+export const Cancelled: Story = {
+ args: {
+ variant: "Cancelled",
+ },
+};
+
+export const Disputed: Story = {
+ args: {
+ variant: "Disputed",
+ },
+};
+
+export const AllStatuses: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+};
diff --git a/frontend/components/ui/Badge.tsx b/frontend/components/ui/Badge.tsx
new file mode 100644
index 0000000..6fde0a7
--- /dev/null
+++ b/frontend/components/ui/Badge.tsx
@@ -0,0 +1,98 @@
+import React from "react";
+
+export type BadgeStatusVariant =
+ | "Open"
+ | "Active"
+ | "Locked"
+ | "Completed"
+ | "Cancelled"
+ | "Disputed";
+
+export interface BadgeProps extends React.HTMLAttributes {
+ /**
+ * The status variant of the badge.
+ * Required status variants: Open, Locked, Completed, Cancelled, Disputed.
+ * "Active" is accepted as an alias for "Open".
+ * @default "Open"
+ */
+ variant?: BadgeStatusVariant;
+
+ /**
+ * Optional boolean to show a status indicator dot.
+ * @default true
+ */
+ showDot?: boolean;
+
+ /**
+ * Optional custom text or children. If omitted, the variant name is used.
+ */
+ children?: React.ReactNode;
+}
+
+const statusConfig: Record<
+ BadgeStatusVariant,
+ { container: string; dot: string; defaultLabel: string }
+> = {
+ Open: {
+ container: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/60",
+ dot: "bg-green-500",
+ defaultLabel: "Open",
+ },
+ Active: {
+ container: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/60",
+ dot: "bg-green-500",
+ defaultLabel: "Active",
+ },
+ Locked: {
+ container: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 border-amber-200 dark:border-amber-800/60",
+ dot: "bg-amber-500",
+ defaultLabel: "Locked",
+ },
+ Completed: {
+ container: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 border-blue-200 dark:border-blue-800/60",
+ dot: "bg-blue-500",
+ defaultLabel: "Completed",
+ },
+ Cancelled: {
+ container: "bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-400 border-gray-200 dark:border-gray-600/60",
+ dot: "bg-gray-400",
+ defaultLabel: "Cancelled",
+ },
+ Disputed: {
+ container: "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300 border-rose-200 dark:border-rose-800/60",
+ dot: "bg-rose-500",
+ defaultLabel: "Disputed",
+ },
+};
+
+/**
+ * Airflex primitive Badge for trade lifecycle states and category indicators,
+ * supporting Open, Locked, Completed, Cancelled, and Disputed status variants with dark mode.
+ */
+export function Badge({
+ variant = "Open",
+ showDot = true,
+ className = "",
+ children,
+ ...props
+}: BadgeProps) {
+ const config = statusConfig[variant] ?? statusConfig.Open;
+ const content = children ?? config.defaultLabel;
+
+ return (
+
+ {showDot && (
+
+ )}
+ {content}
+
+ );
+}
+
+export default Badge;
diff --git a/frontend/components/ui/Button.stories.tsx b/frontend/components/ui/Button.stories.tsx
new file mode 100644
index 0000000..6189ee9
--- /dev/null
+++ b/frontend/components/ui/Button.stories.tsx
@@ -0,0 +1,80 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Button } from "./Button";
+
+const meta: Meta = {
+ title: "UI/Button",
+ component: Button,
+ tags: ["autodocs"],
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: ["primary", "secondary", "danger", "ghost"],
+ description: "Visual style variant of the button",
+ },
+ size: {
+ control: { type: "select" },
+ options: ["sm", "md", "lg"],
+ description: "Size of the button",
+ },
+ isLoading: {
+ control: "boolean",
+ description: "Shows spinner and disables interactions",
+ },
+ loadingText: {
+ control: "text",
+ description: "Accessible text displayed during loading",
+ },
+ disabled: {
+ control: "boolean",
+ description: "Disables button interactions",
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Primary: Story = {
+ args: {
+ variant: "primary",
+ children: "Primary Button",
+ },
+};
+
+export const Secondary: Story = {
+ args: {
+ variant: "secondary",
+ children: "Secondary Button",
+ },
+};
+
+export const Danger: Story = {
+ args: {
+ variant: "danger",
+ children: "Danger Button",
+ },
+};
+
+export const Ghost: Story = {
+ args: {
+ variant: "ghost",
+ children: "Ghost Button",
+ },
+};
+
+export const Loading: Story = {
+ args: {
+ variant: "primary",
+ isLoading: true,
+ loadingText: "Processing…",
+ children: "Submit",
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ variant: "primary",
+ disabled: true,
+ children: "Disabled Button",
+ },
+};
diff --git a/frontend/components/ui/Button.tsx b/frontend/components/ui/Button.tsx
new file mode 100644
index 0000000..192b140
--- /dev/null
+++ b/frontend/components/ui/Button.tsx
@@ -0,0 +1,128 @@
+import React, { forwardRef } from "react";
+
+export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
+export type ButtonSize = "sm" | "md" | "lg";
+
+export interface ButtonProps extends React.ButtonHTMLAttributes {
+ /**
+ * Visual style variant of the button.
+ * @default "primary"
+ */
+ variant?: ButtonVariant;
+
+ /**
+ * Size of the button padding and text.
+ * @default "md"
+ */
+ size?: ButtonSize;
+
+ /**
+ * If true, displays a loading spinner and disables user interaction.
+ * @default false
+ */
+ isLoading?: boolean;
+
+ /**
+ * Optional accessible text displayed alongside the spinner while loading.
+ */
+ loadingText?: string;
+
+ /**
+ * Optional icon to show before the label.
+ */
+ leftIcon?: React.ReactNode;
+
+ /**
+ * Optional icon to show after the label.
+ */
+ rightIcon?: React.ReactNode;
+}
+
+const variantStyles: Record = {
+ primary:
+ "bg-violet-600 text-white hover:bg-violet-700 focus-visible:ring-violet-500 dark:bg-violet-600 dark:hover:bg-violet-500 dark:focus-visible:ring-violet-400 disabled:bg-violet-300 dark:disabled:bg-violet-900/50",
+ secondary:
+ "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus-visible:ring-violet-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 dark:focus-visible:ring-violet-400 disabled:opacity-50",
+ danger:
+ "bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500 dark:bg-red-600 dark:hover:bg-red-500 dark:focus-visible:ring-red-400 disabled:bg-red-300 dark:disabled:bg-red-900/50",
+ ghost:
+ "text-gray-700 hover:bg-gray-100 focus-visible:ring-violet-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:focus-visible:ring-violet-400 disabled:opacity-50",
+};
+
+const sizeStyles: Record = {
+ sm: "px-3 py-1.5 text-xs rounded-lg gap-1.5",
+ md: "px-4 py-2 text-sm rounded-xl gap-2",
+ lg: "px-6 py-3 text-base rounded-xl gap-2.5",
+};
+
+/**
+ * Airflex primitive Button supporting primary, secondary, danger, and ghost variants,
+ * accessible states, loading indicator, and dark mode.
+ */
+export const Button = forwardRef(
+ (
+ {
+ variant = "primary",
+ size = "md",
+ isLoading = false,
+ loadingText,
+ disabled,
+ className = "",
+ children,
+ leftIcon,
+ rightIcon,
+ type = "button",
+ ...props
+ },
+ ref
+ ) => {
+ const isDisabled = disabled || isLoading;
+
+ return (
+
+ {isLoading ? (
+ <>
+
+ {loadingText ? {loadingText} : children}
+ >
+ ) : (
+ <>
+ {leftIcon && {leftIcon}}
+ {children}
+ {rightIcon && {rightIcon}}
+ >
+ )}
+
+ );
+ }
+);
+
+Button.displayName = "Button";
+export default Button;
diff --git a/frontend/components/ui/Card.stories.tsx b/frontend/components/ui/Card.stories.tsx
new file mode 100644
index 0000000..d0a2067
--- /dev/null
+++ b/frontend/components/ui/Card.stories.tsx
@@ -0,0 +1,43 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./Card";
+import { Button } from "./Button";
+
+const meta: Meta = {
+ title: "UI/Card",
+ component: Card,
+ tags: ["autodocs"],
+ argTypes: {
+ noPadding: {
+ control: "boolean",
+ description: "Toggles outer card padding",
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
+ Trade Details
+ Escrow-protected airtime purchase
+
+
+
+ Asset
+ MTN Airtime
+
+
+ Amount
+ ₦5,000
+
+
+
+ Cancel
+ Buy Now
+
+
+ ),
+};
diff --git a/frontend/components/ui/Card.tsx b/frontend/components/ui/Card.tsx
new file mode 100644
index 0000000..dae1129
--- /dev/null
+++ b/frontend/components/ui/Card.tsx
@@ -0,0 +1,114 @@
+import React, { forwardRef } from "react";
+
+export interface CardProps extends React.HTMLAttributes {
+ /**
+ * Optional boolean to remove outer padding from the Card container.
+ * @default false
+ */
+ noPadding?: boolean;
+}
+
+/**
+ * Airflex primitive Card container with light/dark surface, border, and elevation styling.
+ */
+export const Card = forwardRef(
+ ({ noPadding = false, className = "", children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+Card.displayName = "Card";
+
+export interface CardHeaderProps extends React.HTMLAttributes {}
+
+export const CardHeader = forwardRef(
+ ({ className = "", children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+CardHeader.displayName = "CardHeader";
+
+export interface CardTitleProps extends React.HTMLAttributes {
+ as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
+}
+
+export const CardTitle = forwardRef(
+ ({ as: Component = "h3", className = "", children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+CardTitle.displayName = "CardTitle";
+
+export interface CardDescriptionProps extends React.HTMLAttributes {}
+
+export const CardDescription = forwardRef(
+ ({ className = "", children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+CardDescription.displayName = "CardDescription";
+
+export interface CardContentProps extends React.HTMLAttributes {}
+
+export const CardContent = forwardRef(
+ ({ className = "", children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+CardContent.displayName = "CardContent";
+
+export interface CardFooterProps extends React.HTMLAttributes {}
+
+export const CardFooter = forwardRef(
+ ({ className = "", children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+CardFooter.displayName = "CardFooter";
+
+export default Card;
diff --git a/frontend/components/ui/Input.stories.tsx b/frontend/components/ui/Input.stories.tsx
new file mode 100644
index 0000000..911ac12
--- /dev/null
+++ b/frontend/components/ui/Input.stories.tsx
@@ -0,0 +1,62 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Input } from "./Input";
+
+const meta: Meta = {
+ title: "UI/Input",
+ component: Input,
+ tags: ["autodocs"],
+ argTypes: {
+ label: {
+ control: "text",
+ description: "Label above the input",
+ },
+ placeholder: {
+ control: "text",
+ description: "Placeholder text inside the input",
+ },
+ error: {
+ control: "text",
+ description: "Error message indicating invalid state",
+ },
+ helperText: {
+ control: "text",
+ description: "Helpful text below the input",
+ },
+ disabled: {
+ control: "boolean",
+ description: "Disables the input field",
+ },
+ required: {
+ control: "boolean",
+ description: "Whether the input is required",
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ label: "Email Address",
+ placeholder: "you@example.com",
+ helperText: "We'll never share your email with anyone.",
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ label: "Amount (NGN)",
+ defaultValue: "-500",
+ error: "Please enter a valid amount of at least ₦100.",
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ label: "Wallet Address",
+ value: "CCBJ235OCBFZXBFSUUUT4PMG7RRCAXZXMUEB2L7CTTQ5NRSNO4P2SLNP",
+ disabled: true,
+ helperText: "Generated by Soroban escrow contract.",
+ },
+};
diff --git a/frontend/components/ui/Input.tsx b/frontend/components/ui/Input.tsx
new file mode 100644
index 0000000..b48665b
--- /dev/null
+++ b/frontend/components/ui/Input.tsx
@@ -0,0 +1,121 @@
+import React, { forwardRef, useId } from "react";
+
+export interface InputProps extends React.InputHTMLAttributes {
+ /**
+ * Optional label displayed above the input field.
+ */
+ label?: string;
+
+ /**
+ * Error message displayed below the input. When present, marks the input as invalid.
+ */
+ error?: string;
+
+ /**
+ * Supporting instructional text displayed below the input when not in error state.
+ */
+ helperText?: string;
+
+ /**
+ * Optional icon or element to render on the left side inside the input.
+ */
+ leftAddon?: React.ReactNode;
+
+ /**
+ * Optional icon or element to render on the right side inside the input.
+ */
+ rightAddon?: React.ReactNode;
+}
+
+/**
+ * Airflex primitive Input with label, validation errors, helper text, dark mode, and accessibility support.
+ */
+export const Input = forwardRef(
+ (
+ {
+ label,
+ error,
+ helperText,
+ leftAddon,
+ rightAddon,
+ id,
+ disabled,
+ required,
+ className = "",
+ ...props
+ },
+ ref
+ ) => {
+ const generatedId = useId();
+ const inputId = id ?? generatedId;
+ const errorId = `${inputId}-error`;
+ const helperId = `${inputId}-helper`;
+
+ const hasError = Boolean(error);
+
+ return (
+
+ {label && (
+
+ )}
+
+
+ {leftAddon && (
+
+ {leftAddon}
+
+ )}
+
+
+
+ {rightAddon && (
+
+ {rightAddon}
+
+ )}
+
+
+ {hasError ? (
+
+ {error}
+
+ ) : helperText ? (
+
+ {helperText}
+
+ ) : null}
+
+ );
+ }
+);
+
+Input.displayName = "Input";
+export default Input;
diff --git a/frontend/components/ui/Modal.stories.tsx b/frontend/components/ui/Modal.stories.tsx
new file mode 100644
index 0000000..ceb0e6a
--- /dev/null
+++ b/frontend/components/ui/Modal.stories.tsx
@@ -0,0 +1,63 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import React, { useState } from "react";
+import { Modal } from "./Modal";
+import { Button } from "./Button";
+
+const meta: Meta = {
+ title: "UI/Modal",
+ component: Modal,
+ tags: ["autodocs"],
+ argTypes: {
+ isOpen: {
+ control: "boolean",
+ description: "Controls open visibility of the modal",
+ },
+ title: {
+ control: "text",
+ description: "Header title of the dialog",
+ },
+ description: {
+ control: "text",
+ description: "Contextual subtitle in the dialog",
+ },
+ maxWidth: {
+ control: "text",
+ description: "Tailwind max-width class",
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Interactive: Story = {
+ render: () => {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
setOpen(true)}>Open Modal
+
setOpen(false)}
+ title="Confirm Action"
+ description="Are you sure you want to proceed with this operation?"
+ footer={
+ <>
+ setOpen(false)}>
+ Cancel
+
+ setOpen(false)}>
+ Confirm
+
+ >
+ }
+ >
+
+ This action will update the contract status on the Stellar network.
+
+
+
+ );
+ },
+};
diff --git a/frontend/components/ui/Modal.tsx b/frontend/components/ui/Modal.tsx
new file mode 100644
index 0000000..2f1250a
--- /dev/null
+++ b/frontend/components/ui/Modal.tsx
@@ -0,0 +1,199 @@
+import React, { useEffect, useRef, useCallback, useId } from "react";
+
+export interface ModalProps {
+ /**
+ * Whether the modal dialog is currently open and visible.
+ */
+ isOpen: boolean;
+
+ /**
+ * Callback fired when the modal requests closure (via ESC key, backdrop click, or close button).
+ */
+ onClose: () => void;
+
+ /**
+ * Optional title displayed in the modal header.
+ */
+ title?: React.ReactNode;
+
+ /**
+ * Optional description text displayed under the title.
+ */
+ description?: React.ReactNode;
+
+ /**
+ * Main dialog contents.
+ */
+ children: React.ReactNode;
+
+ /**
+ * Optional footer actions (e.g. Cancel and Confirm buttons).
+ */
+ footer?: React.ReactNode;
+
+ /**
+ * Optional max width styling for the modal dialog.
+ * @default "max-w-lg"
+ */
+ maxWidth?: string;
+
+ /**
+ * Whether clicking the backdrop closes the modal.
+ * @default true
+ */
+ closeOnBackdropClick?: boolean;
+}
+
+/**
+ * Accessible Modal dialog primitive with focus trap, ESC listener, ARIA role="dialog",
+ * smooth backdrop transition, and dark mode support.
+ */
+export function Modal({
+ isOpen,
+ onClose,
+ title,
+ description,
+ children,
+ footer,
+ maxWidth = "max-w-lg",
+ closeOnBackdropClick = true,
+}: ModalProps) {
+ const modalRef = useRef(null);
+ const previousFocusRef = useRef(null);
+ const titleId = useId();
+ const descId = useId();
+
+ // Handle ESC key to close
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onClose();
+ return;
+ }
+
+ // Accessible Focus Trap
+ if (e.key === "Tab" && modalRef.current) {
+ const focusableElements = modalRef.current.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+ if (!focusableElements.length) return;
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+
+ if (e.shiftKey) {
+ if (document.activeElement === firstElement) {
+ e.preventDefault();
+ lastElement?.focus();
+ }
+ } else {
+ if (document.activeElement === lastElement) {
+ e.preventDefault();
+ firstElement?.focus();
+ }
+ }
+ }
+ },
+ [onClose]
+ );
+
+ useEffect(() => {
+ if (isOpen) {
+ previousFocusRef.current = document.activeElement as HTMLElement;
+ document.body.style.overflow = "hidden";
+ window.addEventListener("keydown", handleKeyDown);
+
+ // Focus first focusable element or modal container
+ requestAnimationFrame(() => {
+ if (modalRef.current) {
+ const focusable = modalRef.current.querySelector(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+ focusable ? focusable.focus() : modalRef.current.focus();
+ }
+ });
+ } else {
+ document.body.style.overflow = "";
+ window.removeEventListener("keydown", handleKeyDown);
+ previousFocusRef.current?.focus();
+ }
+
+ return () => {
+ document.body.style.overflow = "";
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [isOpen, handleKeyDown]);
+
+ if (!isOpen) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Dialog */}
+
+ {/* Header */}
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+
+
+ {/* Body Content */}
+
{children}
+
+ {/* Footer */}
+ {footer &&
{footer}
}
+
+
+ );
+}
+
+export default Modal;
diff --git a/frontend/components/ui/Select.stories.tsx b/frontend/components/ui/Select.stories.tsx
new file mode 100644
index 0000000..82896fd
--- /dev/null
+++ b/frontend/components/ui/Select.stories.tsx
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Select } from "./Select";
+
+const meta: Meta = {
+ title: "UI/Select",
+ component: Select,
+ tags: ["autodocs"],
+ argTypes: {
+ label: {
+ control: "text",
+ description: "Label above the select element",
+ },
+ error: {
+ control: "text",
+ description: "Error message indicating invalid state",
+ },
+ helperText: {
+ control: "text",
+ description: "Helpful text below the select",
+ },
+ disabled: {
+ control: "boolean",
+ description: "Disables the select element",
+ },
+ required: {
+ control: "boolean",
+ description: "Whether a selection is required",
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+const bankOptions = [
+ { value: "058", label: "GTBank" },
+ { value: "011", label: "First Bank of Nigeria" },
+ { value: "033", label: "United Bank for Africa (UBA)" },
+ { value: "057", label: "Zenith Bank" },
+ { value: "044", label: "Access Bank" },
+];
+
+export const Default: Story = {
+ args: {
+ label: "Destination Bank",
+ options: bankOptions,
+ helperText: "Select your registered Nigerian bank account.",
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ label: "Destination Bank",
+ options: [{ value: "", label: "-- Select a bank --" }, ...bankOptions],
+ error: "Please select a valid bank.",
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ label: "Settlement Currency",
+ options: [{ value: "NGN", label: "Nigerian Naira (NGN)" }],
+ disabled: true,
+ },
+};
diff --git a/frontend/components/ui/Select.tsx b/frontend/components/ui/Select.tsx
new file mode 100644
index 0000000..733e99c
--- /dev/null
+++ b/frontend/components/ui/Select.tsx
@@ -0,0 +1,129 @@
+import React, { forwardRef, useId } from "react";
+
+export interface SelectOption {
+ value: string;
+ label: string;
+ disabled?: boolean;
+}
+
+export interface SelectProps extends React.SelectHTMLAttributes {
+ /**
+ * Optional label displayed above the select element.
+ */
+ label?: string;
+
+ /**
+ * Optional error message displayed below the select element.
+ */
+ error?: string;
+
+ /**
+ * Supporting instructional text displayed below the select when not in error state.
+ */
+ helperText?: string;
+
+ /**
+ * Array of options to render in the dropdown. Can also pass