diff --git a/package-lock.json b/package-lock.json index 7cf84b7..d30cfe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,7 +120,6 @@ "version": "7.29.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -439,7 +438,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -484,7 +482,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2009,7 +2006,8 @@ "node_modules/@types/aria-query": { "version": "5.0.4", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2142,7 +2140,6 @@ "version": "19.2.14", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2161,7 +2158,6 @@ "version": "19.2.3", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2230,7 +2226,6 @@ "version": "8.56.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2577,7 +2572,6 @@ "version": "8.16.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2702,7 +2696,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2961,7 +2954,8 @@ "node_modules/dom-accessibility-api": { "version": "0.5.16", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-to-chromium": { "version": "1.5.302", @@ -3064,7 +3058,6 @@ "version": "9.39.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4116,6 +4109,7 @@ "version": "1.5.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4163,7 +4157,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", @@ -4409,6 +4402,7 @@ "version": "27.5.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4422,6 +4416,7 @@ "version": "5.2.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -4457,7 +4452,6 @@ "node_modules/react": { "version": "19.2.4", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4478,7 +4472,6 @@ "node_modules/react-dom": { "version": "19.2.4", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4489,7 +4482,8 @@ "node_modules/react-is": { "version": "17.0.2", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -4960,7 +4954,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5054,7 +5047,6 @@ "node_modules/vite": { "version": "7.3.1", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5381,7 +5373,6 @@ "version": "4.3.6", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/App.tsx b/src/App.tsx index 647355f..9550654 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,10 +18,8 @@ import { SettlementSummaryPage } from "./pages/SettlementSummaryPage"; import AdoptionTimelinePage from "./pages/AdoptionTimelinePage"; import ModalPreview from "./pages/ModalPreview"; import StatusPollingDemo from "./pages/StatusPollingDemo"; -<<<<<<< feat/custody-timeline-page import CustodyTimelinePage from "./pages/CustodyTimelinePage"; -======= ->>>>>>> main +import DisputeDetailPage from "./pages/DisputeDetailPage"; function App() { @@ -47,6 +45,7 @@ function App() { } /> } /> } /> + } /> {/* Custody Routes */} } /> diff --git a/src/api/custodyService.ts b/src/api/custodyService.ts index 73b3066..7911ec6 100644 --- a/src/api/custodyService.ts +++ b/src/api/custodyService.ts @@ -1,5 +1,4 @@ import { apiClient } from "../lib/api-client"; -<<<<<<< feat/custody-timeline-page export interface CustodyTimelineEvent { type: string; @@ -25,12 +24,5 @@ export const custodyService = { return apiClient.get( `/custody/${custodyId}/timeline`, ); -======= -import type { CustodyDetails } from "../types/adoption"; - -export const custodyService = { - async getDetails(custodyId: string): Promise { - return apiClient.get(`/custody/${custodyId}`); ->>>>>>> main }, }; diff --git a/src/api/disputeService.ts b/src/api/disputeService.ts new file mode 100644 index 0000000..c7301a2 --- /dev/null +++ b/src/api/disputeService.ts @@ -0,0 +1,8 @@ +import { apiClient } from "../lib/api-client"; +import type { DisputeDetails } from "../types/dispute"; + +export const disputeService = { + async getDetails(disputeId: string): Promise { + return apiClient.get(`/disputes/${disputeId}`); + }, +}; \ No newline at end of file diff --git a/src/components/dispute/DisputeDetailHeader.tsx b/src/components/dispute/DisputeDetailHeader.tsx new file mode 100644 index 0000000..10ef0d8 --- /dev/null +++ b/src/components/dispute/DisputeDetailHeader.tsx @@ -0,0 +1,181 @@ +import { FileText, Gavel, Plus, UserRound } from "lucide-react"; +import { EscrowStatusBadge } from "../escrow/EscrowStatusBadge"; +import { StellarTxLink } from "../escrow/StellarTxLink"; +import { EmptyState } from "../ui/emptyState"; +import { Skeleton } from "../ui/Skeleton"; +import { DisputeSLABadge } from "./DisputeSLABadge"; +import { DisputeStatusBadge } from "./DisputeStatusBadge"; +import type { DisputeDetails } from "../../types/dispute"; + +interface DisputeDetailHeaderProps { + data?: DisputeDetails; + isLoading?: boolean; + onAddEvidence?: () => void; +} + +function formatRole(role: DisputeDetails["raisedBy"]["role"]) { + return role.charAt(0) + role.slice(1).toLowerCase(); +} + +function HeaderSectionSkeleton() { + return ( +
+
+ + + +
+
+ ); + } + +export function DisputeDetailHeader({ + data, + isLoading = false, + onAddEvidence, +}: DisputeDetailHeaderProps) { + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + if (!data) { + return ( + + ); + } + + const canAddEvidence = data.status === "OPEN" || data.status === "UNDER_REVIEW"; + + return ( +
+
+
+
+

+ Dispute Detail +

+

Case #{data.id}

+

Adoption #{data.adoptionId}

+
+
+ + +
+
+ +
+
+
+ + Raised by +
+

{data.raisedBy.name}

+

{formatRole(data.raisedBy.role)}

+
+ +
+
+ + Reason +
+

+ {data.reason.replace(/_/g, " ")} +

+

{data.description}

+
+
+
+ +
+
+
+ + Evidence Files +
+ +
+ {data.evidence.length === 0 ? ( + + ) : ( + data.evidence.map((file) => ( +
+
+

{file.fileName}

+

+ Added by {file.submittedBy.name} · {new Date(file.submittedAt).toLocaleString()} +

+ + SHA-256: {file.sha256} + +
+ + + Download + +
+ )) + )} +
+ + {canAddEvidence && onAddEvidence ? ( + + ) : null} +
+ +
+

+ Escrow +

+
+
+

+ On-chain status +

+
+ +
+
+ +
+

+ Stellar account +

+
+ +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/dispute/DisputeSLABadge.tsx b/src/components/dispute/DisputeSLABadge.tsx new file mode 100644 index 0000000..2888f71 --- /dev/null +++ b/src/components/dispute/DisputeSLABadge.tsx @@ -0,0 +1,39 @@ +import { AlertTriangle, CheckCircle2, Clock3 } from "lucide-react"; + +interface DisputeSLABadgeProps { + deadlineAt: string; + now?: Date; +} + +function getRemainingHours(deadlineAt: string, now: Date): number { + return Math.ceil((new Date(deadlineAt).getTime() - now.getTime()) / (1000 * 60 * 60)); +} + +export function DisputeSLABadge({ deadlineAt, now = new Date() }: DisputeSLABadgeProps) { + const hoursRemaining = getRemainingHours(deadlineAt, now); + + let label = "On track"; + let className = "bg-emerald-100 text-emerald-800"; + let Icon = CheckCircle2; + + if (hoursRemaining <= 0) { + label = "SLA breached"; + className = "bg-rose-100 text-rose-800"; + Icon = AlertTriangle; + } else if (hoursRemaining <= 24) { + label = `${hoursRemaining}h remaining`; + className = "bg-amber-100 text-amber-800"; + Icon = Clock3; + } else { + label = `${hoursRemaining}h remaining`; + } + + return ( + + + {label} + + ); +} \ No newline at end of file diff --git a/src/components/dispute/DisputeStatusBadge.tsx b/src/components/dispute/DisputeStatusBadge.tsx new file mode 100644 index 0000000..b38af5f --- /dev/null +++ b/src/components/dispute/DisputeStatusBadge.tsx @@ -0,0 +1,36 @@ +import type { DisputeStatus } from "../../types/dispute"; + +interface DisputeStatusBadgeProps { + status: DisputeStatus; +} + +const STATUS_STYLES: Record = { + OPEN: { + label: "Open", + className: "bg-amber-100 text-amber-800", + }, + UNDER_REVIEW: { + label: "Under Review", + className: "bg-sky-100 text-sky-800", + }, + RESOLVED: { + label: "Resolved", + className: "bg-emerald-100 text-emerald-800", + }, + CLOSED: { + label: "Closed", + className: "bg-slate-200 text-slate-700", + }, +}; + +export function DisputeStatusBadge({ status }: DisputeStatusBadgeProps) { + const config = STATUS_STYLES[status]; + + return ( + + {config.label} + + ); +} \ No newline at end of file diff --git a/src/components/dispute/index.ts b/src/components/dispute/index.ts new file mode 100644 index 0000000..c7b7137 --- /dev/null +++ b/src/components/dispute/index.ts @@ -0,0 +1,3 @@ +export * from "./DisputeDetailHeader"; +export * from "./DisputeSLABadge"; +export * from "./DisputeStatusBadge"; \ No newline at end of file diff --git a/src/components/escrow/StellarTxLink.tsx b/src/components/escrow/StellarTxLink.tsx index 5fd940c..833a9e1 100644 --- a/src/components/escrow/StellarTxLink.tsx +++ b/src/components/escrow/StellarTxLink.tsx @@ -1,13 +1,23 @@ import { useState } from "react"; -import { stellarExplorerUrl, truncateTxHash } from "../../lib/stellar"; +import { + stellarAccountExplorerUrl, + stellarExplorerUrl, + truncateTxHash, +} from "../../lib/stellar"; interface StellarTxLinkProps { txHash: string; + resourceType?: "tx" | "account"; } -export function StellarTxLink({ txHash }: StellarTxLinkProps) { +export function StellarTxLink({ + txHash, + resourceType = "tx", +}: StellarTxLinkProps) { const [copied, setCopied] = useState(false); - const href = stellarExplorerUrl(txHash); + const href = resourceType === "account" + ? stellarAccountExplorerUrl(txHash) + : stellarExplorerUrl(txHash); const label = truncateTxHash(txHash); async function handleCopy() { diff --git a/src/components/modals/EvidenceUploadModal.tsx b/src/components/modals/EvidenceUploadModal.tsx new file mode 100644 index 0000000..4ebc416 --- /dev/null +++ b/src/components/modals/EvidenceUploadModal.tsx @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from "react"; + +interface EvidenceUploadModalProps { + isOpen: boolean; + onClose: () => void; + disputeId: string; +} + +export function EvidenceUploadModal({ + isOpen, + onClose, + disputeId, +}: EvidenceUploadModalProps) { + const closeButtonRef = useRef(null); + const [notes, setNotes] = useState(""); + const [fileName, setFileName] = useState(""); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + closeButtonRef.current?.focus(); + + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + function handleClose() { + setNotes(""); + setFileName(""); + onClose(); + } + + function handleSubmit() { + handleClose(); + } + + return ( +
+
event.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="evidence-upload-title" + > + + +
+
+

+ Evidence Upload +

+

+ Add supporting evidence +

+

Dispute #{disputeId}

+
+ +
+ + setFileName(event.target.files?.[0]?.name ?? "")} + className="block w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm text-slate-700 file:mr-4 file:rounded-full file:border-0 file:bg-slate-900 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white" + /> + {fileName ?

Selected: {fileName}

: null} +
+ +
+ +