diff --git a/apps/frontend/app/escrow/[id]/page.tsx b/apps/frontend/app/escrow/[id]/page.tsx new file mode 100644 index 0000000..ad72f70 --- /dev/null +++ b/apps/frontend/app/escrow/[id]/page.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { useEscrow } from '@/hooks/useEscrow'; +import { useWallet } from '@/hooks/useWallet'; +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 { IEscrowExtended } from '@/types/escrow'; + +const EscrowDetailPage = () => { + const { id } = useParams(); + const { escrow, loading, error } = useEscrow(id as string); + const { connected, publicKey, connect } = useWallet(); // Assuming wallet hook exists + const [userRole, setUserRole] = useState<'creator' | 'counterparty' | null>(null); + + useEffect(() => { + if (escrow && publicKey) { + if (escrow.creatorId === publicKey) { + setUserRole('creator'); + } else if (escrow.parties?.some((party: any) => party.userId === publicKey)) { + setUserRole('counterparty'); + } + } + }, [escrow, publicKey]); + + if (loading) { + return ( +
+
+
+

Loading escrow details...

+
+
+ ); + } + + if (error) { + return ( +
+
+

Error Loading Escrow

+

{error}

+ +
+
+ ); + } + + if (!escrow) { + return ( +
+
+

Escrow Not Found

+

The requested escrow agreement could not be found.

+ + Back to Escrows + +
+
+ ); + } + + return ( +
+
+ {/* Header Section */} + + +
+
+ {/* Parties Section */} + + + {/* Timeline Section */} + + + {/* Transaction History */} + +
+ +
+ {/* Terms Section */} + +
+
+
+
+ ); +}; + +export default EscrowDetailPage; \ No newline at end of file diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx index 432c880..da685af 100644 --- a/apps/frontend/app/page.tsx +++ b/apps/frontend/app/page.tsx @@ -1,4 +1,5 @@ -import Image from "next/image"; + import Image from "next/image"; + import Link from "next/link"; export default function Home() { return ( @@ -27,18 +28,18 @@ export default function Home() {

- Access Dashboard - - + Create Escrow - +
@@ -64,26 +65,26 @@ export default function Home() { ); diff --git a/apps/frontend/components/escrow/detail/EscrowHeader.tsx b/apps/frontend/components/escrow/detail/EscrowHeader.tsx new file mode 100644 index 0000000..b320136 --- /dev/null +++ b/apps/frontend/components/escrow/detail/EscrowHeader.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { Share, Copy, Wallet, Clock, CheckCircle, AlertTriangle, XCircle, ShareIcon } from 'lucide-react'; +import { IEscrowExtended } from '@/types/escrow'; + +interface EscrowHeaderProps { + escrow: IEscrowExtended; + userRole: 'creator' | 'counterparty' | null; + connected: boolean; + connect: () => void; + publicKey: string | null; +} + +const getStatusColor = (status: string) => { + switch (status.toLowerCase()) { + case 'pending': + return 'bg-yellow-100 text-yellow-800'; + case 'active': + return 'bg-blue-100 text-blue-800'; + case 'completed': + return 'bg-green-100 text-green-800'; + case 'cancelled': + return 'bg-gray-100 text-gray-800'; + case 'disputed': + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-800'; + } +}; + +const getStatusIcon = (status: string) => { + switch (status.toLowerCase()) { + case 'pending': + return ; + case 'active': + return ; + case 'completed': + return ; + case 'cancelled': + return ; + case 'disputed': + return ; + default: + return ; + } +}; + +const EscrowHeader: React.FC = ({ + escrow, + userRole, + connected, + connect, + publicKey +}: EscrowHeaderProps) => { + const handleCopyLink = () => { + navigator.clipboard.writeText(window.location.href); + alert('Link copied to clipboard!'); + }; + + return ( +
+
+
+
+

{escrow.title}

+ + {getStatusIcon(escrow.status)} + {escrow.status} + +
+

{escrow.description}

+ +
+
+ ID: + {escrow.id.substring(0, 8)}... +
+
+ Amount: + {Number(escrow.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 7 })} {escrow.asset} +
+
+ Created: + {new Date(escrow.createdAt).toLocaleDateString()} +
+
+
+ +
+ + {!connected && ( + + )} +
+
+
+ ); +}; + +export default EscrowHeader; \ No newline at end of file diff --git a/apps/frontend/components/escrow/detail/PartiesSection.tsx b/apps/frontend/components/escrow/detail/PartiesSection.tsx new file mode 100644 index 0000000..730682d --- /dev/null +++ b/apps/frontend/components/escrow/detail/PartiesSection.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { IEscrowExtended } from '@/types/escrow'; + +interface PartiesSectionProps { + escrow: IEscrowExtended; + userRole: 'creator' | 'counterparty' | null; +} + +const getRoleColor = (role: string) => { + switch (role.toUpperCase()) { + case 'BUYER': + return 'bg-blue-100 text-blue-800'; + case 'SELLER': + return 'bg-green-100 text-green-800'; + case 'ARBITRATOR': + return 'bg-purple-100 text-purple-800'; + default: + return 'bg-gray-100 text-gray-800'; + } +}; + +const getStatusColor = (status: string) => { + switch (status.toUpperCase()) { + case 'ACCEPTED': + return 'bg-green-100 text-green-800'; + case 'REJECTED': + return 'bg-red-100 text-red-800'; + case 'PENDING': + return 'bg-yellow-100 text-yellow-800'; + default: + return 'bg-gray-100 text-gray-800'; + } +}; + +const PartiesSection: React.FC = ({ escrow, userRole }: PartiesSectionProps) => { + return ( +
+

Parties

+ +
+ {escrow.parties.map((party: any) => ( +
+
+
+
+ + {party.role} + + + {party.status} + +
+

+ User ID: {party.userId} +

+
+ + {userRole === 'creator' && party.status === 'PENDING' && ( +
+ + +
+ )} +
+
+ ))} +
+ + {escrow.conditions && escrow.conditions.length > 0 && ( +
+

Conditions

+
    + {escrow.conditions.map((condition: any) => ( +
  • +
    + + + +
    +

    {condition.description}

    +
  • + ))} +
+
+ )} +
+ ); +}; + +export default PartiesSection; \ No newline at end of file diff --git a/apps/frontend/components/escrow/detail/TermsSection.tsx b/apps/frontend/components/escrow/detail/TermsSection.tsx new file mode 100644 index 0000000..fdcd16f --- /dev/null +++ b/apps/frontend/components/escrow/detail/TermsSection.tsx @@ -0,0 +1,102 @@ +import React, { useState, useEffect } from 'react'; +import { IEscrowExtended } from '@/types/escrow'; + +interface TermsSectionProps { + escrow: IEscrowExtended; + userRole: 'creator' | 'counterparty' | null; +} + +const TermsSection: React.FC = ({ escrow, userRole }: TermsSectionProps) => { + const [timeLeft, setTimeLeft] = useState(''); + + useEffect(() => { + if (escrow.expiresAt) { + const calculateTimeLeft = () => { + const expiryDate = new Date(escrow.expiresAt!); + const now = new Date(); + const difference = expiryDate.getTime() - now.getTime(); + + if (difference <= 0) { + return 'Expired'; + } + + const days = Math.floor(difference / (1000 * 60 * 60 * 24)); + const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)); + + return `${days}d ${hours}h ${minutes}m`; + }; + + setTimeLeft(calculateTimeLeft()); + const timer = setInterval(() => { + setTimeLeft(calculateTimeLeft()); + }, 60000); // Update every minute + + return () => clearInterval(timer); + } + }, [escrow.expiresAt]); + + return ( +
+

Terms & Actions

+ +
+
+

Agreement Details

+
+
+
Amount
+
{Number(escrow.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 7 })} {escrow.asset}
+
+
+
Type
+
{escrow.type}
+
+
+
Status
+
{escrow.status}
+
+ {escrow.expiresAt && ( +
+
Expires
+
+ {new Date(escrow.expiresAt).toLocaleDateString()} +
+ {timeLeft} +
+
+ )} +
+
+ + {/* Action buttons based on user role and escrow status */} +
+ {userRole === 'creator' && escrow.status === 'PENDING' && ( + + )} + + {userRole === 'counterparty' && escrow.status === 'ACTIVE' && ( + <> + + + + )} + + {(userRole === 'creator' || userRole === 'counterparty') && escrow.status === 'ACTIVE' && ( + + )} +
+
+
+ ); +}; + +export default TermsSection; \ No newline at end of file diff --git a/apps/frontend/components/escrow/detail/TimelineSection.tsx b/apps/frontend/components/escrow/detail/TimelineSection.tsx new file mode 100644 index 0000000..c1de3d5 --- /dev/null +++ b/apps/frontend/components/escrow/detail/TimelineSection.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { IEscrowExtended } from '@/types/escrow'; + +interface TimelineSectionProps { + escrow: IEscrowExtended; +} + +const getEventIcon = (eventType: string) => { + switch (eventType.toUpperCase()) { + case 'CREATED': + return ( + + + + + + ); + case 'FUNDED': + return ( + + + + + + ); + case 'CONDITION_MET': + return ( + + + + + + ); + case 'COMPLETED': + return ( + + + + + + ); + case 'CANCELLED': + return ( + + + + + + ); + default: + return ( + + + + + + ); + } +}; + +const getEventColor = (eventType: string) => { + switch (eventType.toUpperCase()) { + case 'CREATED': + return 'bg-blue-50 text-blue-700'; + case 'FUNDED': + return 'bg-green-50 text-green-700'; + case 'CONDITION_MET': + return 'bg-yellow-50 text-yellow-700'; + case 'COMPLETED': + return 'bg-purple-50 text-purple-700'; + case 'CANCELLED': + return 'bg-red-50 text-red-700'; + default: + return 'bg-gray-50 text-gray-700'; + } +}; + +const TimelineSection: React.FC = ({ escrow }: TimelineSectionProps) => { + // Sort events by date, with the newest first + const sortedEvents = [...escrow.events].sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + return ( +
+

Timeline

+ + {sortedEvents.length === 0 ? ( +

No events recorded yet.

+ ) : ( +
+
    + {sortedEvents.map((event, index) => ( +
  • +
    + {index !== sortedEvents.length - 1 ? ( +
    +
  • + ))} +
+
+ )} +
+ ); +}; + +export default TimelineSection; \ No newline at end of file diff --git a/apps/frontend/components/escrow/detail/TransactionHistory.tsx b/apps/frontend/components/escrow/detail/TransactionHistory.tsx new file mode 100644 index 0000000..add7469 --- /dev/null +++ b/apps/frontend/components/escrow/detail/TransactionHistory.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { IEscrowExtended } from '@/types/escrow'; + +interface TransactionHistoryProps { + escrow: IEscrowExtended; +} + +const TransactionHistory: React.FC = ({ escrow }: TransactionHistoryProps) => { + // Filter events that represent transactions + const transactionEvents = escrow.events.filter((event: any) => + event.eventType === 'FUNDED' || + event.eventType === 'CONDITION_MET' || + event.eventType === 'COMPLETED' || + event.eventType === 'CANCELLED' + ); + + return ( +
+

Transaction History

+ + {transactionEvents.length === 0 ? ( +

No transaction history available.

+ ) : ( +
+ + + + + + + + + + + + {transactionEvents.map((event: any) => ( + + + + + + + + ))} + +
+ Type + + Amount + + Status + + Date + + Transaction +
+
+ {event.eventType.replace(/_/g, ' ').toLowerCase()} +
+
+
+ {event.eventType === 'FUNDED' && `${Number(escrow.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 7 })} ${escrow.asset}`} + {event.eventType === 'COMPLETED' && `${Number(escrow.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 7 })} ${escrow.asset}`} + {event.eventType === 'CANCELLED' && '0 XLM'} + {event.eventType === 'CONDITION_MET' && 'Variable'} +
+
+ + {event.eventType === 'COMPLETED' ? 'Completed' : + event.eventType === 'CANCELLED' ? 'Cancelled' : 'Processed'} + + + {new Date(event.createdAt).toLocaleDateString()} + + {event.data?.transactionHash ? ( + + View Transaction + + ) : ( + N/A + )} +
+
+ )} +
+ ); +}; + +export default TransactionHistory; \ No newline at end of file diff --git a/apps/frontend/hooks/useEscrow.ts b/apps/frontend/hooks/useEscrow.ts new file mode 100644 index 0000000..f8a3c13 --- /dev/null +++ b/apps/frontend/hooks/useEscrow.ts @@ -0,0 +1,41 @@ +import { useState, useEffect } from 'react'; +import { IEscrowExtended, IUseEscrowReturn } from '@/types/escrow'; + +export const useEscrow = (id: string): IUseEscrowReturn => { + const [escrow, setEscrow] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchEscrow = async () => { + try { + setLoading(true); + const response = await fetch(`/api/escrows/${id}`); + + if (!response.ok) { + if (response.status === 404) { + setError('Escrow not found'); + } else { + setError('Failed to load escrow details'); + } + return; + } + + const data = await response.json(); + setEscrow(data); + setError(null); + } catch (err) { + setError('An error occurred while fetching escrow details'); + console.error('Error fetching escrow:', err); + } finally { + setLoading(false); + } + }; + + if (id) { + fetchEscrow(); + } + }, [id]); + + return { escrow, loading, error }; +}; \ No newline at end of file diff --git a/apps/frontend/hooks/useWallet.ts b/apps/frontend/hooks/useWallet.ts new file mode 100644 index 0000000..834a133 --- /dev/null +++ b/apps/frontend/hooks/useWallet.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; +import { IWalletHookReturn } from '@/types/escrow'; + +export const useWallet = (): IWalletHookReturn => { + const [connected, setConnected] = useState(false); + const [publicKey, setPublicKey] = useState(null); + + const connect = () => { + // Mock implementation - in real app this would connect to a wallet provider + setConnected(true); + setPublicKey('GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'); // Mock public key + }; + + return { + connected, + publicKey, + connect + }; +}; \ No newline at end of file diff --git a/apps/frontend/types/escrow.ts b/apps/frontend/types/escrow.ts index b42ab21..d324ebe 100644 --- a/apps/frontend/types/escrow.ts +++ b/apps/frontend/types/escrow.ts @@ -7,7 +7,7 @@ export interface IEscrow { creatorAddress: string; counterpartyAddress: string; deadline: string; // ISO date string - status: 'created' | 'funded' | 'confirmed' | 'released' | 'completed' | 'cancelled' | 'disputed'; + status: 'created' | 'funded' | 'confirmed' | 'released' | 'completed' | 'cancelled' | 'disputed' | 'PENDING' | 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'DISPUTED'; createdAt: string; // ISO date string updatedAt: string; // ISO date string milestones?: Array<{ @@ -18,6 +18,57 @@ export interface IEscrow { }>; } +export interface IParty { + id: string; + userId: string; + role: 'BUYER' | 'SELLER' | 'ARBITRATOR'; + status: 'PENDING' | 'ACCEPTED' | 'REJECTED'; + createdAt: string; +} + +export interface ICondition { + id: string; + description: string; + type: string; + metadata?: Record; +} + +export interface IEscrowEvent { + id: string; + eventType: 'CREATED' | 'PARTY_ADDED' | 'PARTY_ACCEPTED' | 'PARTY_REJECTED' | 'FUNDED' | 'CONDITION_MET' | 'STATUS_CHANGED' | 'UPDATED' | 'CANCELLED' | 'COMPLETED' | 'DISPUTED'; + actorId?: string; + data?: Record; + ipAddress?: string; + createdAt: string; +} + +// Extended IEscrow interface to match backend entities +export interface IEscrowExtended extends IEscrow { + type: 'STANDARD' | 'MILESTONE' | 'TIMED'; + creatorId: string; + expiresAt?: string; + isActive: boolean; + creator: { + id: string; + walletAddress?: string; + }; + parties: IParty[]; + conditions: ICondition[]; + events: IEscrowEvent[]; +} + +export interface IUseEscrowReturn { + escrow: IEscrowExtended | null; + loading: boolean; + error: string | null; +} + +export interface IWalletHookReturn { + connected: boolean; + publicKey: string | null; + connect: () => void; +} + export interface IEscrowResponse { escrows: IEscrow[]; hasNextPage: boolean;