mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-24 18:57:56 +00:00
feat(p2p): add Phase 4 merchant tier system and migrations
- Add merchant tier system (Lite/Super/Diamond) with tier badges - Add advanced order filters (token, fiat, payment method, amount range) - Add merchant dashboard with stats, ads management, tier upgrade - Add fraud prevention system with risk scoring and trade limits - Rename migrations to timestamp format for Supabase CLI compatibility - Add new migrations: phase2_phase3_tables, fraud_prevention, merchant_system
This commit is contained in:
@@ -0,0 +1,829 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { toast } from 'sonner';
|
||||
import { formatAddress, formatDate } from '@pezkuwi/utils/formatting';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Eye,
|
||||
FileText,
|
||||
Gavel,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Scale,
|
||||
Shield,
|
||||
User,
|
||||
XCircle,
|
||||
ExternalLink,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types
|
||||
interface Dispute {
|
||||
id: string;
|
||||
trade_id: string;
|
||||
opened_by: string;
|
||||
reason: string;
|
||||
category: string;
|
||||
evidence_urls: string[];
|
||||
status: 'open' | 'under_review' | 'resolved' | 'escalated' | 'closed';
|
||||
decision?: string;
|
||||
decision_reasoning?: string;
|
||||
assigned_moderator_id?: string;
|
||||
assigned_at?: string;
|
||||
resolved_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Joined data
|
||||
trade?: Trade;
|
||||
opener?: UserProfile;
|
||||
evidence?: Evidence[];
|
||||
}
|
||||
|
||||
interface Trade {
|
||||
id: string;
|
||||
offer_id: string;
|
||||
seller_id: string;
|
||||
buyer_id: string;
|
||||
crypto_amount: number;
|
||||
fiat_amount: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
seller?: UserProfile;
|
||||
buyer?: UserProfile;
|
||||
}
|
||||
|
||||
interface UserProfile {
|
||||
id: string;
|
||||
username?: string;
|
||||
wallet_address?: string;
|
||||
}
|
||||
|
||||
interface Evidence {
|
||||
id: string;
|
||||
dispute_id: string;
|
||||
uploaded_by: string;
|
||||
evidence_type: string;
|
||||
file_url: string;
|
||||
file_name?: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
is_valid?: boolean;
|
||||
review_notes?: string;
|
||||
}
|
||||
|
||||
// Decision options
|
||||
const DECISION_OPTIONS = [
|
||||
{ value: 'release_to_buyer', label: 'Release to Buyer', description: 'Release escrowed crypto to the buyer' },
|
||||
{ value: 'refund_to_seller', label: 'Refund to Seller', description: 'Return escrowed crypto to the seller' },
|
||||
{ value: 'split', label: 'Split 50/50', description: 'Split the escrowed amount between both parties' },
|
||||
{ value: 'escalate', label: 'Escalate', description: 'Escalate to higher authority for complex cases' }
|
||||
];
|
||||
|
||||
// Status badge colors
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
open: 'bg-yellow-500/20 text-yellow-500 border-yellow-500/30',
|
||||
under_review: 'bg-blue-500/20 text-blue-500 border-blue-500/30',
|
||||
resolved: 'bg-green-500/20 text-green-500 border-green-500/30',
|
||||
escalated: 'bg-purple-500/20 text-purple-500 border-purple-500/30',
|
||||
closed: 'bg-gray-500/20 text-gray-400 border-gray-500/30'
|
||||
};
|
||||
|
||||
// Category labels
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
payment_not_received: 'Payment Not Received',
|
||||
wrong_amount: 'Wrong Amount',
|
||||
fake_payment_proof: 'Fake Payment Proof',
|
||||
seller_not_responding: 'Seller Not Responding',
|
||||
buyer_not_responding: 'Buyer Not Responding',
|
||||
fraudulent_behavior: 'Fraudulent Behavior',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
export function DisputeResolutionPanel() {
|
||||
const [disputes, setDisputes] = useState<Dispute[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedDispute, setSelectedDispute] = useState<Dispute | null>(null);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [resolveOpen, setResolveOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('open');
|
||||
const [decision, setDecision] = useState('');
|
||||
const [reasoning, setReasoning] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [lightboxImage, setLightboxImage] = useState<string | null>(null);
|
||||
|
||||
// Fetch disputes
|
||||
const fetchDisputes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('p2p_fiat_disputes')
|
||||
.select(`
|
||||
*,
|
||||
trade:p2p_fiat_trades(
|
||||
id,
|
||||
offer_id,
|
||||
seller_id,
|
||||
buyer_id,
|
||||
crypto_amount,
|
||||
fiat_amount,
|
||||
status,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Fetch evidence for each dispute
|
||||
const disputesWithEvidence = await Promise.all(
|
||||
(data || []).map(async (dispute) => {
|
||||
const { data: evidence } = await supabase
|
||||
.from('p2p_dispute_evidence')
|
||||
.select('*')
|
||||
.eq('dispute_id', dispute.id)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
return {
|
||||
...dispute,
|
||||
evidence: evidence || []
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setDisputes(disputesWithEvidence);
|
||||
} catch (error) {
|
||||
console.error('Error fetching disputes:', error);
|
||||
toast.error('Failed to load disputes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDisputes();
|
||||
|
||||
// Subscribe to real-time updates
|
||||
const channel = supabase
|
||||
.channel('admin-disputes')
|
||||
.on('postgres_changes', {
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'p2p_fiat_disputes'
|
||||
}, () => {
|
||||
fetchDisputes();
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Filter disputes by status
|
||||
const filteredDisputes = disputes.filter(d => {
|
||||
if (activeTab === 'open') return d.status === 'open';
|
||||
if (activeTab === 'under_review') return d.status === 'under_review';
|
||||
if (activeTab === 'resolved') return ['resolved', 'closed'].includes(d.status);
|
||||
if (activeTab === 'escalated') return d.status === 'escalated';
|
||||
return true;
|
||||
});
|
||||
|
||||
// Claim dispute for review
|
||||
const claimDispute = async (disputeId: string) => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('p2p_fiat_disputes')
|
||||
.update({
|
||||
status: 'under_review',
|
||||
assigned_moderator_id: user.id,
|
||||
assigned_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', disputeId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Dispute claimed for review');
|
||||
fetchDisputes();
|
||||
} catch (error) {
|
||||
console.error('Error claiming dispute:', error);
|
||||
toast.error('Failed to claim dispute');
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve dispute
|
||||
const resolveDispute = async () => {
|
||||
if (!selectedDispute || !decision || !reasoning) {
|
||||
toast.error('Please select a decision and provide reasoning');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
// Update dispute
|
||||
const { error: disputeError } = await supabase
|
||||
.from('p2p_fiat_disputes')
|
||||
.update({
|
||||
status: decision === 'escalate' ? 'escalated' : 'resolved',
|
||||
decision,
|
||||
decision_reasoning: reasoning,
|
||||
resolved_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', selectedDispute.id);
|
||||
|
||||
if (disputeError) throw disputeError;
|
||||
|
||||
// Update trade status based on decision
|
||||
if (decision !== 'escalate' && selectedDispute.trade) {
|
||||
const tradeStatus = decision === 'release_to_buyer' ? 'completed' : 'refunded';
|
||||
await supabase
|
||||
.from('p2p_fiat_trades')
|
||||
.update({ status: tradeStatus })
|
||||
.eq('id', selectedDispute.trade_id);
|
||||
}
|
||||
|
||||
// Create notifications for both parties
|
||||
if (selectedDispute.trade) {
|
||||
const notificationPromises = [
|
||||
supabase.rpc('create_p2p_notification', {
|
||||
p_user_id: selectedDispute.trade.seller_id,
|
||||
p_type: 'dispute_resolved',
|
||||
p_title: 'Dispute Resolved',
|
||||
p_message: `The dispute has been resolved: ${DECISION_OPTIONS.find(o => o.value === decision)?.label}`,
|
||||
p_reference_type: 'dispute',
|
||||
p_reference_id: selectedDispute.id
|
||||
}),
|
||||
supabase.rpc('create_p2p_notification', {
|
||||
p_user_id: selectedDispute.trade.buyer_id,
|
||||
p_type: 'dispute_resolved',
|
||||
p_title: 'Dispute Resolved',
|
||||
p_message: `The dispute has been resolved: ${DECISION_OPTIONS.find(o => o.value === decision)?.label}`,
|
||||
p_reference_type: 'dispute',
|
||||
p_reference_id: selectedDispute.id
|
||||
})
|
||||
];
|
||||
await Promise.all(notificationPromises);
|
||||
}
|
||||
|
||||
toast.success('Dispute resolved successfully');
|
||||
setResolveOpen(false);
|
||||
setSelectedDispute(null);
|
||||
setDecision('');
|
||||
setReasoning('');
|
||||
fetchDisputes();
|
||||
} catch (error) {
|
||||
console.error('Error resolving dispute:', error);
|
||||
toast.error('Failed to resolve dispute');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open details modal
|
||||
const openDetails = (dispute: Dispute) => {
|
||||
setSelectedDispute(dispute);
|
||||
setDetailsOpen(true);
|
||||
};
|
||||
|
||||
// Open resolve modal
|
||||
const openResolve = (dispute: Dispute) => {
|
||||
setSelectedDispute(dispute);
|
||||
setResolveOpen(true);
|
||||
};
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
open: disputes.filter(d => d.status === 'open').length,
|
||||
under_review: disputes.filter(d => d.status === 'under_review').length,
|
||||
resolved: disputes.filter(d => ['resolved', 'closed'].includes(d.status)).length,
|
||||
escalated: disputes.filter(d => d.status === 'escalated').length
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Gavel className="h-6 w-6 text-kurdish-green" />
|
||||
Dispute Resolution
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Review and resolve P2P trading disputes
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchDisputes} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-yellow-500/10 border-yellow-500/20">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Open</p>
|
||||
<p className="text-2xl font-bold text-yellow-500">{stats.open}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-yellow-500/50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-blue-500/10 border-blue-500/20">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Under Review</p>
|
||||
<p className="text-2xl font-bold text-blue-500">{stats.under_review}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-blue-500/50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-green-500/10 border-green-500/20">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Resolved</p>
|
||||
<p className="text-2xl font-bold text-green-500">{stats.resolved}</p>
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-green-500/50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-purple-500/10 border-purple-500/20">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Escalated</p>
|
||||
<p className="text-2xl font-bold text-purple-500">{stats.escalated}</p>
|
||||
</div>
|
||||
<Scale className="h-8 w-8 text-purple-500/50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Disputes Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid grid-cols-4 w-full max-w-md">
|
||||
<TabsTrigger value="open" className="gap-1">
|
||||
Open
|
||||
{stats.open > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
|
||||
{stats.open}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="under_review">In Review</TabsTrigger>
|
||||
<TabsTrigger value="resolved">Resolved</TabsTrigger>
|
||||
<TabsTrigger value="escalated">Escalated</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={activeTab} className="mt-4">
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredDisputes.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Shield className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No disputes in this category</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredDisputes.map((dispute) => (
|
||||
<Card key={dispute.id} className="hover:border-kurdish-green/50 transition-colors">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge className={STATUS_COLORS[dispute.status]}>
|
||||
{dispute.status.replace('_', ' ').toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{CATEGORY_LABELS[dispute.category] || dispute.category}
|
||||
</Badge>
|
||||
{dispute.evidence && dispute.evidence.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<ImageIcon className="h-3 w-3" />
|
||||
{dispute.evidence.length} evidence
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-2">
|
||||
{dispute.reason}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
Trade: {formatAddress(dispute.trade_id)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(dispute.created_at)}
|
||||
</span>
|
||||
{dispute.trade && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Scale className="h-3 w-3" />
|
||||
{dispute.trade.crypto_amount} crypto
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDetails(dispute)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
|
||||
{dispute.status === 'open' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => claimDispute(dispute.id)}
|
||||
>
|
||||
Claim
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{dispute.status === 'under_review' && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-kurdish-green hover:bg-kurdish-green-dark"
|
||||
onClick={() => openResolve(dispute)}
|
||||
>
|
||||
<Gavel className="h-4 w-4 mr-1" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Details Modal */}
|
||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Scale className="h-5 w-5" />
|
||||
Dispute Details
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review all information related to this dispute
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedDispute && (
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<div className="space-y-6">
|
||||
{/* Status & Category */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={STATUS_COLORS[selectedDispute.status]}>
|
||||
{selectedDispute.status.replace('_', ' ').toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{CATEGORY_LABELS[selectedDispute.category] || selectedDispute.category}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Reason</h4>
|
||||
<p className="text-sm text-muted-foreground bg-muted p-3 rounded-lg">
|
||||
{selectedDispute.reason}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Trade Info */}
|
||||
{selectedDispute.trade && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Trade Information</h4>
|
||||
<div className="bg-muted p-3 rounded-lg space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Trade ID:</span>
|
||||
<span className="font-mono">{formatAddress(selectedDispute.trade_id)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Amount:</span>
|
||||
<span>{selectedDispute.trade.crypto_amount} crypto</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Fiat:</span>
|
||||
<span>{selectedDispute.trade.fiat_amount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Trade Status:</span>
|
||||
<Badge variant="secondary">{selectedDispute.trade.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parties */}
|
||||
{selectedDispute.trade && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Parties</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-muted p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Seller</span>
|
||||
</div>
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
{formatAddress(selectedDispute.trade.seller_id)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Buyer</span>
|
||||
</div>
|
||||
<p className="text-xs font-mono text-muted-foreground">
|
||||
{formatAddress(selectedDispute.trade.buyer_id)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Evidence */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">
|
||||
Evidence ({selectedDispute.evidence?.length || 0})
|
||||
</h4>
|
||||
{selectedDispute.evidence && selectedDispute.evidence.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{selectedDispute.evidence.map((ev) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="bg-muted p-3 rounded-lg cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
onClick={() => setLightboxImage(ev.file_url)}
|
||||
>
|
||||
<div className="aspect-video relative mb-2 rounded overflow-hidden bg-black/20">
|
||||
{ev.evidence_type === 'screenshot' || ev.file_url.match(/\.(jpg|jpeg|png|gif|webp)$/i) ? (
|
||||
<img
|
||||
src={ev.file_url}
|
||||
alt={ev.description || 'Evidence'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<FileText className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<Badge variant="outline" className="mb-1">
|
||||
{ev.evidence_type}
|
||||
</Badge>
|
||||
{ev.description && (
|
||||
<p className="text-muted-foreground line-clamp-2 mt-1">
|
||||
{ev.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No evidence uploaded</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Timeline</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-muted-foreground">Opened:</span>
|
||||
<span>{formatDate(selectedDispute.created_at)}</span>
|
||||
</div>
|
||||
{selectedDispute.assigned_at && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-muted-foreground">Claimed:</span>
|
||||
<span>{formatDate(selectedDispute.assigned_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedDispute.resolved_at && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span className="text-muted-foreground">Resolved:</span>
|
||||
<span>{formatDate(selectedDispute.resolved_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolution (if resolved) */}
|
||||
{selectedDispute.decision && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Resolution</h4>
|
||||
<div className="bg-green-500/10 border border-green-500/20 p-3 rounded-lg">
|
||||
<Badge className="bg-green-500/20 text-green-500 mb-2">
|
||||
{DECISION_OPTIONS.find(o => o.value === selectedDispute.decision)?.label}
|
||||
</Badge>
|
||||
{selectedDispute.decision_reasoning && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedDispute.decision_reasoning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => setDetailsOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
{selectedDispute?.status === 'under_review' && (
|
||||
<Button
|
||||
className="bg-kurdish-green hover:bg-kurdish-green-dark"
|
||||
onClick={() => {
|
||||
setDetailsOpen(false);
|
||||
openResolve(selectedDispute);
|
||||
}}
|
||||
>
|
||||
<Gavel className="h-4 w-4 mr-2" />
|
||||
Resolve Dispute
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Resolve Modal */}
|
||||
<Dialog open={resolveOpen} onOpenChange={setResolveOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Gavel className="h-5 w-5 text-kurdish-green" />
|
||||
Resolve Dispute
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make a final decision on this dispute. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Decision */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Decision</label>
|
||||
<Select value={decision} onValueChange={setDecision}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a decision..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DECISION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Reasoning */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
Reasoning <span className="text-muted-foreground">(required)</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={reasoning}
|
||||
onChange={(e) => setReasoning(e.target.value)}
|
||||
placeholder="Explain your decision based on the evidence..."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This will be visible to both parties
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-500">Important</p>
|
||||
<p className="text-muted-foreground">
|
||||
Your decision will trigger automatic actions on the escrowed funds.
|
||||
Make sure you have reviewed all evidence carefully.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setResolveOpen(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-kurdish-green hover:bg-kurdish-green-dark"
|
||||
onClick={resolveDispute}
|
||||
disabled={submitting || !decision || !reasoning}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Confirm Resolution
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Image Lightbox */}
|
||||
<Dialog open={!!lightboxImage} onOpenChange={() => setLightboxImage(null)}>
|
||||
<DialogContent className="max-w-4xl p-0 bg-black/90">
|
||||
{lightboxImage && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={lightboxImage}
|
||||
alt="Evidence"
|
||||
className="w-full h-auto max-h-[80vh] object-contain"
|
||||
/>
|
||||
<div className="absolute top-4 right-4 flex gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={() => window.open(lightboxImage, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
asChild
|
||||
>
|
||||
<a href={lightboxImage} download>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={() => setLightboxImage(null)}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisputeResolutionPanel;
|
||||
@@ -0,0 +1,506 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import { MerchantTierBadge, type MerchantTier } from './MerchantTierBadge';
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Crown,
|
||||
Diamond,
|
||||
Info,
|
||||
Loader2,
|
||||
Lock,
|
||||
Shield,
|
||||
Star,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
|
||||
// Tier requirements interface
|
||||
interface TierRequirements {
|
||||
tier: MerchantTier;
|
||||
min_trades: number;
|
||||
min_completion_rate: number;
|
||||
min_volume_30d: number;
|
||||
deposit_required: number;
|
||||
deposit_token: string;
|
||||
max_pending_orders: number;
|
||||
max_order_amount: number;
|
||||
featured_ads_allowed: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// User stats interface
|
||||
interface UserStats {
|
||||
completed_trades: number;
|
||||
completion_rate: number;
|
||||
volume_30d: number;
|
||||
}
|
||||
|
||||
// Current tier info
|
||||
interface CurrentTierInfo {
|
||||
tier: MerchantTier;
|
||||
application_status: string | null;
|
||||
applied_for_tier: string | null;
|
||||
}
|
||||
|
||||
// Default tier requirements
|
||||
const DEFAULT_REQUIREMENTS: TierRequirements[] = [
|
||||
{
|
||||
tier: 'lite',
|
||||
min_trades: 0,
|
||||
min_completion_rate: 0,
|
||||
min_volume_30d: 0,
|
||||
deposit_required: 0,
|
||||
deposit_token: 'HEZ',
|
||||
max_pending_orders: 5,
|
||||
max_order_amount: 10000,
|
||||
featured_ads_allowed: 0,
|
||||
description: 'Basic tier for all verified users'
|
||||
},
|
||||
{
|
||||
tier: 'super',
|
||||
min_trades: 20,
|
||||
min_completion_rate: 90,
|
||||
min_volume_30d: 5000,
|
||||
deposit_required: 10000,
|
||||
deposit_token: 'HEZ',
|
||||
max_pending_orders: 20,
|
||||
max_order_amount: 100000,
|
||||
featured_ads_allowed: 3,
|
||||
description: 'Professional trader tier with higher limits'
|
||||
},
|
||||
{
|
||||
tier: 'diamond',
|
||||
min_trades: 100,
|
||||
min_completion_rate: 95,
|
||||
min_volume_30d: 25000,
|
||||
deposit_required: 50000,
|
||||
deposit_token: 'HEZ',
|
||||
max_pending_orders: 50,
|
||||
max_order_amount: 150000,
|
||||
featured_ads_allowed: 10,
|
||||
description: 'Elite merchant tier with maximum privileges'
|
||||
}
|
||||
];
|
||||
|
||||
// Tier icon mapping
|
||||
const TIER_ICONS = {
|
||||
lite: Shield,
|
||||
super: Star,
|
||||
diamond: Diamond
|
||||
};
|
||||
|
||||
// Tier colors
|
||||
const TIER_COLORS = {
|
||||
lite: 'text-gray-400',
|
||||
super: 'text-yellow-500',
|
||||
diamond: 'text-purple-500'
|
||||
};
|
||||
|
||||
export function MerchantApplication() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [requirements, setRequirements] = useState<TierRequirements[]>(DEFAULT_REQUIREMENTS);
|
||||
const [userStats, setUserStats] = useState<UserStats>({ completed_trades: 0, completion_rate: 0, volume_30d: 0 });
|
||||
const [currentTier, setCurrentTier] = useState<CurrentTierInfo>({ tier: 'lite', application_status: null, applied_for_tier: null });
|
||||
const [applyModalOpen, setApplyModalOpen] = useState(false);
|
||||
const [selectedTier, setSelectedTier] = useState<MerchantTier | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
// Fetch data
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
|
||||
// Fetch tier requirements
|
||||
const { data: reqData } = await supabase
|
||||
.from('p2p_tier_requirements')
|
||||
.select('*')
|
||||
.order('min_trades', { ascending: true });
|
||||
|
||||
if (reqData && reqData.length > 0) {
|
||||
setRequirements(reqData as TierRequirements[]);
|
||||
}
|
||||
|
||||
// Fetch user reputation
|
||||
const { data: repData } = await supabase
|
||||
.from('p2p_reputation')
|
||||
.select('completed_trades')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
// Fetch merchant stats
|
||||
const { data: statsData } = await supabase
|
||||
.from('p2p_merchant_stats')
|
||||
.select('completion_rate_30d, total_volume_30d')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
// Fetch current tier
|
||||
const { data: tierData } = await supabase
|
||||
.from('p2p_merchant_tiers')
|
||||
.select('tier, application_status, applied_for_tier')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
setUserStats({
|
||||
completed_trades: repData?.completed_trades || 0,
|
||||
completion_rate: statsData?.completion_rate_30d || 0,
|
||||
volume_30d: statsData?.total_volume_30d || 0
|
||||
});
|
||||
|
||||
if (tierData) {
|
||||
setCurrentTier(tierData as CurrentTierInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching merchant data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate progress for a requirement
|
||||
const calculateProgress = (current: number, required: number): number => {
|
||||
if (required === 0) return 100;
|
||||
return Math.min((current / required) * 100, 100);
|
||||
};
|
||||
|
||||
// Check if tier is unlocked
|
||||
const isTierUnlocked = (tier: TierRequirements): boolean => {
|
||||
return (
|
||||
userStats.completed_trades >= tier.min_trades &&
|
||||
userStats.completion_rate >= tier.min_completion_rate &&
|
||||
userStats.volume_30d >= tier.min_volume_30d
|
||||
);
|
||||
};
|
||||
|
||||
// Get tier index for comparison
|
||||
const getTierIndex = (tier: MerchantTier): number => {
|
||||
return requirements.findIndex(r => r.tier === tier);
|
||||
};
|
||||
|
||||
// Check if can apply for tier
|
||||
const canApplyForTier = (tier: TierRequirements): boolean => {
|
||||
if (!isTierUnlocked(tier)) return false;
|
||||
if (getTierIndex(currentTier.tier) >= getTierIndex(tier.tier)) return false;
|
||||
if (currentTier.application_status === 'pending') return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Apply for tier
|
||||
const applyForTier = async () => {
|
||||
if (!selectedTier) return;
|
||||
|
||||
setApplying(true);
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
const { data, error } = await supabase.rpc('apply_for_tier_upgrade', {
|
||||
p_user_id: user.id,
|
||||
p_target_tier: selectedTier
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
if (data && data[0]) {
|
||||
if (data[0].success) {
|
||||
toast.success('Application submitted successfully!');
|
||||
setApplyModalOpen(false);
|
||||
fetchData();
|
||||
} else {
|
||||
toast.error(data[0].message || 'Application failed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying for tier:', error);
|
||||
toast.error('Failed to submit application');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open apply modal
|
||||
const openApplyModal = (tier: MerchantTier) => {
|
||||
setSelectedTier(tier);
|
||||
setApplyModalOpen(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Tier Card */}
|
||||
<Card className="bg-gradient-to-br from-kurdish-green/10 to-transparent border-kurdish-green/30">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Crown className="h-5 w-5 text-kurdish-green" />
|
||||
Your Merchant Status
|
||||
</CardTitle>
|
||||
<CardDescription>Current tier and application status</CardDescription>
|
||||
</div>
|
||||
<MerchantTierBadge tier={currentTier.tier} size="lg" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-background/50 rounded-lg">
|
||||
<p className="text-2xl font-bold">{userStats.completed_trades}</p>
|
||||
<p className="text-sm text-muted-foreground">Completed Trades</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-background/50 rounded-lg">
|
||||
<p className="text-2xl font-bold">{userStats.completion_rate.toFixed(1)}%</p>
|
||||
<p className="text-sm text-muted-foreground">Completion Rate</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-background/50 rounded-lg">
|
||||
<p className="text-2xl font-bold">${userStats.volume_30d.toLocaleString()}</p>
|
||||
<p className="text-sm text-muted-foreground">30-Day Volume</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentTier.application_status === 'pending' && (
|
||||
<Alert className="mt-4 bg-yellow-500/10 border-yellow-500/30">
|
||||
<Clock className="h-4 w-4 text-yellow-500" />
|
||||
<AlertDescription className="text-yellow-500">
|
||||
Your application for {currentTier.applied_for_tier?.toUpperCase()} tier is pending review.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tier Progression */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{requirements.map((tier) => {
|
||||
const TierIcon = TIER_ICONS[tier.tier];
|
||||
const isCurrentTier = tier.tier === currentTier.tier;
|
||||
const isUnlocked = isTierUnlocked(tier);
|
||||
const canApply = canApplyForTier(tier);
|
||||
const isPastTier = getTierIndex(tier.tier) < getTierIndex(currentTier.tier);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={tier.tier}
|
||||
className={`relative overflow-hidden transition-all ${
|
||||
isCurrentTier
|
||||
? 'border-kurdish-green bg-kurdish-green/5'
|
||||
: isPastTier
|
||||
? 'border-green-500/30 bg-green-500/5'
|
||||
: isUnlocked
|
||||
? 'border-yellow-500/30 hover:border-yellow-500/50'
|
||||
: 'opacity-75'
|
||||
}`}
|
||||
>
|
||||
{/* Current tier indicator */}
|
||||
{isCurrentTier && (
|
||||
<div className="absolute top-0 right-0 bg-kurdish-green text-white text-xs px-2 py-0.5 rounded-bl">
|
||||
Current
|
||||
</div>
|
||||
)}
|
||||
{isPastTier && (
|
||||
<div className="absolute top-0 right-0 bg-green-500 text-white text-xs px-2 py-0.5 rounded-bl">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded-full bg-background ${TIER_COLORS[tier.tier]}`}>
|
||||
<TierIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg capitalize">{tier.tier}</CardTitle>
|
||||
<CardDescription className="text-xs">{tier.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Requirements */}
|
||||
<div className="space-y-3">
|
||||
{/* Trades */}
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>Completed Trades</span>
|
||||
<span>{userStats.completed_trades} / {tier.min_trades}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={calculateProgress(userStats.completed_trades, tier.min_trades)}
|
||||
className="h-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Completion Rate */}
|
||||
{tier.min_completion_rate > 0 && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>Completion Rate</span>
|
||||
<span>{userStats.completion_rate.toFixed(1)}% / {tier.min_completion_rate}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={calculateProgress(userStats.completion_rate, tier.min_completion_rate)}
|
||||
className="h-1.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Volume */}
|
||||
{tier.min_volume_30d > 0 && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>30-Day Volume</span>
|
||||
<span>${userStats.volume_30d.toLocaleString()} / ${tier.min_volume_30d.toLocaleString()}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={calculateProgress(userStats.volume_30d, tier.min_volume_30d)}
|
||||
className="h-1.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Benefits */}
|
||||
<div className="pt-2 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground mb-2">Benefits:</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-kurdish-green" />
|
||||
<span>Up to {tier.max_pending_orders} pending orders</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-kurdish-green" />
|
||||
<span>Max ${tier.max_order_amount.toLocaleString()} per trade</span>
|
||||
</div>
|
||||
{tier.featured_ads_allowed > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-kurdish-green" />
|
||||
<span>{tier.featured_ads_allowed} featured ads</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deposit requirement */}
|
||||
{tier.deposit_required > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
<span>Requires {tier.deposit_required.toLocaleString()} {tier.deposit_token} deposit</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
{canApply && (
|
||||
<Button
|
||||
className="w-full mt-2 bg-kurdish-green hover:bg-kurdish-green-dark"
|
||||
size="sm"
|
||||
onClick={() => openApplyModal(tier.tier)}
|
||||
>
|
||||
Apply for Upgrade
|
||||
<ArrowRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Apply Modal */}
|
||||
<Dialog open={applyModalOpen} onOpenChange={setApplyModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-kurdish-green" />
|
||||
Apply for {selectedTier?.toUpperCase()} Tier
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Submit your application for tier upgrade. Our team will review it shortly.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedTier && (
|
||||
<div className="space-y-4">
|
||||
{/* Requirements check */}
|
||||
<div className="bg-muted p-4 rounded-lg space-y-2">
|
||||
<p className="font-medium text-sm">Requirements Met:</p>
|
||||
{requirements.find(r => r.tier === selectedTier) && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Completed trades requirement</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Completion rate requirement</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>30-day volume requirement</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deposit info */}
|
||||
{(requirements.find(r => r.tier === selectedTier)?.deposit_required ?? 0) > 0 && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
This tier requires a deposit of{' '}
|
||||
<strong>
|
||||
{requirements.find(r => r.tier === selectedTier)?.deposit_required.toLocaleString()}{' '}
|
||||
{requirements.find(r => r.tier === selectedTier)?.deposit_token}
|
||||
</strong>
|
||||
. You will be prompted to complete the deposit after approval.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setApplyModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-kurdish-green hover:bg-kurdish-green-dark"
|
||||
onClick={applyForTier}
|
||||
disabled={applying}
|
||||
>
|
||||
{applying ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Submit Application
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MerchantApplication;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Diamond, Star, Shield } from 'lucide-react';
|
||||
|
||||
export type MerchantTier = 'lite' | 'super' | 'diamond';
|
||||
|
||||
interface MerchantTierBadgeProps {
|
||||
tier: MerchantTier;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
const TIER_CONFIG = {
|
||||
lite: {
|
||||
label: 'Lite',
|
||||
icon: Shield,
|
||||
className: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
iconClassName: 'text-gray-400',
|
||||
description: 'Basic verified trader'
|
||||
},
|
||||
super: {
|
||||
label: 'Super',
|
||||
icon: Star,
|
||||
className: 'bg-yellow-500/20 text-yellow-500 border-yellow-500/30',
|
||||
iconClassName: 'text-yellow-500',
|
||||
description: 'Professional trader with 20+ trades and 90%+ completion rate'
|
||||
},
|
||||
diamond: {
|
||||
label: 'Diamond',
|
||||
icon: Diamond,
|
||||
className: 'bg-purple-500/20 text-purple-500 border-purple-500/30',
|
||||
iconClassName: 'text-purple-500',
|
||||
description: 'Elite merchant with 100+ trades and 95%+ completion rate'
|
||||
}
|
||||
};
|
||||
|
||||
const SIZE_CONFIG = {
|
||||
sm: {
|
||||
badge: 'text-[10px] px-1.5 py-0.5',
|
||||
icon: 'h-3 w-3'
|
||||
},
|
||||
md: {
|
||||
badge: 'text-xs px-2 py-1',
|
||||
icon: 'h-3.5 w-3.5'
|
||||
},
|
||||
lg: {
|
||||
badge: 'text-sm px-3 py-1.5',
|
||||
icon: 'h-4 w-4'
|
||||
}
|
||||
};
|
||||
|
||||
export function MerchantTierBadge({
|
||||
tier,
|
||||
size = 'md',
|
||||
showLabel = true
|
||||
}: MerchantTierBadgeProps) {
|
||||
const config = TIER_CONFIG[tier];
|
||||
const sizeConfig = SIZE_CONFIG[size];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${config.className} ${sizeConfig.badge} gap-1 cursor-help`}
|
||||
>
|
||||
<Icon className={`${sizeConfig.icon} ${config.iconClassName}`} />
|
||||
{showLabel && <span>{config.label}</span>}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">{config.label} Merchant</p>
|
||||
<p className="text-xs text-muted-foreground">{config.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Standalone tier icon for compact displays
|
||||
export function MerchantTierIcon({
|
||||
tier,
|
||||
size = 'md'
|
||||
}: {
|
||||
tier: MerchantTier;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}) {
|
||||
const config = TIER_CONFIG[tier];
|
||||
const sizeConfig = SIZE_CONFIG[size];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help">
|
||||
<Icon className={`${sizeConfig.icon} ${config.iconClassName}`} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">{config.label} Merchant</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default MerchantTierBadge;
|
||||
@@ -0,0 +1,534 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetFooter } from '@/components/ui/sheet';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
ChevronDown,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
Diamond,
|
||||
X,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
|
||||
// Filter options
|
||||
export interface P2PFilters {
|
||||
// Token
|
||||
token: 'HEZ' | 'PEZ' | 'all';
|
||||
|
||||
// Fiat currency
|
||||
fiatCurrency: string | 'all';
|
||||
|
||||
// Payment methods
|
||||
paymentMethods: string[];
|
||||
|
||||
// Amount range
|
||||
minAmount: number | null;
|
||||
maxAmount: number | null;
|
||||
|
||||
// Merchant tier
|
||||
merchantTiers: ('lite' | 'super' | 'diamond')[];
|
||||
|
||||
// Completion rate
|
||||
minCompletionRate: number;
|
||||
|
||||
// Online status
|
||||
onlineOnly: boolean;
|
||||
|
||||
// Verified only
|
||||
verifiedOnly: boolean;
|
||||
|
||||
// Sort
|
||||
sortBy: 'price' | 'completion_rate' | 'trades' | 'newest';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Default filters
|
||||
export const DEFAULT_FILTERS: P2PFilters = {
|
||||
token: 'all',
|
||||
fiatCurrency: 'all',
|
||||
paymentMethods: [],
|
||||
minAmount: null,
|
||||
maxAmount: null,
|
||||
merchantTiers: [],
|
||||
minCompletionRate: 0,
|
||||
onlineOnly: false,
|
||||
verifiedOnly: false,
|
||||
sortBy: 'price',
|
||||
sortOrder: 'asc'
|
||||
};
|
||||
|
||||
// Available fiat currencies
|
||||
const FIAT_CURRENCIES = [
|
||||
{ value: 'TRY', label: 'TRY - Turkish Lira' },
|
||||
{ value: 'EUR', label: 'EUR - Euro' },
|
||||
{ value: 'USD', label: 'USD - US Dollar' },
|
||||
{ value: 'IQD', label: 'IQD - Iraqi Dinar' },
|
||||
{ value: 'IRR', label: 'IRR - Iranian Rial' }
|
||||
];
|
||||
|
||||
// Merchant tiers
|
||||
const MERCHANT_TIERS = [
|
||||
{ value: 'super', label: 'Super', icon: Star, color: 'text-yellow-500' },
|
||||
{ value: 'diamond', label: 'Diamond', icon: Diamond, color: 'text-purple-500' }
|
||||
];
|
||||
|
||||
interface OrderFiltersProps {
|
||||
filters: P2PFilters;
|
||||
onFiltersChange: (filters: P2PFilters) => void;
|
||||
variant?: 'inline' | 'sheet';
|
||||
}
|
||||
|
||||
export function OrderFilters({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
variant = 'inline'
|
||||
}: OrderFiltersProps) {
|
||||
const [localFilters, setLocalFilters] = useState<P2PFilters>(filters);
|
||||
const [paymentMethods, setPaymentMethods] = useState<{ id: string; method_name: string }[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
currency: true,
|
||||
payment: false,
|
||||
merchant: false,
|
||||
amount: false
|
||||
});
|
||||
|
||||
// Fetch payment methods
|
||||
useEffect(() => {
|
||||
const fetchPaymentMethods = async () => {
|
||||
const { data } = await supabase
|
||||
.from('payment_methods')
|
||||
.select('id, method_name')
|
||||
.eq('is_active', true);
|
||||
|
||||
if (data) {
|
||||
setPaymentMethods(data);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPaymentMethods();
|
||||
}, []);
|
||||
|
||||
// Update local filters
|
||||
const updateFilter = <K extends keyof P2PFilters>(key: K, value: P2PFilters[K]) => {
|
||||
setLocalFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Apply filters
|
||||
const applyFilters = () => {
|
||||
onFiltersChange(localFilters);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Reset filters
|
||||
const resetFilters = () => {
|
||||
setLocalFilters(DEFAULT_FILTERS);
|
||||
onFiltersChange(DEFAULT_FILTERS);
|
||||
};
|
||||
|
||||
// Toggle section
|
||||
const toggleSection = (section: keyof typeof expandedSections) => {
|
||||
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
|
||||
};
|
||||
|
||||
// Count active filters
|
||||
const activeFilterCount = () => {
|
||||
let count = 0;
|
||||
if (localFilters.token !== 'all') count++;
|
||||
if (localFilters.fiatCurrency !== 'all') count++;
|
||||
if (localFilters.paymentMethods.length > 0) count++;
|
||||
if (localFilters.minAmount !== null || localFilters.maxAmount !== null) count++;
|
||||
if (localFilters.merchantTiers.length > 0) count++;
|
||||
if (localFilters.minCompletionRate > 0) count++;
|
||||
if (localFilters.onlineOnly) count++;
|
||||
if (localFilters.verifiedOnly) count++;
|
||||
return count;
|
||||
};
|
||||
|
||||
// Filter content
|
||||
const FilterContent = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Token Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Cryptocurrency</Label>
|
||||
<div className="flex gap-2">
|
||||
{['all', 'HEZ', 'PEZ'].map((token) => (
|
||||
<Button
|
||||
key={token}
|
||||
variant={localFilters.token === token ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updateFilter('token', token as P2PFilters['token'])}
|
||||
className={localFilters.token === token ? 'bg-kurdish-green hover:bg-kurdish-green-dark' : ''}
|
||||
>
|
||||
{token === 'all' ? 'All' : token}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fiat Currency */}
|
||||
<Collapsible open={expandedSections.currency} onOpenChange={() => toggleSection('currency')}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full py-2">
|
||||
<Label className="cursor-pointer">Fiat Currency</Label>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${expandedSections.currency ? 'rotate-180' : ''}`} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<Select
|
||||
value={localFilters.fiatCurrency}
|
||||
onValueChange={(value) => updateFilter('fiatCurrency', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Currencies</SelectItem>
|
||||
{FIAT_CURRENCIES.map((currency) => (
|
||||
<SelectItem key={currency.value} value={currency.value}>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<Collapsible open={expandedSections.payment} onOpenChange={() => toggleSection('payment')}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full py-2">
|
||||
<Label className="cursor-pointer">Payment Methods</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{localFilters.paymentMethods.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{localFilters.paymentMethods.length}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${expandedSections.payment ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2 space-y-2">
|
||||
{paymentMethods.map((method) => (
|
||||
<div key={method.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={method.id}
|
||||
checked={localFilters.paymentMethods.includes(method.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateFilter('paymentMethods', [...localFilters.paymentMethods, method.id]);
|
||||
} else {
|
||||
updateFilter('paymentMethods', localFilters.paymentMethods.filter(id => id !== method.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={method.id} className="text-sm cursor-pointer">
|
||||
{method.method_name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Amount Range */}
|
||||
<Collapsible open={expandedSections.amount} onOpenChange={() => toggleSection('amount')}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full py-2">
|
||||
<Label className="cursor-pointer">Amount Range</Label>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${expandedSections.amount ? 'rotate-180' : ''}`} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">Min Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={localFilters.minAmount || ''}
|
||||
onChange={(e) => updateFilter('minAmount', e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Max Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="No limit"
|
||||
value={localFilters.maxAmount || ''}
|
||||
onChange={(e) => updateFilter('maxAmount', e.target.value ? Number(e.target.value) : null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Merchant Tier */}
|
||||
<Collapsible open={expandedSections.merchant} onOpenChange={() => toggleSection('merchant')}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full py-2">
|
||||
<Label className="cursor-pointer">Merchant Tier</Label>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${expandedSections.merchant ? 'rotate-180' : ''}`} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2 space-y-2">
|
||||
{MERCHANT_TIERS.map((tier) => {
|
||||
const Icon = tier.icon;
|
||||
return (
|
||||
<div key={tier.value} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`tier-${tier.value}`}
|
||||
checked={localFilters.merchantTiers.includes(tier.value as 'super' | 'diamond')}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateFilter('merchantTiers', [...localFilters.merchantTiers, tier.value as 'super' | 'diamond']);
|
||||
} else {
|
||||
updateFilter('merchantTiers', localFilters.merchantTiers.filter(t => t !== tier.value));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={`tier-${tier.value}`} className="flex items-center gap-1 text-sm cursor-pointer">
|
||||
<Icon className={`h-4 w-4 ${tier.color}`} />
|
||||
{tier.label}+ only
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Completion Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label>Min Completion Rate: {localFilters.minCompletionRate}%</Label>
|
||||
<Slider
|
||||
value={[localFilters.minCompletionRate]}
|
||||
onValueChange={([value]) => updateFilter('minCompletionRate', value)}
|
||||
max={100}
|
||||
step={5}
|
||||
className="py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggle options */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="online-only"
|
||||
checked={localFilters.onlineOnly}
|
||||
onCheckedChange={(checked) => updateFilter('onlineOnly', !!checked)}
|
||||
/>
|
||||
<label htmlFor="online-only" className="text-sm cursor-pointer">
|
||||
Online traders only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="verified-only"
|
||||
checked={localFilters.verifiedOnly}
|
||||
onCheckedChange={(checked) => updateFilter('verifiedOnly', !!checked)}
|
||||
/>
|
||||
<label htmlFor="verified-only" className="text-sm cursor-pointer">
|
||||
Verified merchants only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="space-y-2">
|
||||
<Label>Sort By</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Select
|
||||
value={localFilters.sortBy}
|
||||
onValueChange={(value) => updateFilter('sortBy', value as P2PFilters['sortBy'])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="price">Price</SelectItem>
|
||||
<SelectItem value="completion_rate">Completion Rate</SelectItem>
|
||||
<SelectItem value="trades">Trade Count</SelectItem>
|
||||
<SelectItem value="newest">Newest</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={localFilters.sortOrder}
|
||||
onValueChange={(value) => updateFilter('sortOrder', value as 'asc' | 'desc')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="asc">Low to High</SelectItem>
|
||||
<SelectItem value="desc">High to Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Sheet variant (mobile)
|
||||
if (variant === 'sheet') {
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
Filters
|
||||
{activeFilterCount() > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{activeFilterCount()}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
Filter Orders
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="py-4 overflow-y-auto max-h-[calc(100vh-200px)]">
|
||||
<FilterContent />
|
||||
</div>
|
||||
<SheetFooter className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t">
|
||||
<Button variant="outline" onClick={() => setIsOpen(false)} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={applyFilters} className="flex-1 bg-kurdish-green hover:bg-kurdish-green-dark">
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Apply Filters
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline variant (desktop)
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<FilterContent />
|
||||
<Button onClick={applyFilters} className="w-full mt-4 bg-kurdish-green hover:bg-kurdish-green-dark">
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Apply Filters
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Quick filter bar for top of listing
|
||||
export function QuickFilterBar({
|
||||
filters,
|
||||
onFiltersChange
|
||||
}: {
|
||||
filters: P2PFilters;
|
||||
onFiltersChange: (filters: P2PFilters) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Token quick select */}
|
||||
<div className="flex gap-1">
|
||||
{['all', 'HEZ', 'PEZ'].map((token) => (
|
||||
<Button
|
||||
key={token}
|
||||
variant={filters.token === token ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onFiltersChange({ ...filters, token: token as P2PFilters['token'] })}
|
||||
className={filters.token === token ? 'bg-kurdish-green hover:bg-kurdish-green-dark' : ''}
|
||||
>
|
||||
{token === 'all' ? 'All' : token}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Currency select */}
|
||||
<Select
|
||||
value={filters.fiatCurrency}
|
||||
onValueChange={(value) => onFiltersChange({ ...filters, fiatCurrency: value })}
|
||||
>
|
||||
<SelectTrigger className="w-[120px] h-9">
|
||||
<SelectValue placeholder="Currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
{FIAT_CURRENCIES.map((currency) => (
|
||||
<SelectItem key={currency.value} value={currency.value}>
|
||||
{currency.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Amount input */}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="I want to trade..."
|
||||
className="w-[150px] h-9"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? Number(e.target.value) : null;
|
||||
onFiltersChange({ ...filters, minAmount: value, maxAmount: value ? value * 1.1 : null });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Advanced filters sheet */}
|
||||
<OrderFilters
|
||||
filters={filters}
|
||||
onFiltersChange={onFiltersChange}
|
||||
variant="sheet"
|
||||
/>
|
||||
|
||||
{/* Active filter badges */}
|
||||
{filters.merchantTiers.length > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Star className="h-3 w-3 text-yellow-500" />
|
||||
{filters.merchantTiers.join(', ')}+
|
||||
<button
|
||||
onClick={() => onFiltersChange({ ...filters, merchantTiers: [] })}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{filters.minCompletionRate > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
{filters.minCompletionRate}%+ rate
|
||||
<button
|
||||
onClick={() => onFiltersChange({ ...filters, minCompletionRate: 0 })}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderFilters;
|
||||
Reference in New Issue
Block a user