feat(p2p): add Phase 3 dispute system components

- Add DisputeModal.tsx with reason selection, evidence upload, terms acceptance
- Add P2PDispute.tsx page with evidence gallery, status timeline, real-time updates
- Integrate dispute button in P2PTrade.tsx
- Add /p2p/dispute/:disputeId route to App.tsx
- Add P2P test suite with MockStore pattern (32 tests passing)
- Update P2P-BUILDING-PLAN.md with Phase 3 progress (70% complete)
- Fix lint errors in test files and components
This commit is contained in:
2025-12-11 09:10:04 +03:00
parent 8a602dc3fa
commit 7330b2e7a6
321 changed files with 5328 additions and 182 deletions
+608
View File
@@ -0,0 +1,608 @@
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertTriangle,
ArrowLeft,
Clock,
CheckCircle,
XCircle,
Upload,
FileText,
Download,
User,
MessageSquare,
Calendar,
Scale,
ChevronRight,
X,
} from 'lucide-react';
import { supabase } from '@/lib/supabase';
import { toast } from 'sonner';
import { formatAddress } from '@pezkuwi/utils/formatting';
interface DisputeDetails {
id: string;
trade_id: string;
opened_by: string;
reason: string;
description: string;
status: 'open' | 'under_review' | 'resolved' | 'closed';
resolution?: 'release_to_buyer' | 'refund_to_seller' | 'split';
resolution_notes?: string;
resolved_by?: string;
resolved_at?: string;
created_at: string;
trade?: {
id: string;
buyer_id: string;
seller_id: string;
buyer_wallet: string;
seller_wallet: string;
crypto_amount: string;
fiat_amount: string;
token: string;
fiat_currency: string;
status: string;
};
opener?: {
id: string;
email: string;
};
}
interface Evidence {
id: string;
dispute_id: string;
uploaded_by: string;
evidence_type: string;
file_url: string;
description: string;
created_at: string;
}
const STATUS_CONFIG: Record<string, { color: string; icon: React.ReactNode; label: string }> = {
open: {
color: 'bg-amber-500',
icon: <Clock className="h-4 w-4" />,
label: 'Open',
},
under_review: {
color: 'bg-blue-500',
icon: <Scale className="h-4 w-4" />,
label: 'Under Review',
},
resolved: {
color: 'bg-green-500',
icon: <CheckCircle className="h-4 w-4" />,
label: 'Resolved',
},
closed: {
color: 'bg-gray-500',
icon: <XCircle className="h-4 w-4" />,
label: 'Closed',
},
};
const RESOLUTION_LABELS: Record<string, string> = {
release_to_buyer: 'Released to Buyer',
refund_to_seller: 'Refunded to Seller',
split: 'Split Decision',
};
export default function P2PDispute() {
const { disputeId } = useParams<{ disputeId: string }>();
const navigate = useNavigate();
useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [dispute, setDispute] = useState<DisputeDetails | null>(null);
const [evidence, setEvidence] = useState<Evidence[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
useEffect(() => {
const fetchDispute = async () => {
if (!disputeId) return;
try {
// Get current user
const { data: { user } } = await supabase.auth.getUser();
setCurrentUserId(user?.id || null);
// Fetch dispute with trade info
const { data: disputeData, error: disputeError } = await supabase
.from('p2p_disputes')
.select(`
*,
trade:p2p_fiat_trades(
id, buyer_id, seller_id, buyer_wallet, seller_wallet,
crypto_amount, fiat_amount, token, fiat_currency, status
)
`)
.eq('id', disputeId)
.single();
if (disputeError) throw disputeError;
setDispute(disputeData);
// Fetch evidence
const { data: evidenceData, error: evidenceError } = await supabase
.from('p2p_dispute_evidence')
.select('*')
.eq('dispute_id', disputeId)
.order('created_at', { ascending: true });
if (!evidenceError && evidenceData) {
setEvidence(evidenceData);
}
} catch (error) {
console.error('Failed to fetch dispute:', error);
toast.error('Failed to load dispute details');
} finally {
setIsLoading(false);
}
};
fetchDispute();
// Subscribe to dispute updates
const channel = supabase
.channel(`dispute-${disputeId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'p2p_disputes',
filter: `id=eq.${disputeId}`,
},
(payload) => {
if (payload.new) {
setDispute((prev) => prev ? { ...prev, ...payload.new } : null);
}
}
)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'p2p_dispute_evidence',
filter: `dispute_id=eq.${disputeId}`,
},
(payload) => {
if (payload.new) {
setEvidence((prev) => [...prev, payload.new as Evidence]);
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [disputeId]);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0 || !dispute || !currentUserId) return;
setIsUploading(true);
try {
for (const file of Array.from(files)) {
if (file.size > 10 * 1024 * 1024) {
toast.error(`File ${file.name} is too large (max 10MB)`);
continue;
}
const fileName = `disputes/${dispute.id}/${Date.now()}-${file.name}`;
const { data, error } = await supabase.storage
.from('p2p-evidence')
.upload(fileName, file);
if (error) throw error;
const { data: urlData } = supabase.storage
.from('p2p-evidence')
.getPublicUrl(data.path);
// Insert evidence record
await supabase.from('p2p_dispute_evidence').insert({
dispute_id: dispute.id,
uploaded_by: currentUserId,
evidence_type: file.type.startsWith('image/') ? 'screenshot' : 'document',
file_url: urlData.publicUrl,
description: file.name,
});
}
toast.success('Evidence uploaded successfully');
} catch (error) {
console.error('Upload failed:', error);
toast.error('Failed to upload evidence');
} finally {
setIsUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const isParticipant = dispute?.trade &&
(dispute.trade.buyer_id === currentUserId || dispute.trade.seller_id === currentUserId);
const isBuyer = dispute?.trade?.buyer_id === currentUserId;
const isSeller = dispute?.trade?.seller_id === currentUserId;
const isOpener = dispute?.opened_by === currentUserId;
if (isLoading) {
return (
<div className="container max-w-4xl mx-auto p-4 space-y-4">
<Skeleton className="h-10 w-32" />
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
</div>
);
}
if (!dispute) {
return (
<div className="container max-w-4xl mx-auto p-4">
<Card>
<CardContent className="py-12 text-center">
<AlertTriangle className="h-12 w-12 mx-auto text-amber-500 mb-4" />
<h2 className="text-lg font-semibold mb-2">Dispute Not Found</h2>
<p className="text-muted-foreground mb-4">
The dispute you are looking for does not exist or you do not have access.
</p>
<Button onClick={() => navigate('/p2p/orders')}>
Go to My Orders
</Button>
</CardContent>
</Card>
</div>
);
}
const statusConfig = STATUS_CONFIG[dispute.status] || STATUS_CONFIG.open;
return (
<div className="container max-w-4xl mx-auto p-4 space-y-4">
{/* Back Button */}
<Button
variant="ghost"
onClick={() => navigate(-1)}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
{/* Header Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
Dispute #{dispute.id.slice(0, 8)}
</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">
<Calendar className="h-4 w-4" />
Opened {new Date(dispute.created_at).toLocaleDateString()}
</CardDescription>
</div>
<Badge className={`${statusConfig.color} text-white flex items-center gap-1`}>
{statusConfig.icon}
{statusConfig.label}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Trade Info Summary */}
{dispute.trade && (
<div className="bg-muted rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Related Trade</p>
<p className="font-medium">
{dispute.trade.crypto_amount} {dispute.trade.token} for{' '}
{dispute.trade.fiat_amount} {dispute.trade.fiat_currency}
</p>
</div>
<Link to={`/p2p/trade/${dispute.trade_id}`}>
<Button variant="outline" size="sm">
View Trade
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</Link>
</div>
<div className="grid grid-cols-2 gap-4 mt-4 text-sm">
<div>
<span className="text-muted-foreground">Buyer:</span>{' '}
<span className={isBuyer ? 'font-medium text-primary' : ''}>
{formatAddress(dispute.trade.buyer_wallet, 6, 4)}
{isBuyer && ' (You)'}
</span>
</div>
<div>
<span className="text-muted-foreground">Seller:</span>{' '}
<span className={isSeller ? 'font-medium text-primary' : ''}>
{formatAddress(dispute.trade.seller_wallet, 6, 4)}
{isSeller && ' (You)'}
</span>
</div>
</div>
</div>
)}
{/* Dispute Details */}
<div>
<h3 className="font-semibold mb-2 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Dispute Reason
</h3>
<Badge variant="outline" className="mb-2">
{dispute.reason.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
</Badge>
<p className="text-sm text-muted-foreground bg-muted p-3 rounded-lg">
{dispute.description}
</p>
<p className="text-xs text-muted-foreground mt-2">
Opened by: {isOpener ? 'You' : (isBuyer ? 'Seller' : 'Buyer')}
</p>
</div>
{/* Resolution (if resolved) */}
{dispute.status === 'resolved' && dispute.resolution && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<h3 className="font-semibold mb-2 flex items-center gap-2 text-green-700 dark:text-green-300">
<CheckCircle className="h-4 w-4" />
Resolution
</h3>
<p className="font-medium text-green-800 dark:text-green-200">
{RESOLUTION_LABELS[dispute.resolution]}
</p>
{dispute.resolution_notes && (
<p className="text-sm text-green-700 dark:text-green-300 mt-2">
{dispute.resolution_notes}
</p>
)}
{dispute.resolved_at && (
<p className="text-xs text-green-600 dark:text-green-400 mt-2">
Resolved on {new Date(dispute.resolved_at).toLocaleString()}
</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Evidence Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Evidence</CardTitle>
{isParticipant && dispute.status !== 'resolved' && (
<div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept="image/*,.pdf,.doc,.docx"
multiple
className="hidden"
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
<Upload className="h-4 w-4 mr-2" />
{isUploading ? 'Uploading...' : 'Add Evidence'}
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
{evidence.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No evidence submitted yet</p>
{isParticipant && dispute.status !== 'resolved' && (
<p className="text-sm mt-1">
Upload screenshots, receipts, or documents to support your case
</p>
)}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{evidence.map((item) => {
const isImage = item.evidence_type === 'screenshot' ||
item.file_url.match(/\.(jpg|jpeg|png|gif|webp)$/i);
const isMyEvidence = item.uploaded_by === currentUserId;
return (
<div
key={item.id}
className={`relative border rounded-lg overflow-hidden ${
isMyEvidence ? 'border-primary' : ''
}`}
>
{isImage ? (
<img
src={item.file_url}
alt={item.description}
className="w-full h-24 object-cover cursor-pointer hover:opacity-80 transition"
onClick={() => setSelectedImage(item.file_url)}
/>
) : (
<div className="w-full h-24 flex items-center justify-center bg-muted">
<FileText className="h-8 w-8 text-blue-500" />
</div>
)}
<div className="p-2 text-xs">
<p className="truncate font-medium">{item.description}</p>
<p className="text-muted-foreground">
{isMyEvidence ? 'You' : (
item.uploaded_by === dispute.trade?.buyer_id ? 'Buyer' : 'Seller'
)}
</p>
</div>
<a
href={item.file_url}
download
className="absolute top-1 right-1 p-1 bg-black/50 rounded text-white hover:bg-black/70"
>
<Download className="h-3 w-3" />
</a>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Status Timeline */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Status Timeline</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Opened */}
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-amber-500 flex items-center justify-center">
<Clock className="h-4 w-4 text-white" />
</div>
<div>
<p className="font-medium">Dispute Opened</p>
<p className="text-sm text-muted-foreground">
{new Date(dispute.created_at).toLocaleString()}
</p>
</div>
</div>
{/* Under Review (if applicable) */}
{(dispute.status === 'under_review' || dispute.status === 'resolved') && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center">
<Scale className="h-4 w-4 text-white" />
</div>
<div>
<p className="font-medium">Under Review</p>
<p className="text-sm text-muted-foreground">
Admin is reviewing the case
</p>
</div>
</div>
)}
{/* Resolved (if applicable) */}
{dispute.status === 'resolved' && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center">
<CheckCircle className="h-4 w-4 text-white" />
</div>
<div>
<p className="font-medium">Resolved</p>
<p className="text-sm text-muted-foreground">
{dispute.resolved_at && new Date(dispute.resolved_at).toLocaleString()}
</p>
</div>
</div>
)}
{/* Pending steps */}
{dispute.status === 'open' && (
<>
<div className="flex items-start gap-3 opacity-40">
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center">
<Scale className="h-4 w-4 text-white" />
</div>
<div>
<p className="font-medium">Under Review</p>
<p className="text-sm text-muted-foreground">Pending</p>
</div>
</div>
<div className="flex items-start gap-3 opacity-40">
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center">
<CheckCircle className="h-4 w-4 text-white" />
</div>
<div>
<p className="font-medium">Resolution</p>
<p className="text-sm text-muted-foreground">Pending</p>
</div>
</div>
</>
)}
</div>
</CardContent>
</Card>
{/* Help Card */}
<Card>
<CardContent className="py-4">
<div className="flex items-center gap-4">
<User className="h-10 w-10 text-muted-foreground" />
<div className="flex-1">
<h3 className="font-medium">Need Help?</h3>
<p className="text-sm text-muted-foreground">
Our support team typically responds within 24-48 hours.
</p>
</div>
<Button variant="outline" asChild>
<a href="mailto:support@pezkuwichain.io">Contact Support</a>
</Button>
</div>
</CardContent>
</Card>
{/* Image Lightbox */}
{selectedImage && (
<div
className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4"
onClick={() => setSelectedImage(null)}
>
<button
className="absolute top-4 right-4 text-white hover:text-gray-300"
onClick={() => setSelectedImage(null)}
>
<X className="h-8 w-8" />
</button>
<img
src={selectedImage}
alt="Evidence"
className="max-w-full max-h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
);
}
+360
View File
@@ -0,0 +1,360 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
ArrowLeft,
Clock,
CheckCircle2,
XCircle,
AlertTriangle,
Loader2,
ArrowUpRight,
ArrowDownLeft,
RefreshCw,
FileText,
} from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { toast } from 'sonner';
import { supabase } from '@/lib/supabase';
import { type P2PFiatTrade, type P2PFiatOffer } from '@shared/lib/p2p-fiat';
// Trade status type
type TradeStatus = 'pending' | 'payment_sent' | 'completed' | 'cancelled' | 'disputed' | 'refunded';
// Extended trade with offer details
interface TradeWithOffer extends P2PFiatTrade {
offer?: P2PFiatOffer;
}
export default function P2POrders() {
const navigate = useNavigate();
const { user } = useAuth();
const [trades, setTrades] = useState<TradeWithOffer[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('active');
// Fetch user's trades
const fetchTrades = async () => {
if (!user) {
setLoading(false);
return;
}
setLoading(true);
try {
// Fetch all trades where user is buyer or seller
const { data: tradesData, error } = await supabase
.from('p2p_fiat_trades')
.select('*')
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
.order('created_at', { ascending: false });
if (error) throw error;
// Fetch offer details for each trade
const tradesWithOffers = await Promise.all(
(tradesData || []).map(async (trade) => {
const { data: offerData } = await supabase
.from('p2p_fiat_offers')
.select('*')
.eq('id', trade.offer_id)
.single();
return {
...trade,
offer: offerData || undefined,
};
})
);
setTrades(tradesWithOffers);
} catch (error) {
console.error('Fetch trades error:', error);
toast.error('Failed to load orders');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTrades();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
// Filter trades by status
const activeTrades = trades.filter(t =>
['pending', 'payment_sent', 'disputed'].includes(t.status)
);
const completedTrades = trades.filter(t => t.status === 'completed');
const cancelledTrades = trades.filter(t =>
['cancelled', 'refunded'].includes(t.status)
);
// Get status badge
const getStatusBadge = (status: TradeStatus) => {
switch (status) {
case 'pending':
return (
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30">
<Clock className="w-3 h-3 mr-1" />
Awaiting Payment
</Badge>
);
case 'payment_sent':
return (
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30">
<Clock className="w-3 h-3 mr-1" />
Payment Sent
</Badge>
);
case 'completed':
return (
<Badge className="bg-green-500/20 text-green-400 border-green-500/30">
<CheckCircle2 className="w-3 h-3 mr-1" />
Completed
</Badge>
);
case 'cancelled':
return (
<Badge className="bg-gray-500/20 text-gray-400 border-gray-500/30">
<XCircle className="w-3 h-3 mr-1" />
Cancelled
</Badge>
);
case 'disputed':
return (
<Badge className="bg-red-500/20 text-red-400 border-red-500/30">
<AlertTriangle className="w-3 h-3 mr-1" />
Disputed
</Badge>
);
case 'refunded':
return (
<Badge className="bg-purple-500/20 text-purple-400 border-purple-500/30">
<RefreshCw className="w-3 h-3 mr-1" />
Refunded
</Badge>
);
default:
return <Badge>{status}</Badge>;
}
};
// Render trade card
const renderTradeCard = (trade: TradeWithOffer) => {
const isBuyer = trade.buyer_id === user?.id;
const counterpartyWallet = isBuyer
? trade.offer?.seller_wallet || 'Unknown'
: trade.buyer_wallet;
const timeAgo = (date: string) => {
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
if (seconds < 60) return 'Just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
};
return (
<Card
key={trade.id}
className="bg-gray-900 border-gray-800 hover:border-gray-700 transition-colors cursor-pointer"
onClick={() => navigate(`/p2p/trade/${trade.id}`)}
>
<CardContent className="p-4">
<div className="flex items-center gap-4">
{/* Direction Icon */}
<div className={`
w-10 h-10 rounded-full flex items-center justify-center
${isBuyer ? 'bg-green-500/20' : 'bg-red-500/20'}
`}>
{isBuyer ? (
<ArrowDownLeft className="w-5 h-5 text-green-400" />
) : (
<ArrowUpRight className="w-5 h-5 text-red-400" />
)}
</div>
{/* Trade Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`font-semibold ${isBuyer ? 'text-green-400' : 'text-red-400'}`}>
{isBuyer ? 'Buy' : 'Sell'}
</span>
<span className="text-white font-medium">
{trade.crypto_amount} {trade.offer?.token || 'HEZ'}
</span>
{getStatusBadge(trade.status as TradeStatus)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Avatar className="h-5 w-5">
<AvatarFallback className="bg-gray-700 text-xs">
{counterpartyWallet.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="truncate">
{counterpartyWallet.slice(0, 8)}...{counterpartyWallet.slice(-4)}
</span>
<span className="text-gray-500"></span>
<span>{timeAgo(trade.created_at)}</span>
</div>
</div>
{/* Amount */}
<div className="text-right">
<p className="text-lg font-semibold text-white">
{trade.fiat_amount.toFixed(2)} {trade.offer?.fiat_currency || 'TRY'}
</p>
<p className="text-sm text-gray-400">
@ {trade.price_per_unit.toFixed(2)}/{trade.offer?.token || 'HEZ'}
</p>
</div>
</div>
{/* Deadline warning for pending trades */}
{trade.status === 'pending' && trade.payment_deadline && (
<div className="mt-3 pt-3 border-t border-gray-800">
<div className="flex items-center gap-2 text-yellow-400 text-sm">
<Clock className="w-4 h-4" />
<span>
Payment deadline: {new Date(trade.payment_deadline).toLocaleTimeString()}
</span>
</div>
</div>
)}
</CardContent>
</Card>
);
};
// Render empty state
const renderEmptyState = (message: string) => (
<div className="text-center py-12">
<FileText className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400">{message}</p>
</div>
);
if (!user) {
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-12 text-center">
<AlertTriangle className="w-16 h-16 text-yellow-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Login Required</h2>
<p className="text-gray-400 mb-6">Please log in to view your P2P orders.</p>
<Button onClick={() => navigate('/login')}>Log In</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/p2p')}
className="text-gray-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
P2P Trading
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={fetchTrades}
disabled={loading}
className="text-gray-400 hover:text-white"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* Title */}
<div className="mb-6">
<h1 className="text-3xl font-bold text-white">My Orders</h1>
<p className="text-gray-400">View and manage your P2P trades</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-4 mb-6">
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-4 text-center">
<p className="text-2xl font-bold text-yellow-400">{activeTrades.length}</p>
<p className="text-sm text-gray-400">Active</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-4 text-center">
<p className="text-2xl font-bold text-green-400">{completedTrades.length}</p>
<p className="text-sm text-gray-400">Completed</p>
</CardContent>
</Card>
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-4 text-center">
<p className="text-2xl font-bold text-gray-400">{cancelledTrades.length}</p>
<p className="text-sm text-gray-400">Cancelled</p>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsTrigger value="active" className="relative">
Active
{activeTrades.length > 0 && (
<span className="ml-2 px-1.5 py-0.5 text-xs bg-yellow-500 text-black rounded-full">
{activeTrades.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="completed">Completed</TabsTrigger>
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
</TabsList>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
</div>
) : (
<>
<TabsContent value="active" className="space-y-4">
{activeTrades.length === 0
? renderEmptyState('No active trades')
: activeTrades.map(renderTradeCard)
}
</TabsContent>
<TabsContent value="completed" className="space-y-4">
{completedTrades.length === 0
? renderEmptyState('No completed trades')
: completedTrades.map(renderTradeCard)
}
</TabsContent>
<TabsContent value="cancelled" className="space-y-4">
{cancelledTrades.length === 0
? renderEmptyState('No cancelled trades')
: cancelledTrades.map(renderTradeCard)
}
</TabsContent>
</>
)}
</Tabs>
</div>
);
}
+877
View File
@@ -0,0 +1,877 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import {
ArrowLeft,
Clock,
CheckCircle2,
XCircle,
AlertTriangle,
Loader2,
Copy,
Upload,
Shield,
Zap,
MessageSquare,
Ban,
ExternalLink,
RefreshCw,
Star,
} from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { toast } from 'sonner';
import { supabase } from '@/lib/supabase';
import {
markPaymentSent,
confirmPaymentReceived,
getUserReputation,
type P2PFiatTrade,
type P2PFiatOffer,
type P2PReputation,
} from '@shared/lib/p2p-fiat';
import { TradeChat } from '@/components/p2p/TradeChat';
import { RatingModal } from '@/components/p2p/RatingModal';
import { DisputeModal } from '@/components/p2p/DisputeModal';
// Trade status type
type TradeStatus = 'pending' | 'payment_sent' | 'completed' | 'cancelled' | 'disputed' | 'refunded';
// Extended trade with offer details
interface TradeWithDetails extends P2PFiatTrade {
offer?: P2PFiatOffer;
seller_reputation?: P2PReputation;
buyer_reputation?: P2PReputation;
payment_method_name?: string;
payment_details?: Record<string, string>;
}
// Timeline step interface
interface TimelineStep {
id: string;
label: string;
description: string;
status: 'completed' | 'current' | 'pending';
timestamp?: string;
}
export default function P2PTrade() {
const { tradeId } = useParams<{ tradeId: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { api, selectedAccount } = usePolkadot();
const [trade, setTrade] = useState<TradeWithDetails | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [timeRemaining, setTimeRemaining] = useState<number>(0);
const [showCancelModal, setShowCancelModal] = useState(false);
const [showProofModal, setShowProofModal] = useState(false);
const [showRatingModal, setShowRatingModal] = useState(false);
const [showDisputeModal, setShowDisputeModal] = useState(false);
const [showChat, setShowChat] = useState(false);
const [paymentProof, setPaymentProof] = useState<File | null>(null);
const [paymentReference, setPaymentReference] = useState('');
const [cancelReason, setCancelReason] = useState('');
// Determine user role
const isSeller = trade?.seller_id === user?.id;
const isBuyer = trade?.buyer_id === user?.id;
const isParticipant = isSeller || isBuyer;
// Fetch trade details
const fetchTrade = useCallback(async () => {
if (!tradeId) return;
try {
// Fetch trade
const { data: tradeData, error: tradeError } = await supabase
.from('p2p_fiat_trades')
.select('*')
.eq('id', tradeId)
.single();
if (tradeError) throw tradeError;
if (!tradeData) throw new Error('Trade not found');
// Fetch offer details
const { data: offerData } = await supabase
.from('p2p_fiat_offers')
.select('*')
.eq('id', tradeData.offer_id)
.single();
// Fetch payment method
let paymentMethodName = 'Unknown';
let paymentDetails: Record<string, string> = {};
if (offerData?.payment_method_id) {
const { data: methodData } = await supabase
.from('payment_methods')
.select('method_name')
.eq('id', offerData.payment_method_id)
.single();
paymentMethodName = methodData?.method_name || 'Unknown';
// Decrypt payment details for buyer (only after trade starts)
if (offerData.payment_details_encrypted && tradeData.status !== 'cancelled') {
try {
paymentDetails = JSON.parse(atob(offerData.payment_details_encrypted));
} catch {
paymentDetails = {};
}
}
}
// Fetch reputations
const [sellerRep, buyerRep] = await Promise.all([
getUserReputation(tradeData.seller_id),
getUserReputation(tradeData.buyer_id),
]);
setTrade({
...tradeData,
offer: offerData || undefined,
seller_reputation: sellerRep || undefined,
buyer_reputation: buyerRep || undefined,
payment_method_name: paymentMethodName,
payment_details: paymentDetails,
});
} catch (error) {
console.error('Fetch trade error:', error);
toast.error('Failed to load trade details');
} finally {
setLoading(false);
}
}, [tradeId]);
// Initial fetch
useEffect(() => {
fetchTrade();
}, [fetchTrade]);
// Real-time subscription
useEffect(() => {
if (!tradeId) return;
const channel = supabase
.channel(`trade-${tradeId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'p2p_fiat_trades',
filter: `id=eq.${tradeId}`,
},
() => {
fetchTrade();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [tradeId, fetchTrade]);
// Countdown timer
useEffect(() => {
if (!trade?.payment_deadline || trade.status !== 'pending') {
setTimeRemaining(0);
return;
}
const deadline = new Date(trade.payment_deadline).getTime();
const updateTimer = () => {
const now = Date.now();
const remaining = Math.max(0, Math.floor((deadline - now) / 1000));
setTimeRemaining(remaining);
if (remaining === 0 && trade.status === 'pending') {
// Auto-cancel logic could go here
toast.warning('Payment deadline expired');
}
};
updateTimer();
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [trade?.payment_deadline, trade?.status]);
// Format time remaining
const formatTimeRemaining = (seconds: number): string => {
if (seconds <= 0) return 'Expired';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Get timeline steps
const getTimelineSteps = (): TimelineStep[] => {
const status = trade?.status as TradeStatus;
const steps: TimelineStep[] = [
{
id: 'created',
label: 'Order Created',
description: 'Trade initiated',
status: 'completed',
timestamp: trade?.created_at,
},
{
id: 'pending',
label: 'Awaiting Payment',
description: isBuyer ? 'Send payment to seller' : 'Waiting for buyer payment',
status: status === 'pending' ? 'current' :
['payment_sent', 'completed'].includes(status) ? 'completed' : 'pending',
},
{
id: 'payment_sent',
label: 'Payment Sent',
description: isSeller ? 'Verify and release crypto' : 'Waiting for confirmation',
status: status === 'payment_sent' ? 'current' :
status === 'completed' ? 'completed' : 'pending',
timestamp: trade?.buyer_marked_paid_at,
},
{
id: 'completed',
label: 'Completed',
description: 'Crypto released to buyer',
status: status === 'completed' ? 'completed' : 'pending',
timestamp: trade?.completed_at,
},
];
return steps;
};
// Handle mark as paid
const handleMarkAsPaid = async () => {
if (!trade || !user) return;
setActionLoading(true);
try {
await markPaymentSent(trade.id, paymentProof || undefined);
// Update payment reference if provided
if (paymentReference) {
await supabase
.from('p2p_fiat_trades')
.update({ buyer_payment_reference: paymentReference })
.eq('id', trade.id);
}
setShowProofModal(false);
setPaymentProof(null);
setPaymentReference('');
toast.success('Payment marked as sent');
fetchTrade();
} catch (error) {
console.error('Mark as paid error:', error);
} finally {
setActionLoading(false);
}
};
// Handle release crypto
const handleReleaseCrypto = async () => {
if (!trade || !api || !selectedAccount) {
toast.error('Please connect your wallet');
return;
}
setActionLoading(true);
try {
await confirmPaymentReceived(api, selectedAccount, trade.id);
toast.success('Crypto released to buyer!');
fetchTrade();
} catch (error) {
console.error('Release crypto error:', error);
} finally {
setActionLoading(false);
}
};
// Handle cancel trade
const handleCancelTrade = async () => {
if (!trade) return;
setActionLoading(true);
try {
const { error } = await supabase
.from('p2p_fiat_trades')
.update({
status: 'cancelled',
cancelled_by: user?.id,
cancel_reason: cancelReason,
})
.eq('id', trade.id);
if (error) throw error;
// Restore offer remaining amount
await supabase
.from('p2p_fiat_offers')
.update({
remaining_amount: (trade.offer?.remaining_amount || 0) + trade.crypto_amount,
status: 'open',
})
.eq('id', trade.offer_id);
setShowCancelModal(false);
toast.success('Trade cancelled');
fetchTrade();
} catch (error) {
console.error('Cancel trade error:', error);
toast.error('Failed to cancel trade');
} finally {
setActionLoading(false);
}
};
// Copy to clipboard
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.success(`${label} copied!`);
};
// Render loading state
if (loading) {
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
</div>
</div>
);
}
// Render not found
if (!trade) {
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<Card className="bg-gray-900 border-gray-800">
<CardContent className="py-12 text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Trade Not Found</h2>
<p className="text-gray-400 mb-6">This trade does not exist or you do not have access.</p>
<Button onClick={() => navigate('/p2p')}>Back to P2P</Button>
</CardContent>
</Card>
</div>
);
}
// Get status color
const getStatusColor = (status: TradeStatus) => {
switch (status) {
case 'pending': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
case 'payment_sent': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case 'completed': return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'cancelled': return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
case 'disputed': return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'refunded': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/30';
}
};
const counterparty = isSeller ?
{ id: trade.buyer_id, wallet: trade.buyer_wallet, reputation: trade.buyer_reputation, label: 'Buyer' } :
{ id: trade.seller_id, wallet: trade.offer?.seller_wallet || '', reputation: trade.seller_reputation, label: 'Seller' };
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/p2p/orders')}
className="text-gray-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
My Orders
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={fetchTrade}
className="text-gray-400 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
{/* Trade Header Card */}
<Card className="bg-gray-900 border-gray-800 mb-6">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<CardTitle className="text-white">
{isBuyer ? 'Buy' : 'Sell'} {trade.offer?.token || 'HEZ'}
</CardTitle>
<Badge className={getStatusColor(trade.status as TradeStatus)}>
{trade.status.replace('_', ' ').toUpperCase()}
</Badge>
</div>
{trade.status === 'pending' && timeRemaining > 0 && (
<div className="flex items-center gap-2 text-yellow-400">
<Clock className="w-5 h-5" />
<span className="text-xl font-mono font-bold">
{formatTimeRemaining(timeRemaining)}
</span>
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-gray-400">Amount</p>
<p className="text-lg font-semibold text-white">
{trade.crypto_amount} {trade.offer?.token || 'HEZ'}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Price</p>
<p className="text-lg font-semibold text-green-400">
{trade.fiat_amount.toFixed(2)} {trade.offer?.fiat_currency || 'TRY'}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Unit Price</p>
<p className="text-lg font-semibold text-white">
{trade.price_per_unit.toFixed(2)} {trade.offer?.fiat_currency || 'TRY'}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Payment Method</p>
<p className="text-lg font-semibold text-white">
{trade.payment_method_name}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Timeline */}
<Card className="bg-gray-900 border-gray-800 mb-6">
<CardHeader>
<CardTitle className="text-white text-lg">Trade Progress</CardTitle>
</CardHeader>
<CardContent>
<div className="relative">
{/* Timeline line */}
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-700" />
{/* Steps */}
<div className="space-y-6">
{getTimelineSteps().map((step, index) => (
<div key={step.id} className="relative flex items-start gap-4">
{/* Icon */}
<div className={`
relative z-10 w-8 h-8 rounded-full flex items-center justify-center
${step.status === 'completed' ? 'bg-green-500' :
step.status === 'current' ? 'bg-yellow-500' : 'bg-gray-700'}
`}>
{step.status === 'completed' ? (
<CheckCircle2 className="w-5 h-5 text-white" />
) : step.status === 'current' ? (
<Clock className="w-5 h-5 text-white" />
) : (
<span className="text-gray-400 text-sm">{index + 1}</span>
)}
</div>
{/* Content */}
<div className="flex-1 pb-2">
<p className={`font-medium ${
step.status === 'completed' ? 'text-green-400' :
step.status === 'current' ? 'text-yellow-400' : 'text-gray-500'
}`}>
{step.label}
</p>
<p className="text-sm text-gray-400">{step.description}</p>
{step.timestamp && (
<p className="text-xs text-gray-500 mt-1">
{new Date(step.timestamp).toLocaleString()}
</p>
)}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Counterparty Info */}
<Card className="bg-gray-900 border-gray-800 mb-6">
<CardHeader>
<CardTitle className="text-white text-lg">{counterparty.label} Information</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<Avatar className="h-14 w-14">
<AvatarFallback className="bg-green-500/20 text-green-400 text-lg">
{counterparty.wallet.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-semibold text-white">
{counterparty.wallet.slice(0, 8)}...{counterparty.wallet.slice(-6)}
</p>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => copyToClipboard(counterparty.wallet, 'Address')}
>
<Copy className="w-3 h-3" />
</Button>
{counterparty.reputation?.verified_merchant && (
<Shield className="w-4 h-4 text-blue-400" title="Verified Merchant" />
)}
{counterparty.reputation?.fast_trader && (
<Zap className="w-4 h-4 text-yellow-400" title="Fast Trader" />
)}
</div>
{counterparty.reputation && (
<p className="text-sm text-gray-400">
{counterparty.reputation.completed_trades} trades
{' '}{((counterparty.reputation.completed_trades / (counterparty.reputation.total_trades || 1)) * 100).toFixed(0)}% completion
</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Payment Details (Buyer only, when trade is active) */}
{isBuyer && trade.status === 'pending' && trade.payment_details && Object.keys(trade.payment_details).length > 0 && (
<Card className="bg-gray-900 border-gray-800 mb-6">
<CardHeader>
<CardTitle className="text-white text-lg">Payment Details</CardTitle>
</CardHeader>
<CardContent>
<Alert className="bg-yellow-500/10 border-yellow-500/30 mb-4">
<AlertTriangle className="h-4 w-4 text-yellow-400" />
<AlertDescription className="text-yellow-200">
Send exactly <strong>{trade.fiat_amount.toFixed(2)} {trade.offer?.fiat_currency}</strong> to the account below.
Do not include any cryptocurrency references in your payment.
</AlertDescription>
</Alert>
<div className="space-y-3">
{Object.entries(trade.payment_details).map(([key, value]) => (
<div key={key} className="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
<div>
<p className="text-sm text-gray-400">
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</p>
<p className="text-white font-medium">{value}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(value, key)}
>
<Copy className="w-4 h-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Payment Proof (if uploaded) */}
{trade.buyer_payment_proof_url && (
<Card className="bg-gray-900 border-gray-800 mb-6">
<CardHeader>
<CardTitle className="text-white text-lg">Payment Proof</CardTitle>
</CardHeader>
<CardContent>
<a
href={trade.buyer_payment_proof_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-blue-400 hover:text-blue-300"
>
<ExternalLink className="w-4 h-4" />
View Payment Proof
</a>
</CardContent>
</Card>
)}
{/* Action Buttons */}
{isParticipant && !['completed', 'cancelled', 'refunded'].includes(trade.status) && (
<Card className="bg-gray-900 border-gray-800 mb-6">
<CardContent className="pt-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* Buyer Actions */}
{isBuyer && trade.status === 'pending' && (
<>
<Button
className="flex-1 bg-green-600 hover:bg-green-700"
onClick={() => setShowProofModal(true)}
disabled={actionLoading}
>
<CheckCircle2 className="w-4 h-4 mr-2" />
I Have Paid
</Button>
<Button
variant="outline"
className="flex-1 border-gray-700"
onClick={() => setShowCancelModal(true)}
disabled={actionLoading}
>
<Ban className="w-4 h-4 mr-2" />
Cancel Trade
</Button>
</>
)}
{/* Seller Actions */}
{isSeller && trade.status === 'payment_sent' && (
<>
<Button
className="flex-1 bg-green-600 hover:bg-green-700"
onClick={handleReleaseCrypto}
disabled={actionLoading}
>
{actionLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 mr-2" />
)}
Release Crypto
</Button>
<Button
variant="outline"
className="flex-1 border-red-500/50 text-red-400 hover:bg-red-500/10"
onClick={() => setShowDisputeModal(true)}
disabled={actionLoading}
>
<AlertTriangle className="w-4 h-4 mr-2" />
Open Dispute
</Button>
</>
)}
{/* Chat Button */}
<Button
variant="outline"
className="border-gray-700"
onClick={() => setShowChat(!showChat)}
>
<MessageSquare className="w-4 h-4 mr-2" />
{showChat ? 'Hide Chat' : 'Chat'}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Chat Section */}
{showChat && isParticipant && (
<div className="mb-6">
<TradeChat
tradeId={tradeId!}
counterpartyId={counterparty.id}
counterpartyWallet={counterparty.wallet}
isTradeActive={!['completed', 'cancelled', 'refunded'].includes(trade.status)}
/>
</div>
)}
{/* Completed Message */}
{trade.status === 'completed' && (
<Card className="bg-green-500/10 border-green-500/30 mb-6">
<CardContent className="py-6 text-center">
<CheckCircle2 className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-green-400 mb-2">Trade Completed!</h3>
<p className="text-gray-400 mb-4">
{isBuyer
? `You received ${trade.crypto_amount} ${trade.offer?.token}`
: `You received ${trade.fiat_amount.toFixed(2)} ${trade.offer?.fiat_currency}`
}
</p>
<Button
onClick={() => setShowRatingModal(true)}
className="bg-yellow-500 hover:bg-yellow-600 text-black"
>
<Star className="w-4 h-4 mr-2" />
Rate This Trade
</Button>
</CardContent>
</Card>
)}
{/* Cancelled Message */}
{trade.status === 'cancelled' && (
<Card className="bg-gray-500/10 border-gray-500/30 mb-6">
<CardContent className="py-6 text-center">
<XCircle className="w-16 h-16 text-gray-500 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-400 mb-2">Trade Cancelled</h3>
{trade.cancel_reason && (
<p className="text-gray-500">Reason: {trade.cancel_reason}</p>
)}
</CardContent>
</Card>
)}
{/* Mark as Paid Modal */}
<Dialog open={showProofModal} onOpenChange={setShowProofModal}>
<DialogContent className="bg-gray-900 border-gray-800">
<DialogHeader>
<DialogTitle className="text-white">Confirm Payment</DialogTitle>
<DialogDescription className="text-gray-400">
Please confirm you have sent the payment to the seller.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="reference">Payment Reference (Optional)</Label>
<Input
id="reference"
value={paymentReference}
onChange={(e) => setPaymentReference(e.target.value)}
placeholder="Transaction ID or reference number"
className="bg-gray-800 border-gray-700"
/>
</div>
<div>
<Label>Payment Proof (Optional)</Label>
<div className="mt-2">
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-700 border-dashed rounded-lg cursor-pointer hover:bg-gray-800">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Upload className="w-8 h-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-400">
{paymentProof ? paymentProof.name : 'Click to upload screenshot'}
</p>
</div>
<input
type="file"
className="hidden"
accept="image/*"
onChange={(e) => setPaymentProof(e.target.files?.[0] || null)}
/>
</label>
</div>
</div>
<Alert className="bg-yellow-500/10 border-yellow-500/30">
<AlertTriangle className="h-4 w-4 text-yellow-400" />
<AlertDescription className="text-yellow-200">
Only click confirm after you have actually sent the payment.
Falsely marking payment as sent may result in account suspension.
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowProofModal(false)}
disabled={actionLoading}
className="border-gray-700"
>
Cancel
</Button>
<Button
onClick={handleMarkAsPaid}
disabled={actionLoading}
className="bg-green-600 hover:bg-green-700"
>
{actionLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 mr-2" />
)}
Confirm Payment Sent
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Cancel Trade Modal */}
<Dialog open={showCancelModal} onOpenChange={setShowCancelModal}>
<DialogContent className="bg-gray-900 border-gray-800">
<DialogHeader>
<DialogTitle className="text-white">Cancel Trade</DialogTitle>
<DialogDescription className="text-gray-400">
Are you sure you want to cancel this trade? The crypto will be returned to the seller.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="cancelReason">Reason for cancellation</Label>
<Input
id="cancelReason"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="Optional reason"
className="bg-gray-800 border-gray-700 mt-2"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCancelModal(false)}
disabled={actionLoading}
className="border-gray-700"
>
Keep Trade
</Button>
<Button
variant="destructive"
onClick={handleCancelTrade}
disabled={actionLoading}
>
{actionLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<XCircle className="w-4 h-4 mr-2" />
)}
Cancel Trade
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rating Modal */}
<RatingModal
isOpen={showRatingModal}
onClose={() => setShowRatingModal(false)}
tradeId={tradeId!}
counterpartyId={counterparty.id}
counterpartyWallet={counterparty.wallet}
isBuyer={isBuyer}
/>
{/* Dispute Modal */}
<DisputeModal
isOpen={showDisputeModal}
onClose={() => setShowDisputeModal(false)}
tradeId={tradeId!}
counterpartyId={counterparty.id}
counterpartyWallet={counterparty.wallet}
isBuyer={isBuyer}
/>
</div>
);
}