diff --git a/apps/frontend/app/escrow/[id]/page.tsx b/apps/frontend/app/escrow/[id]/page.tsx index e3c2f9a..1e6a107 100644 --- a/apps/frontend/app/escrow/[id]/page.tsx +++ b/apps/frontend/app/escrow/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; import { useEscrow } from '@/hooks/useEscrow'; @@ -9,41 +9,43 @@ import EscrowHeader from '@/components/escrow/detail/EscrowHeader'; import PartiesSection from '@/components/escrow/detail/PartiesSection'; import TermsSection from '@/components/escrow/detail/TermsSection'; import TimelineSection from '@/components/escrow/detail/TimelineSection'; -import TransactionHistory from '@/components/escrow/detail/TransactionHistory'; import ActivityFeed from '@/components/common/ActivityFeed'; -import { IEscrowExtended } from '@/types/escrow'; +import ConditionsList from '@/component/escrow/ConditionsList'; +import { IParty } from '@/types/escrow'; import FileDisputeModal from '@/components/escrow/detail/file-dispute-modal'; -import { Button } from '@/components/ui/button'; import { EscrowDetailSkeleton } from '@/components/ui/EscrowDetailSkeleton'; const EscrowDetailPage = () => { const { id } = useParams(); - const { escrow, error, loading } = useEscrow(id as string); - const { connected, publicKey, connect } = useWallet(); // Assuming wallet hook exists + const { escrow, error, loading, refetch } = useEscrow(id as string); + const { connected, publicKey, connect } = useWallet(); const [userRole, setUserRole] = useState<'creator' | 'counterparty' | null>(null); + const [currentParty, setCurrentParty] = useState(null); const [disputeOpen, setDisputeOpen] = useState(false); useEffect(() => { if (escrow && publicKey) { if (escrow.creatorId === publicKey) { setUserRole('creator'); - } else if (escrow.parties?.some((party: any) => party.userId === publicKey)) { + setCurrentParty(null); + } else if (escrow.parties?.some((party) => party.userId === publicKey)) { setUserRole('counterparty'); + setCurrentParty( + escrow.parties.find((party) => party.userId === publicKey) ?? null, + ); + } else { + setUserRole(null); + setCurrentParty(null); } + } else { + setUserRole(null); + setCurrentParty(null); } }, [escrow, publicKey]); if (loading) { - return ( - //
- //
- //
- //

Loading escrow details...

- //
- //
- - ); + return ; } if (error) { @@ -83,7 +85,6 @@ const EscrowDetailPage = () => { return (
- {/* Header Section */} {
- {/* Parties Section */} - + + + - {/* Timeline Section */} - {/* Activity Feed */}
- {/* Terms Section */}
@@ -120,4 +129,4 @@ const EscrowDetailPage = () => { ); }; -export default EscrowDetailPage; \ No newline at end of file +export default EscrowDetailPage; diff --git a/apps/frontend/component/escrow/ConditionItem.tsx b/apps/frontend/component/escrow/ConditionItem.tsx index b87e5f6..4fd7b64 100644 --- a/apps/frontend/component/escrow/ConditionItem.tsx +++ b/apps/frontend/component/escrow/ConditionItem.tsx @@ -1,40 +1,249 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; +import { + AlertTriangle, + CheckCircle2, + Clock3, + FileText, + Loader2, + ShieldAlert, +} from 'lucide-react'; +import { confirmCondition } from '@/lib/escrow-api'; +import { ICondition, IParty } from '@/types/escrow'; import FulfillConditionModal from './FulfillConditionModal'; - -interface Condition { - id: string; - description: string; - fulfilled: boolean; - confirmed: boolean; -} +import { Button } from '@/components/ui/button'; interface Props { - condition: Condition; - role: 'seller' | 'buyer'; + escrowId: string; + condition: ICondition; + currentParty: IParty | null; + escrowStatus: string; + onUpdated: () => Promise; + isLastOutstandingCondition: boolean; } -const ConditionItem: React.FC = ({ condition, role }) => { +const isLikelyUrl = (value?: string | null) => + Boolean(value && /^https?:\/\//i.test(value)); + +const formatDateTime = (value?: string | null) => + value + ? new Date(value).toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }) + : null; + +const ConditionItem: React.FC = ({ + escrowId, + condition, + currentParty, + escrowStatus, + onUpdated, + isLastOutstandingCondition, +}) => { const [showModal, setShowModal] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const [error, setError] = useState(null); + const [infoMessage, setInfoMessage] = useState(null); + + const partyRole = currentParty?.role?.toLowerCase(); + const partyStatus = currentParty?.status?.toLowerCase(); + const isEscrowActive = escrowStatus.toLowerCase() === 'active'; + const isFulfilled = Boolean(condition.isFulfilled); + const isConfirmed = Boolean(condition.isMet); + + const canFulfill = + partyRole === 'seller' && partyStatus === 'accepted' && !isFulfilled && isEscrowActive; + const canConfirm = + partyRole === 'buyer' && partyStatus === 'accepted' && isFulfilled && !isConfirmed && isEscrowActive; + + const statusConfig = useMemo(() => { + if (isConfirmed) { + return { + label: 'Confirmed', + className: 'bg-emerald-100 text-emerald-800', + icon: , + }; + } + + if (isFulfilled) { + return { + label: 'Awaiting buyer confirmation', + className: 'bg-amber-100 text-amber-800', + icon: , + }; + } + + return { + label: 'Pending fulfillment', + className: 'bg-slate-100 text-slate-700', + icon: , + }; + }, [isConfirmed, isFulfilled]); - const handleFulfill = () => setShowModal(true); const handleConfirm = async () => { - // API call to confirm condition - await fetch(`/api/escrow/conditions/${condition.id}/confirm`, { method: 'POST' }); + setError(null); + setInfoMessage(null); + + if (isLastOutstandingCondition) { + const shouldContinue = window.confirm( + 'This is the last outstanding condition. Confirming it may trigger automatic fund release. Continue?', + ); + + if (!shouldContinue) { + return; + } + } + + setIsConfirming(true); + + try { + await confirmCondition(escrowId, condition.id); + await onUpdated(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Failed to confirm this condition. Please try again.', + ); + } finally { + setIsConfirming(false); + } + }; + + const handleReject = () => { + setInfoMessage( + 'Rejecting a fulfillment is not yet supported by the backend API. Ask the seller for updated evidence or file a dispute if the condition is not met.', + ); }; return ( -
-

{condition.description}

- {role === 'seller' && !condition.fulfilled && ( - - )} - {role === 'buyer' && condition.fulfilled && !condition.confirmed && ( - - )} +
+
+
+
+ + {statusConfig.icon} + {statusConfig.label} + + {isLastOutstandingCondition && canConfirm && ( + + + Final confirmation triggers auto-release + + )} +
+ +
+

{condition.description}

+

+ Condition type: {condition.type} +

+
+ + {error && ( +
+ +

{error}

+
+ )} + + {infoMessage && ( +
+ +

{infoMessage}

+
+ )} + + {(condition.fulfilledAt || condition.fulfillmentNotes || condition.fulfillmentEvidence) && ( +
+

Fulfillment details

+ {condition.fulfilledAt && ( +

+ Fulfilled on {formatDateTime(condition.fulfilledAt)} + {condition.fulfilledByUserId + ? ` by ${condition.fulfilledByUserId}` + : ''} +

+ )} + {condition.fulfillmentNotes && ( +

{condition.fulfillmentNotes}

+ )} + {condition.fulfillmentEvidence && ( +
+ + {isLikelyUrl(condition.fulfillmentEvidence) ? ( + + {condition.fulfillmentEvidence} + + ) : ( +

{condition.fulfillmentEvidence}

+ )} +
+ )} +
+ )} + + {condition.metAt && ( +
+ Confirmed on {formatDateTime(condition.metAt)} + {condition.metByUserId ? ` by ${condition.metByUserId}` : ''}. +
+ )} +
+ +
+ {canFulfill && ( + + )} + + {canConfirm && ( + <> + + + + )} + + {!isEscrowActive && ( +

+ Actions are disabled because this escrow is {escrowStatus.toLowerCase()}. +

+ )} + + {partyStatus !== 'accepted' && currentParty && ( +

+ Accept your invitation before taking condition actions. +

+ )} +
+
+ {showModal && ( setShowModal(false)} + onSubmitted={onUpdated} /> )}
diff --git a/apps/frontend/component/escrow/ConditionsList.tsx b/apps/frontend/component/escrow/ConditionsList.tsx index 8f1086c..63643a4 100644 --- a/apps/frontend/component/escrow/ConditionsList.tsx +++ b/apps/frontend/component/escrow/ConditionsList.tsx @@ -1,25 +1,81 @@ import React from 'react'; +import { CheckCircle2, Clock3 } from 'lucide-react'; +import { ICondition, IParty } from '@/types/escrow'; import ConditionItem from './ConditionItem'; -interface Condition { - id: string; - description: string; - fulfilled: boolean; - confirmed: boolean; -} - interface Props { - conditions: Condition[]; - role: 'seller' | 'buyer'; + escrowId: string; + escrowStatus: string; + conditions: ICondition[]; + currentParty: IParty | null; + onConditionsUpdated: () => Promise; } -const ConditionsList: React.FC = ({ conditions, role }) => { +const ConditionsList: React.FC = ({ + escrowId, + escrowStatus, + conditions, + currentParty, + onConditionsUpdated, +}) => { + const totalConditions = conditions.length; + const fulfilledConditions = conditions.filter((condition) => condition.isFulfilled).length; + const confirmedConditions = conditions.filter((condition) => condition.isMet).length; + const remainingConfirmations = conditions.filter( + (condition) => condition.isFulfilled && !condition.isMet, + ).length; + const allConditionsMet = totalConditions > 0 && confirmedConditions === totalConditions; + + if (totalConditions === 0) { + return null; + } + return ( -
- {conditions.map((condition) => ( - - ))} -
+
+
+
+

Conditions

+

+ {confirmedConditions} of {totalConditions} confirmed, {fulfilledConditions} fulfilled. +

+
+
+ + {remainingConfirmations} awaiting buyer review +
+
+ + {allConditionsMet && ( +
+ +
+

All conditions have been confirmed

+

+ The escrow is now eligible for automatic fund release. Watch the escrow status and + activity feed for the release event. +

+
+
+ )} + +
+ {conditions.map((condition) => ( + + ))} +
+
); }; diff --git a/apps/frontend/component/escrow/FulfillConditionModal.tsx b/apps/frontend/component/escrow/FulfillConditionModal.tsx index 412e3ae..8970798 100644 --- a/apps/frontend/component/escrow/FulfillConditionModal.tsx +++ b/apps/frontend/component/escrow/FulfillConditionModal.tsx @@ -1,39 +1,177 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; +import { AlertTriangle, FileText, Loader2, UploadCloud } from 'lucide-react'; +import { fulfillCondition } from '@/lib/escrow-api'; +import { ICondition } from '@/types/escrow'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; interface Props { - conditionId: string; + escrowId: string; + condition: ICondition; + isOpen: boolean; onClose: () => void; + onSubmitted: () => Promise; } -const FulfillConditionModal: React.FC = ({ conditionId, onClose }) => { +const FulfillConditionModal: React.FC = ({ + escrowId, + condition, + isOpen, + onClose, + onSubmitted, +}) => { const [notes, setNotes] = useState(''); + const [evidenceUrl, setEvidenceUrl] = useState(''); const [file, setFile] = useState(null); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); - const handleSubmit = async () => { - const formData = new FormData(); - formData.append('notes', notes); - if (file) formData.append('evidence', file); + const evidenceValue = useMemo(() => { + if (evidenceUrl.trim()) { + return evidenceUrl.trim(); + } + + if (file) { + return `Uploaded file: ${file.name}`; + } - await fetch(`/api/escrow/conditions/${conditionId}/fulfill`, { - method: 'POST', - body: formData, - }); + return undefined; + }, [evidenceUrl, file]); + const resetAndClose = () => { + setNotes(''); + setEvidenceUrl(''); + setFile(null); + setError(null); onClose(); }; + const handleSubmit = async () => { + setIsSubmitting(true); + setError(null); + + try { + await fulfillCondition(escrowId, condition.id, { + notes: notes.trim() || undefined, + evidence: evidenceValue, + }); + await onSubmitted(); + resetAndClose(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : 'Failed to fulfill this condition. Please try again.', + ); + } finally { + setIsSubmitting(false); + } + }; + return ( -
-

Fulfill Condition

-