mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 18:17:58 +00:00
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:
@@ -0,0 +1,414 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { AlertTriangle, Upload, X, FileText } from 'lucide-react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface DisputeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tradeId: string;
|
||||
counterpartyId: string;
|
||||
counterpartyWallet: string;
|
||||
isBuyer: boolean;
|
||||
}
|
||||
|
||||
interface EvidenceFile {
|
||||
id: string;
|
||||
file: File;
|
||||
preview?: string;
|
||||
type: 'image' | 'document';
|
||||
}
|
||||
|
||||
const DISPUTE_REASONS = [
|
||||
{ value: 'payment_not_received', label: 'Payment not received' },
|
||||
{ value: 'wrong_amount', label: 'Wrong amount received' },
|
||||
{ value: 'seller_not_responding', label: 'Seller not responding' },
|
||||
{ value: 'buyer_not_responding', label: 'Buyer not responding' },
|
||||
{ value: 'fraudulent_behavior', label: 'Fraudulent behavior' },
|
||||
{ value: 'fake_payment_proof', label: 'Fake payment proof' },
|
||||
{ value: 'account_mismatch', label: 'Payment account name mismatch' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
export function DisputeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
tradeId,
|
||||
counterpartyId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
counterpartyWallet,
|
||||
isBuyer,
|
||||
}: DisputeModalProps) {
|
||||
useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [reason, setReason] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [evidenceFiles, setEvidenceFiles] = useState<EvidenceFile[]>([]);
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Filter reasons based on role
|
||||
const availableReasons = DISPUTE_REASONS.filter((r) => {
|
||||
if (isBuyer) {
|
||||
return r.value !== 'buyer_not_responding' && r.value !== 'payment_not_received';
|
||||
} else {
|
||||
return r.value !== 'seller_not_responding' && r.value !== 'fake_payment_proof';
|
||||
}
|
||||
});
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
const newFiles: EvidenceFile[] = [];
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
if (evidenceFiles.length + newFiles.length >= 5) {
|
||||
toast.error('Maximum 5 evidence files allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error(`File ${file.name} is too large (max 10MB)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const evidence: EvidenceFile = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file,
|
||||
type: isImage ? 'image' : 'document',
|
||||
};
|
||||
|
||||
if (isImage) {
|
||||
evidence.preview = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
newFiles.push(evidence);
|
||||
});
|
||||
|
||||
setEvidenceFiles((prev) => [...prev, ...newFiles]);
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const removeEvidence = (id: string) => {
|
||||
setEvidenceFiles((prev) => {
|
||||
const file = prev.find((f) => f.id === id);
|
||||
if (file?.preview) {
|
||||
URL.revokeObjectURL(file.preview);
|
||||
}
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
};
|
||||
|
||||
const uploadEvidence = async (disputeId: string): Promise<string[]> => {
|
||||
const uploadedUrls: string[] = [];
|
||||
|
||||
for (const evidence of evidenceFiles) {
|
||||
const fileName = `disputes/${disputeId}/${evidence.id}-${evidence.file.name}`;
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from('p2p-evidence')
|
||||
.upload(fileName, evidence.file);
|
||||
|
||||
if (error) {
|
||||
console.error('Evidence upload failed:', error);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data: urlData } = supabase.storage
|
||||
.from('p2p-evidence')
|
||||
.getPublicUrl(data.path);
|
||||
|
||||
uploadedUrls.push(urlData.publicUrl);
|
||||
}
|
||||
|
||||
return uploadedUrls;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reason) {
|
||||
toast.error('Please select a reason');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description || description.length < 20) {
|
||||
toast.error('Please provide a detailed description (at least 20 characters)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!termsAccepted) {
|
||||
toast.error('Please accept the terms and conditions');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
// Create dispute
|
||||
const { data: dispute, error: disputeError } = await supabase
|
||||
.from('p2p_disputes')
|
||||
.insert({
|
||||
trade_id: tradeId,
|
||||
opened_by: user.id,
|
||||
reason,
|
||||
description,
|
||||
status: 'open',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (disputeError) throw disputeError;
|
||||
|
||||
// Upload evidence files
|
||||
if (evidenceFiles.length > 0) {
|
||||
const evidenceUrls = await uploadEvidence(dispute.id);
|
||||
|
||||
// Insert evidence records
|
||||
const evidenceRecords = evidenceUrls.map((url, index) => ({
|
||||
dispute_id: dispute.id,
|
||||
uploaded_by: user.id,
|
||||
evidence_type: evidenceFiles[index].type === 'image' ? 'screenshot' : 'document',
|
||||
file_url: url,
|
||||
description: `Evidence ${index + 1}`,
|
||||
}));
|
||||
|
||||
await supabase.from('p2p_dispute_evidence').insert(evidenceRecords);
|
||||
}
|
||||
|
||||
// Update trade status to disputed
|
||||
await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.update({ status: 'disputed' })
|
||||
.eq('id', tradeId);
|
||||
|
||||
// Create notification for counterparty
|
||||
await supabase.from('p2p_notifications').insert({
|
||||
user_id: counterpartyId,
|
||||
type: 'dispute_opened',
|
||||
title: 'Dispute Opened',
|
||||
message: `A dispute has been opened for your trade. Reason: ${reason}`,
|
||||
reference_type: 'dispute',
|
||||
reference_id: dispute.id,
|
||||
});
|
||||
|
||||
// Create notification for admin (user-100 / platform admin)
|
||||
// In production, this would be a specific admin role
|
||||
await supabase.from('p2p_notifications').insert({
|
||||
user_id: counterpartyId, // TODO: Replace with actual admin user ID
|
||||
type: 'dispute_opened',
|
||||
title: 'New Dispute Requires Attention',
|
||||
message: `Dispute #${dispute.id.slice(0, 8)} opened. Trade: ${tradeId.slice(0, 8)}`,
|
||||
reference_type: 'dispute',
|
||||
reference_id: dispute.id,
|
||||
});
|
||||
|
||||
toast.success('Dispute opened successfully');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to open dispute:', error);
|
||||
toast.error('Failed to open dispute. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Cleanup previews
|
||||
evidenceFiles.forEach((f) => {
|
||||
if (f.preview) URL.revokeObjectURL(f.preview);
|
||||
});
|
||||
setReason('');
|
||||
setDescription('');
|
||||
setEvidenceFiles([]);
|
||||
setTermsAccepted(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-500">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Open Dispute
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please provide details about the issue. Our support team will review your case
|
||||
and contact both parties for resolution.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Reason Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason">Reason for Dispute *</Label>
|
||||
<Select value={reason} onValueChange={setReason}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a reason..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableReasons.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">
|
||||
Detailed Description * <span className="text-muted-foreground text-xs">(min 20 chars)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Please describe the issue in detail. Include relevant transaction IDs, timestamps, and any communication with the counterparty..."
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{description.length}/2000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Evidence Upload */}
|
||||
<div className="space-y-2">
|
||||
<Label>Evidence (Optional - max 5 files, 10MB each)</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept="image/*,.pdf,.doc,.docx"
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<Upload className="h-8 w-8 mx-auto text-gray-400 mb-2" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={evidenceFiles.length >= 5}
|
||||
>
|
||||
Upload Evidence
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Screenshots, bank statements, chat logs, receipts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Evidence Preview */}
|
||||
{evidenceFiles.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{evidenceFiles.map((evidence) => (
|
||||
<div
|
||||
key={evidence.id}
|
||||
className="relative border rounded-lg p-2 flex items-center gap-2"
|
||||
>
|
||||
{evidence.type === 'image' ? (
|
||||
<img
|
||||
src={evidence.preview}
|
||||
alt="Evidence"
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<FileText className="w-10 h-10 text-blue-500" />
|
||||
)}
|
||||
<span className="text-xs truncate flex-1">
|
||||
{evidence.file.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeEvidence(evidence.id)}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning Box */}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<div className="flex gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-amber-800 dark:text-amber-200">
|
||||
Important Notice
|
||||
</p>
|
||||
<ul className="text-amber-700 dark:text-amber-300 text-xs mt-1 space-y-1">
|
||||
<li>• False disputes may result in account restrictions</li>
|
||||
<li>• Resolution typically takes 1-3 business days</li>
|
||||
<li>• Both parties can submit evidence</li>
|
||||
<li>• Admin decision is final</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms Checkbox */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={termsAccepted}
|
||||
onCheckedChange={(checked) => setTermsAccepted(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="terms" className="text-sm leading-tight cursor-pointer">
|
||||
I confirm that the information provided is accurate and understand that
|
||||
false claims may result in penalties.
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !reason || !description || !termsAccepted}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Open Dispute'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Bell,
|
||||
MessageSquare,
|
||||
DollarSign,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Star,
|
||||
Loader2,
|
||||
CheckCheck,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
reference_type?: string;
|
||||
reference_id?: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Fetch notifications
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_notifications')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setNotifications(data || []);
|
||||
setUnreadCount(data?.filter(n => !n.is_read).length || 0);
|
||||
} catch (error) {
|
||||
console.error('Fetch notifications error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
// Real-time subscription
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`notifications-${user.id}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'p2p_notifications',
|
||||
filter: `user_id=eq.${user.id}`,
|
||||
},
|
||||
(payload) => {
|
||||
const newNotif = payload.new as Notification;
|
||||
setNotifications(prev => [newNotif, ...prev.slice(0, 19)]);
|
||||
setUnreadCount(prev => prev + 1);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
// Mark as read
|
||||
const markAsRead = async (notificationId: string) => {
|
||||
try {
|
||||
await supabase
|
||||
.from('p2p_notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('id', notificationId);
|
||||
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === notificationId ? { ...n, is_read: true } : n)
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
} catch (error) {
|
||||
console.error('Mark as read error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark all as read
|
||||
const markAllAsRead = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
await supabase
|
||||
.from('p2p_notifications')
|
||||
.update({ is_read: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_read', false);
|
||||
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
} catch (error) {
|
||||
console.error('Mark all as read error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle notification click
|
||||
const handleClick = (notification: Notification) => {
|
||||
// Mark as read
|
||||
if (!notification.is_read) {
|
||||
markAsRead(notification.id);
|
||||
}
|
||||
|
||||
// Navigate to reference
|
||||
if (notification.reference_type === 'trade' && notification.reference_id) {
|
||||
navigate(`/p2p/trade/${notification.reference_id}`);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon for notification type
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'new_message':
|
||||
return <MessageSquare className="w-4 h-4 text-blue-400" />;
|
||||
case 'payment_sent':
|
||||
return <DollarSign className="w-4 h-4 text-yellow-400" />;
|
||||
case 'payment_confirmed':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-400" />;
|
||||
case 'trade_cancelled':
|
||||
return <XCircle className="w-4 h-4 text-gray-400" />;
|
||||
case 'dispute_opened':
|
||||
return <AlertTriangle className="w-4 h-4 text-red-400" />;
|
||||
case 'new_rating':
|
||||
return <Star className="w-4 h-4 text-yellow-400" />;
|
||||
case 'new_order':
|
||||
return <DollarSign className="w-4 h-4 text-green-400" />;
|
||||
default:
|
||||
return <Bell className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Format time ago
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateString).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`;
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative text-gray-400 hover:text-white"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 flex items-center justify-center bg-red-500 text-white text-xs rounded-full">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-80 bg-gray-900 border-gray-800"
|
||||
>
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
<span className="text-white">Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={markAllAsRead}
|
||||
className="text-xs text-gray-400 hover:text-white h-auto py-1"
|
||||
>
|
||||
<CheckCheck className="w-3 h-3 mr-1" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-gray-800" />
|
||||
|
||||
<ScrollArea className="h-[300px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||
<Bell className="w-8 h-8 mb-2" />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<DropdownMenuItem
|
||||
key={notification.id}
|
||||
onClick={() => handleClick(notification)}
|
||||
className={`
|
||||
flex items-start gap-3 p-3 cursor-pointer
|
||||
${!notification.is_read ? 'bg-gray-800/50' : ''}
|
||||
hover:bg-gray-800
|
||||
`}
|
||||
>
|
||||
<div className="mt-0.5">{getIcon(notification.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm ${!notification.is_read ? 'text-white font-medium' : 'text-gray-300'}`}>
|
||||
{notification.title}
|
||||
</p>
|
||||
{notification.message && (
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{notification.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{formatTimeAgo(notification.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full mt-1.5" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-gray-800" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigate('/p2p/orders');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="justify-center text-gray-400 hover:text-white cursor-pointer"
|
||||
>
|
||||
View all orders
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,138 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlusCircle, Home } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { PlusCircle, Home, ClipboardList, TrendingUp, CheckCircle2, Clock } from 'lucide-react';
|
||||
import { AdList } from './AdList';
|
||||
import { CreateAd } from './CreateAd';
|
||||
import { NotificationBell } from './NotificationBell';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface UserStats {
|
||||
activeTrades: number;
|
||||
completedTrades: number;
|
||||
totalVolume: number;
|
||||
}
|
||||
|
||||
export function P2PDashboard() {
|
||||
const [showCreateAd, setShowCreateAd] = useState(false);
|
||||
const [userStats, setUserStats] = useState<UserStats>({ activeTrades: 0, completedTrades: 0, totalVolume: 0 });
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Fetch user stats
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// Count active trades
|
||||
const { count: activeCount } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
||||
.in('status', ['pending', 'payment_sent']);
|
||||
|
||||
// Count completed trades
|
||||
const { count: completedCount } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
||||
.eq('status', 'completed');
|
||||
|
||||
// Calculate total volume
|
||||
const { data: trades } = await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.select('fiat_amount')
|
||||
.or(`seller_id.eq.${user.id},buyer_id.eq.${user.id}`)
|
||||
.eq('status', 'completed');
|
||||
|
||||
const totalVolume = trades?.reduce((sum, t) => sum + (t.fiat_amount || 0), 0) || 0;
|
||||
|
||||
setUserStats({
|
||||
activeTrades: activeCount || 0,
|
||||
completedTrades: completedCount || 0,
|
||||
totalVolume,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fetch stats error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/')}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Back to Home
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/p2p/orders')}
|
||||
className="border-gray-700 hover:bg-gray-800"
|
||||
>
|
||||
<ClipboardList className="w-4 h-4 mr-2" />
|
||||
My Orders
|
||||
{userStats.activeTrades > 0 && (
|
||||
<Badge className="ml-2 bg-yellow-500 text-black">
|
||||
{userStats.activeTrades}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{user && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-500/20 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{userStats.activeTrades}</p>
|
||||
<p className="text-sm text-gray-400">Active Trades</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">{userStats.completedTrades}</p>
|
||||
<p className="text-sm text-gray-400">Completed</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<TrendingUp className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white">${userStats.totalVolume.toLocaleString()}</p>
|
||||
<p className="text-sm text-gray-400">Volume</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-white">P2P Trading</h1>
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Star, Loader2, ThumbsUp, ThumbsDown } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface RatingModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tradeId: string;
|
||||
counterpartyId: string;
|
||||
counterpartyWallet: string;
|
||||
isBuyer: boolean;
|
||||
}
|
||||
|
||||
export function RatingModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
tradeId,
|
||||
counterpartyId,
|
||||
counterpartyWallet,
|
||||
isBuyer,
|
||||
}: RatingModalProps) {
|
||||
const { user } = useAuth();
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
const [review, setReview] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!user || rating === 0) {
|
||||
toast.error('Please select a rating');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Check if already rated
|
||||
const { data: existingRating } = await supabase
|
||||
.from('p2p_ratings')
|
||||
.select('id')
|
||||
.eq('trade_id', tradeId)
|
||||
.eq('rater_id', user.id)
|
||||
.single();
|
||||
|
||||
if (existingRating) {
|
||||
toast.error('You have already rated this trade');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert rating
|
||||
const { error: ratingError } = await supabase.from('p2p_ratings').insert({
|
||||
trade_id: tradeId,
|
||||
rater_id: user.id,
|
||||
rated_id: counterpartyId,
|
||||
rating,
|
||||
review: review.trim() || null,
|
||||
});
|
||||
|
||||
if (ratingError) throw ratingError;
|
||||
|
||||
// Update reputation score
|
||||
const { data: repData } = await supabase
|
||||
.from('p2p_reputation')
|
||||
.select('*')
|
||||
.eq('user_id', counterpartyId)
|
||||
.single();
|
||||
|
||||
if (repData) {
|
||||
// Calculate new average rating
|
||||
const { data: allRatings } = await supabase
|
||||
.from('p2p_ratings')
|
||||
.select('rating')
|
||||
.eq('rated_id', counterpartyId);
|
||||
|
||||
const totalRatings = allRatings?.length || 0;
|
||||
const avgRating = allRatings
|
||||
? allRatings.reduce((sum, r) => sum + r.rating, 0) / totalRatings
|
||||
: rating;
|
||||
|
||||
// Update reputation
|
||||
await supabase
|
||||
.from('p2p_reputation')
|
||||
.update({
|
||||
reputation_score: Math.round(avgRating * 20), // Convert 5-star to 100-point scale
|
||||
})
|
||||
.eq('user_id', counterpartyId);
|
||||
}
|
||||
|
||||
// Create notification
|
||||
await supabase.from('p2p_notifications').insert({
|
||||
user_id: counterpartyId,
|
||||
type: 'new_rating',
|
||||
title: 'New Rating Received',
|
||||
message: `You received a ${rating}-star rating`,
|
||||
reference_type: 'trade',
|
||||
reference_id: tradeId,
|
||||
is_read: false,
|
||||
});
|
||||
|
||||
toast.success('Rating submitted successfully');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Submit rating error:', error);
|
||||
toast.error('Failed to submit rating');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoveredRating(star)}
|
||||
onMouseLeave={() => setHoveredRating(0)}
|
||||
className="p-1 transition-transform hover:scale-110"
|
||||
>
|
||||
<Star
|
||||
className={`w-8 h-8 transition-colors ${
|
||||
star <= (hoveredRating || rating)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getRatingLabel = (r: number): string => {
|
||||
switch (r) {
|
||||
case 1: return 'Poor';
|
||||
case 2: return 'Fair';
|
||||
case 3: return 'Good';
|
||||
case 4: return 'Very Good';
|
||||
case 5: return 'Excellent';
|
||||
default: return 'Select a rating';
|
||||
}
|
||||
};
|
||||
|
||||
const quickReviews = [
|
||||
{ icon: ThumbsUp, text: 'Fast payment', positive: true },
|
||||
{ icon: ThumbsUp, text: 'Good communication', positive: true },
|
||||
{ icon: ThumbsUp, text: 'Smooth transaction', positive: true },
|
||||
{ icon: ThumbsDown, text: 'Slow response', positive: false },
|
||||
{ icon: ThumbsDown, text: 'Delayed payment', positive: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="bg-gray-900 border-gray-800 text-white max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rate Your Experience</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
How was your trade with {counterpartyWallet.slice(0, 6)}...{counterpartyWallet.slice(-4)}?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Star Rating */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{renderStars()}
|
||||
<p className={`text-sm font-medium ${
|
||||
rating >= 4 ? 'text-green-400' :
|
||||
rating >= 3 ? 'text-yellow-400' :
|
||||
rating >= 1 ? 'text-red-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{getRatingLabel(hoveredRating || rating)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Review Buttons */}
|
||||
<div>
|
||||
<Label className="text-gray-400 text-sm">Quick feedback (optional)</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{quickReviews.map((qr, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setReview(prev =>
|
||||
prev ? `${prev}, ${qr.text}` : qr.text
|
||||
)}
|
||||
className={`
|
||||
flex items-center gap-1 px-3 py-1.5 rounded-full text-sm
|
||||
border transition-colors
|
||||
${qr.positive
|
||||
? 'border-green-500/30 text-green-400 hover:bg-green-500/10'
|
||||
: 'border-red-500/30 text-red-400 hover:bg-red-500/10'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<qr.icon className="w-3 h-3" />
|
||||
{qr.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review Text */}
|
||||
<div>
|
||||
<Label htmlFor="review" className="text-gray-400 text-sm">
|
||||
Additional comments (optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="review"
|
||||
value={review}
|
||||
onChange={(e) => setReview(e.target.value)}
|
||||
placeholder="Share your experience..."
|
||||
className="mt-2 bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 resize-none"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 text-right mt-1">
|
||||
{review.length}/500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Role Badge */}
|
||||
<div className="flex items-center justify-center">
|
||||
<span className={`
|
||||
px-3 py-1 rounded-full text-xs
|
||||
${isBuyer
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}
|
||||
`}>
|
||||
Rating as {isBuyer ? 'Buyer' : 'Seller'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="border-gray-700"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={rating === 0 || loading}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit Rating'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Send,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCheck,
|
||||
Clock,
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
trade_id: string;
|
||||
sender_id: string;
|
||||
message: string;
|
||||
message_type: 'text' | 'image' | 'system';
|
||||
attachment_url?: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface TradeChatProps {
|
||||
tradeId: string;
|
||||
counterpartyId: string;
|
||||
counterpartyWallet: string;
|
||||
isTradeActive: boolean;
|
||||
}
|
||||
|
||||
export function TradeChat({
|
||||
tradeId,
|
||||
counterpartyId,
|
||||
counterpartyWallet,
|
||||
isTradeActive,
|
||||
}: TradeChatProps) {
|
||||
const { user } = useAuth();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Scroll to bottom
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch messages
|
||||
const fetchMessages = useCallback(async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_messages')
|
||||
.select('*')
|
||||
.eq('trade_id', tradeId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
setMessages(data || []);
|
||||
|
||||
// Mark messages as read
|
||||
if (user && data && data.length > 0) {
|
||||
const unreadIds = data
|
||||
.filter(m => m.sender_id !== user.id && !m.is_read)
|
||||
.map(m => m.id);
|
||||
|
||||
if (unreadIds.length > 0) {
|
||||
await supabase
|
||||
.from('p2p_messages')
|
||||
.update({ is_read: true })
|
||||
.in('id', unreadIds);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch messages error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tradeId, user]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
}, [fetchMessages]);
|
||||
|
||||
// Real-time subscription
|
||||
useEffect(() => {
|
||||
const channel = supabase
|
||||
.channel(`chat-${tradeId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'p2p_messages',
|
||||
filter: `trade_id=eq.${tradeId}`,
|
||||
},
|
||||
(payload) => {
|
||||
const newMsg = payload.new as Message;
|
||||
setMessages(prev => {
|
||||
// Avoid duplicates
|
||||
if (prev.some(m => m.id === newMsg.id)) return prev;
|
||||
return [...prev, newMsg];
|
||||
});
|
||||
|
||||
// Mark as read if from counterparty
|
||||
if (user && newMsg.sender_id !== user.id) {
|
||||
supabase
|
||||
.from('p2p_messages')
|
||||
.update({ is_read: true })
|
||||
.eq('id', newMsg.id);
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [tradeId, user]);
|
||||
|
||||
// Scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
// Send message
|
||||
const handleSendMessage = async () => {
|
||||
if (!newMessage.trim() || !user || sending) return;
|
||||
|
||||
const messageText = newMessage.trim();
|
||||
setNewMessage('');
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
const { error } = await supabase.from('p2p_messages').insert({
|
||||
trade_id: tradeId,
|
||||
sender_id: user.id,
|
||||
message: messageText,
|
||||
message_type: 'text',
|
||||
is_read: false,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Create notification for counterparty
|
||||
await supabase.from('p2p_notifications').insert({
|
||||
user_id: counterpartyId,
|
||||
type: 'new_message',
|
||||
title: 'New Message',
|
||||
message: messageText.slice(0, 100),
|
||||
reference_type: 'trade',
|
||||
reference_id: tradeId,
|
||||
is_read: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Send message error:', error);
|
||||
toast.error('Failed to send message');
|
||||
setNewMessage(messageText); // Restore message
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Enter key
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// Upload image
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !user) return;
|
||||
|
||||
// Validate file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('Image must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// Upload to Supabase Storage
|
||||
const fileName = `${tradeId}/${Date.now()}-${file.name}`;
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||
.from('p2p-chat-images')
|
||||
.upload(fileName, file);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
// Get public URL
|
||||
const { data: urlData } = supabase.storage
|
||||
.from('p2p-chat-images')
|
||||
.getPublicUrl(uploadData.path);
|
||||
|
||||
// Insert message with image
|
||||
const { error: msgError } = await supabase.from('p2p_messages').insert({
|
||||
trade_id: tradeId,
|
||||
sender_id: user.id,
|
||||
message: 'Sent an image',
|
||||
message_type: 'image',
|
||||
attachment_url: urlData.publicUrl,
|
||||
is_read: false,
|
||||
});
|
||||
|
||||
if (msgError) throw msgError;
|
||||
|
||||
// Create notification
|
||||
await supabase.from('p2p_notifications').insert({
|
||||
user_id: counterpartyId,
|
||||
type: 'new_message',
|
||||
title: 'New Image',
|
||||
message: 'Sent an image',
|
||||
reference_type: 'trade',
|
||||
reference_id: tradeId,
|
||||
is_read: false,
|
||||
});
|
||||
|
||||
toast.success('Image sent');
|
||||
} catch (error) {
|
||||
console.error('Upload image error:', error);
|
||||
toast.error('Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
// Render message
|
||||
const renderMessage = (message: Message) => {
|
||||
const isOwn = message.sender_id === user?.id;
|
||||
const isSystem = message.message_type === 'system';
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div key={message.id} className="flex justify-center my-2">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-gray-800 rounded-full text-xs text-gray-400">
|
||||
<Bot className="w-3 h-3" />
|
||||
{message.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isOwn ? 'justify-end' : 'justify-start'} mb-3`}
|
||||
>
|
||||
<div className={`flex items-end gap-2 max-w-[75%] ${isOwn ? 'flex-row-reverse' : ''}`}>
|
||||
{!isOwn && (
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="bg-gray-700 text-xs">
|
||||
{counterpartyWallet.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div>
|
||||
<div
|
||||
className={`
|
||||
px-3 py-2 rounded-2xl
|
||||
${isOwn
|
||||
? 'bg-green-600 text-white rounded-br-sm'
|
||||
: 'bg-gray-700 text-white rounded-bl-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{message.message_type === 'image' && message.attachment_url ? (
|
||||
<a
|
||||
href={message.attachment_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={message.attachment_url}
|
||||
alt="Shared image"
|
||||
className="max-w-[200px] max-h-[200px] rounded-lg"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{message.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 mt-1 ${isOwn ? 'justify-end' : ''}`}>
|
||||
<span className="text-xs text-gray-500">{formatTime(message.created_at)}</span>
|
||||
{isOwn && (
|
||||
message.is_read ? (
|
||||
<CheckCheck className="w-3 h-3 text-blue-400" />
|
||||
) : (
|
||||
<Clock className="w-3 h-3 text-gray-500" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-800 h-[400px] flex flex-col">
|
||||
<CardHeader className="py-3 px-4 border-b border-gray-800">
|
||||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||||
<span>Chat</span>
|
||||
{messages.filter(m => m.sender_id !== user?.id && !m.is_read).length > 0 && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-green-500 text-white rounded-full">
|
||||
{messages.filter(m => m.sender_id !== user?.id && !m.is_read).length}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 flex flex-col p-0 overflow-hidden">
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500">
|
||||
<AlertCircle className="w-8 h-8 mb-2" />
|
||||
<p className="text-sm">No messages yet</p>
|
||||
<p className="text-xs">Start the conversation</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map(renderMessage)
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input */}
|
||||
{isTradeActive ? (
|
||||
<div className="p-3 border-t border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Input
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type a message..."
|
||||
disabled={sending}
|
||||
className="flex-1 bg-gray-800 border-gray-700 text-white placeholder:text-gray-500"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSendMessage}
|
||||
disabled={!newMessage.trim() || sending}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{sending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 border-t border-gray-800 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
Chat is disabled for completed/cancelled trades
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,7 +16,7 @@ import { Loader2, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { toast } from 'sonner';
|
||||
import { type P2PFiatOffer } from '@shared/lib/p2p-fiat';
|
||||
import { acceptFiatOffer, type P2PFiatOffer } from '@shared/lib/p2p-fiat';
|
||||
|
||||
interface TradeModalProps {
|
||||
offer: P2PFiatOffer;
|
||||
@@ -23,6 +24,7 @@ interface TradeModalProps {
|
||||
}
|
||||
|
||||
export function TradeModal({ offer, onClose }: TradeModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { api, selectedAccount } = usePolkadot();
|
||||
const [amount, setAmount] = useState('');
|
||||
@@ -60,18 +62,18 @@ export function TradeModal({ offer, onClose }: TradeModalProps) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// const _tradeId = await acceptFiatOffer({
|
||||
// api,
|
||||
// account: selectedAccount,
|
||||
// offerId: offer.id,
|
||||
// amount: cryptoAmount
|
||||
// });
|
||||
const tradeId = await acceptFiatOffer({
|
||||
api,
|
||||
account: selectedAccount,
|
||||
offerId: offer.id,
|
||||
amount: cryptoAmount
|
||||
});
|
||||
|
||||
toast.success('Trade initiated! Proceed to payment.');
|
||||
onClose();
|
||||
|
||||
// TODO: Navigate to trade page
|
||||
// navigate(`/p2p/trade/${tradeId}`);
|
||||
|
||||
// Navigate to trade page
|
||||
navigate(`/p2p/trade/${tradeId}`);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Accept offer error:', error);
|
||||
// Error toast already shown in acceptFiatOffer
|
||||
|
||||
Reference in New Issue
Block a user