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:
2025-12-11 10:39:08 +03:00
parent 7330b2e7a6
commit df58d26893
326 changed files with 5197 additions and 174 deletions
@@ -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;
+534
View File
@@ -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;