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 ( -
+

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

Important consequences:

+
    +
  • The Soroban escrow contract will be frozen to protect your funds.
  • +
  • An AirFlex administrator will review on-chain logs and evidence within 24 hours.
  • +
  • Both buyer and seller will receive notifications regarding updates.
  • +
+
+ + {/* Reason Textarea */} +
+
+ + = MAX_REASON_CHARS + ? "text-red-500 font-semibold" + : "text-gray-400 dark:text-gray-500" + }`} + > + {`${reason.length}/${MAX_REASON_CHARS}`} + +
+ +