feat: integrate P2P trading into Telegram mini app

- Add 8 Supabase edge functions for P2P operations (get-internal-balance,
  get-payment-methods, get-p2p-offers, accept-p2p-offer, get-p2p-trades,
  trade-action, p2p-messages, p2p-dispute)
- Add frontend P2P API layer (src/lib/p2p-api.ts)
- Add 8 P2P components (BalanceCard, OfferList, TradeModal, CreateOfferModal,
  TradeView, TradeChat, DisputeModal, P2P section)
- Embed P2P as internal section in App.tsx instead of external link
- Remove old P2PModal component
- Add ~70 P2P translation keys across all 6 languages
This commit is contained in:
2026-02-26 18:36:35 +03:00
parent 2fbbce192f
commit 31e768de45
26 changed files with 4938 additions and 163 deletions
-85
View File
@@ -1,85 +0,0 @@
import { X, ExternalLink, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/i18n';
interface P2PModalProps {
isOpen: boolean;
onClose: () => void;
onOpenP2P: () => void;
}
export function P2PModal({ isOpen, onClose, onOpenP2P }: P2PModalProps) {
const { t, isRTL } = useTranslation();
if (!isOpen) return null;
const handleOpenP2P = () => {
onOpenP2P();
onClose();
};
// Access steps array directly - t() only works for string values
// We need to get the steps from the translation as individual indexed items
const steps: string[] = [];
for (let i = 0; i < 4; i++) {
const step = t(`p2p.steps.${i}`);
if (step !== `p2p.steps.${i}`) steps.push(step);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div
className={cn(
'relative w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden',
isRTL && 'direction-rtl'
)}
dir={isRTL ? 'rtl' : 'ltr'}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">{t('p2p.title')}</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
<p className="text-sm text-muted-foreground">{t('p2p.subtitle')}</p>
{/* First Time Info */}
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="space-y-2">
<p className="text-sm font-medium text-amber-400">{t('p2p.firstTime')}</p>
<ol className="space-y-1.5">
{steps.map((step, i) => (
<li key={i} className="text-xs text-amber-200/80 flex gap-2">
<span className="font-semibold text-amber-400">{i + 1}.</span>
<span>{step}</span>
</li>
))}
</ol>
</div>
</div>
</div>
{/* Note */}
<p className="text-xs text-muted-foreground">{t('p2p.note')}</p>
</div>
{/* Action Button */}
<div className="p-4 pt-0">
<button
onClick={handleOpenP2P}
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-cyan-500 hover:bg-cyan-600 text-white font-medium rounded-xl transition-colors"
>
<ExternalLink className="w-5 h-5" />
{t('p2p.button')}
</button>
</div>
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useState, useEffect } from 'react';
import { Wallet, Lock, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { getInternalBalance, type InternalBalance } from '@/lib/p2p-api';
interface BalanceCardProps {
onRefresh?: () => void;
}
export function BalanceCard({ onRefresh }: BalanceCardProps) {
const { sessionToken } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact } = useTelegram();
const [balances, setBalances] = useState<InternalBalance[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const fetchBalances = async () => {
if (!sessionToken) return;
try {
const data = await getInternalBalance(sessionToken);
setBalances(data);
} catch (err) {
console.error('Failed to fetch balances:', err);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchBalances();
}, [sessionToken]);
const handleRefresh = () => {
hapticImpact('light');
setRefreshing(true);
fetchBalances();
onRefresh?.();
};
const formatBalance = (val: number) => {
return val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 });
};
return (
<div
className={cn(
'bg-gradient-to-br from-cyan-500/20 to-blue-600/20 rounded-2xl border border-cyan-500/30 p-4',
isRTL && 'direction-rtl'
)}
dir={isRTL ? 'rtl' : 'ltr'}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Wallet className="w-5 h-5 text-cyan-400" />
<h3 className="text-sm font-semibold text-foreground">{t('p2p.internalBalance')}</h3>
</div>
<button
onClick={handleRefresh}
disabled={refreshing}
className="p-1.5 rounded-full hover:bg-muted/50 transition-colors"
>
<RefreshCw className={cn('w-4 h-4 text-muted-foreground', refreshing && 'animate-spin')} />
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-4">
<div className="w-5 h-5 border-2 border-cyan-400 border-t-transparent rounded-full animate-spin" />
</div>
) : balances.length === 0 ? (
<div className="text-center py-3">
<p className="text-xs text-muted-foreground">{t('p2p.noBalance')}</p>
</div>
) : (
<div className="space-y-2">
{balances.map((bal) => (
<div key={bal.token} className="bg-card/50 rounded-xl p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-bold text-foreground">{bal.token}</span>
<span className="text-sm font-bold text-foreground">
{formatBalance(bal.available_balance + bal.locked_balance)}
</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Wallet className="w-3 h-3" />
{t('p2p.available')}: {formatBalance(bal.available_balance)}
</span>
<span className="flex items-center gap-1">
<Lock className="w-3 h-3" />
{t('p2p.locked')}: {formatBalance(bal.locked_balance)}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
+308
View File
@@ -0,0 +1,308 @@
import { useState, useEffect } from 'react';
import { X, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { createP2POffer, getPaymentMethods, getInternalBalance, type PaymentMethod } from '@/lib/p2p-api';
interface CreateOfferModalProps {
isOpen: boolean;
onClose: () => void;
onOfferCreated: () => void;
}
const FIAT_CURRENCIES = ['TRY', 'IQD', 'IRR', 'EUR', 'USD'];
const TOKENS: ('HEZ' | 'PEZ')[] = ['HEZ', 'PEZ'];
const TIME_LIMITS = [15, 30, 45, 60, 90, 120];
export function CreateOfferModal({ isOpen, onClose, onOfferCreated }: CreateOfferModalProps) {
const { sessionToken } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact, hapticNotification } = useTelegram();
const [adType, setAdType] = useState<'buy' | 'sell'>('sell');
const [token, setToken] = useState<'HEZ' | 'PEZ'>('HEZ');
const [amountCrypto, setAmountCrypto] = useState('');
const [fiatCurrency, setFiatCurrency] = useState('TRY');
const [fiatAmount, setFiatAmount] = useState('');
const [paymentMethodId, setPaymentMethodId] = useState('');
const [paymentDetails, setPaymentDetails] = useState('');
const [minOrder, setMinOrder] = useState('');
const [maxOrder, setMaxOrder] = useState('');
const [timeLimit, setTimeLimit] = useState(30);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [availableBalance, setAvailableBalance] = useState(0);
useEffect(() => {
if (!isOpen || !sessionToken) return;
getPaymentMethods(sessionToken, fiatCurrency)
.then(setPaymentMethods)
.catch(console.error);
}, [isOpen, sessionToken, fiatCurrency]);
useEffect(() => {
if (!isOpen || !sessionToken) return;
getInternalBalance(sessionToken)
.then((bals) => {
const bal = bals.find((b) => b.token === token);
setAvailableBalance(bal?.available_balance || 0);
})
.catch(console.error);
}, [isOpen, sessionToken, token]);
if (!isOpen) return null;
const numAmount = parseFloat(amountCrypto) || 0;
const numFiat = parseFloat(fiatAmount) || 0;
const pricePerUnit = numAmount > 0 && numFiat > 0 ? numFiat / numAmount : 0;
const isValid =
numAmount > 0 &&
numFiat > 0 &&
paymentMethodId &&
(adType === 'buy' || numAmount <= availableBalance);
const handleCreate = async () => {
if (!sessionToken || !isValid) return;
setError('');
setLoading(true);
hapticImpact('medium');
try {
await createP2POffer({
sessionToken,
token,
amountCrypto: numAmount,
fiatCurrency,
fiatAmount: numFiat,
paymentMethodId,
paymentDetailsEncrypted: btoa(paymentDetails),
minOrderAmount: parseFloat(minOrder) || undefined,
maxOrderAmount: parseFloat(maxOrder) || undefined,
timeLimitMinutes: timeLimit,
adType,
});
hapticNotification('success');
onOfferCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create offer');
hapticNotification('error');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-sm">
<div
className={cn(
'w-full max-w-md bg-card rounded-t-3xl border-t border-border overflow-hidden',
isRTL && 'direction-rtl'
)}
dir={isRTL ? 'rtl' : 'ltr'}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">{t('p2p.createOffer')}</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-3 max-h-[70vh] overflow-y-auto">
{/* Ad Type Toggle */}
<div className="flex rounded-xl bg-muted p-1">
<button
onClick={() => setAdType('sell')}
className={cn(
'flex-1 py-2 text-sm font-medium rounded-lg transition-colors',
adType === 'sell' ? 'bg-red-500 text-white' : 'text-muted-foreground'
)}
>
{t('p2p.sell')}
</button>
<button
onClick={() => setAdType('buy')}
className={cn(
'flex-1 py-2 text-sm font-medium rounded-lg transition-colors',
adType === 'buy' ? 'bg-green-500 text-white' : 'text-muted-foreground'
)}
>
{t('p2p.buy')}
</button>
</div>
{/* Token + Currency */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.token')}</label>
<select
value={token}
onChange={(e) => setToken(e.target.value as 'HEZ' | 'PEZ')}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border"
>
{TOKENS.map((tk) => (
<option key={tk} value={tk}>{tk}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.fiatCurrency')}</label>
<select
value={fiatCurrency}
onChange={(e) => setFiatCurrency(e.target.value)}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border"
>
{FIAT_CURRENCIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
{/* Amount Crypto */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t('p2p.amount')} ({token})
{adType === 'sell' && (
<span className="text-cyan-400 ml-1">
({t('p2p.available')}: {availableBalance.toLocaleString()})
</span>
)}
</label>
<input
type="number"
value={amountCrypto}
onChange={(e) => setAmountCrypto(e.target.value)}
placeholder="0.00"
className="w-full bg-muted rounded-xl px-4 py-2.5 text-foreground border border-border focus:border-cyan-500 focus:outline-none"
step="any"
/>
</div>
{/* Fiat Amount */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.fiatTotal')} ({fiatCurrency})</label>
<input
type="number"
value={fiatAmount}
onChange={(e) => setFiatAmount(e.target.value)}
placeholder="0.00"
className="w-full bg-muted rounded-xl px-4 py-2.5 text-foreground border border-border focus:border-cyan-500 focus:outline-none"
step="any"
/>
{pricePerUnit > 0 && (
<p className="text-xs text-cyan-400 mt-1">
{t('p2p.pricePerUnit')}: {pricePerUnit.toLocaleString(undefined, { maximumFractionDigits: 2 })} {fiatCurrency}/{token}
</p>
)}
</div>
{/* Payment Method */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.paymentMethod')}</label>
<select
value={paymentMethodId}
onChange={(e) => setPaymentMethodId(e.target.value)}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border"
>
<option value="">{t('p2p.selectPaymentMethod')}</option>
{paymentMethods.map((pm) => (
<option key={pm.id} value={pm.id}>{pm.method_name}</option>
))}
</select>
</div>
{/* Payment Details */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.paymentDetails')}</label>
<textarea
value={paymentDetails}
onChange={(e) => setPaymentDetails(e.target.value)}
placeholder={t('p2p.paymentDetailsPlaceholder')}
rows={2}
className="w-full bg-muted rounded-xl px-4 py-2.5 text-foreground border border-border focus:border-cyan-500 focus:outline-none resize-none text-sm"
/>
</div>
{/* Order Limits */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.minOrder')}</label>
<input
type="number"
value={minOrder}
onChange={(e) => setMinOrder(e.target.value)}
placeholder="0"
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border focus:border-cyan-500 focus:outline-none"
step="any"
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.maxOrder')}</label>
<input
type="number"
value={maxOrder}
onChange={(e) => setMaxOrder(e.target.value)}
placeholder={amountCrypto || '0'}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border focus:border-cyan-500 focus:outline-none"
step="any"
/>
</div>
</div>
{/* Time Limit */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t('p2p.timeLimit')}</label>
<select
value={timeLimit}
onChange={(e) => setTimeLimit(parseInt(e.target.value))}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border"
>
{TIME_LIMITS.map((tl) => (
<option key={tl} value={tl}>{tl} {t('p2p.minutes')}</option>
))}
</select>
</div>
{/* Balance Warning */}
{adType === 'sell' && numAmount > availableBalance && (
<div className="flex items-start gap-2 text-xs text-red-400 bg-red-500/10 rounded-xl p-3">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{t('p2p.insufficientBalance')}</span>
</div>
)}
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400">
{error}
</div>
)}
</div>
{/* Action */}
<div className="p-4 pt-0">
<button
onClick={handleCreate}
disabled={!isValid || loading}
className={cn(
'w-full py-3 rounded-xl font-medium text-white transition-colors',
isValid && !loading
? 'bg-cyan-500 hover:bg-cyan-600'
: 'bg-muted text-muted-foreground cursor-not-allowed'
)}
>
{loading ? t('common.loading') : t('p2p.createOffer')}
</button>
</div>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react';
import { X, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { openDispute } from '@/lib/p2p-api';
interface DisputeModalProps {
isOpen: boolean;
onClose: () => void;
tradeId: string;
onDisputeOpened: () => void;
}
const DISPUTE_CATEGORIES = [
'payment_not_received',
'wrong_amount',
'fake_payment_proof',
'other',
] as const;
export function DisputeModal({ isOpen, onClose, tradeId, onDisputeOpened }: DisputeModalProps) {
const { sessionToken } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact, hapticNotification } = useTelegram();
const [category, setCategory] = useState<string>('');
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
if (!isOpen) return null;
const isValid = category && reason.trim().length >= 10;
const handleSubmit = async () => {
if (!sessionToken || !isValid) return;
setError('');
setLoading(true);
hapticImpact('heavy');
try {
await openDispute(sessionToken, tradeId, reason.trim(), category);
hapticNotification('warning');
onDisputeOpened();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to open dispute');
hapticNotification('error');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<div
className={cn(
'w-full max-w-md bg-card rounded-2xl shadow-xl border border-border overflow-hidden',
isRTL && 'direction-rtl'
)}
dir={isRTL ? 'rtl' : 'ltr'}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-400" />
<h2 className="text-lg font-semibold text-foreground">{t('p2p.openDispute')}</h2>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Warning */}
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-3">
<p className="text-xs text-amber-400">{t('p2p.disputeWarning')}</p>
</div>
{/* Category */}
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t('p2p.disputeCategory')}</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-muted rounded-xl px-3 py-2.5 text-sm text-foreground border border-border"
>
<option value="">{t('p2p.selectCategory')}</option>
{DISPUTE_CATEGORIES.map((cat) => (
<option key={cat} value={cat}>{t(`p2p.dispute.${cat}`)}</option>
))}
</select>
</div>
{/* Reason */}
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t('p2p.disputeReason')}</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('p2p.disputeReasonPlaceholder')}
rows={3}
className="w-full bg-muted rounded-xl px-4 py-2.5 text-foreground border border-border focus:border-amber-500 focus:outline-none resize-none text-sm"
maxLength={2000}
/>
<p className="text-xs text-muted-foreground mt-1">
{reason.length}/2000 ({t('p2p.minChars', { count: '10' })})
</p>
</div>
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400">
{error}
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 p-4 pt-0">
<button
onClick={onClose}
className="flex-1 py-3 bg-muted hover:bg-muted/80 text-foreground font-medium rounded-xl transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!isValid || loading}
className={cn(
'flex-1 py-3 font-medium rounded-xl transition-colors text-white',
isValid && !loading
? 'bg-amber-500 hover:bg-amber-600'
: 'bg-muted text-muted-foreground cursor-not-allowed'
)}
>
{loading ? t('common.loading') : t('p2p.submitDispute')}
</button>
</div>
</div>
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import { Star, Clock, ChevronDown, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { getP2POffers, type P2POffer } from '@/lib/p2p-api';
interface OfferListProps {
adType: 'buy' | 'sell';
onAcceptOffer: (offer: P2POffer) => void;
}
const FIAT_CURRENCIES = ['TRY', 'IQD', 'IRR', 'EUR', 'USD'];
const TOKENS = ['HEZ', 'PEZ'];
export function OfferList({ adType, onAcceptOffer }: OfferListProps) {
const { sessionToken } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact } = useTelegram();
const [offers, setOffers] = useState<P2POffer[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [selectedCurrency, setSelectedCurrency] = useState<string>('');
const [selectedToken, setSelectedToken] = useState<string>('');
const [showFilters, setShowFilters] = useState(false);
const limit = 20;
const fetchOffers = async (p = 1) => {
if (!sessionToken) return;
setLoading(true);
try {
const result = await getP2POffers({
sessionToken,
adType,
token: selectedToken || undefined,
fiatCurrency: selectedCurrency || undefined,
page: p,
limit,
});
setOffers(result.offers);
setTotal(result.total);
setPage(p);
} catch (err) {
console.error('Failed to fetch offers:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchOffers(1);
}, [sessionToken, adType, selectedCurrency, selectedToken]);
const handleAccept = (offer: P2POffer) => {
hapticImpact('medium');
onAcceptOffer(offer);
};
const totalPages = Math.ceil(total / limit);
return (
<div className={cn(isRTL && 'direction-rtl')} dir={isRTL ? 'rtl' : 'ltr'}>
{/* Filters Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-1 text-xs text-muted-foreground mb-2 hover:text-foreground transition-colors"
>
<ChevronDown className={cn('w-3 h-3 transition-transform', showFilters && 'rotate-180')} />
{t('p2p.filters')}
</button>
{showFilters && (
<div className="flex flex-wrap gap-2 mb-3">
<select
value={selectedToken}
onChange={(e) => setSelectedToken(e.target.value)}
className="text-xs bg-muted rounded-lg px-2 py-1.5 border border-border text-foreground"
>
<option value="">{t('p2p.allTokens')}</option>
{TOKENS.map((tk) => (
<option key={tk} value={tk}>{tk}</option>
))}
</select>
<select
value={selectedCurrency}
onChange={(e) => setSelectedCurrency(e.target.value)}
className="text-xs bg-muted rounded-lg px-2 py-1.5 border border-border text-foreground"
>
<option value="">{t('p2p.allCurrencies')}</option>
{FIAT_CURRENCIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
)}
{/* Offer List */}
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-primary animate-spin" />
</div>
) : offers.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-muted-foreground">{t('p2p.noOffers')}</p>
</div>
) : (
<div className="space-y-2">
{offers.map((offer) => (
<div
key={offer.id}
className="bg-card rounded-xl border border-border p-3 space-y-2"
>
{/* Top: Token + Price */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-foreground">{offer.token}</span>
<span className="text-xs text-muted-foreground">/ {offer.fiat_currency}</span>
</div>
<span className="text-sm font-bold text-cyan-400">
{offer.price_per_unit?.toLocaleString(undefined, { maximumFractionDigits: 2 })} {offer.fiat_currency}
</span>
</div>
{/* Middle: Amount + Limits */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{t('p2p.available')}: {offer.remaining_amount} {offer.token}
</span>
<span>
{offer.min_order_amount && `${t('p2p.min')}: ${offer.min_order_amount}`}
{offer.min_order_amount && offer.max_order_amount && ' - '}
{offer.max_order_amount && `${t('p2p.max')}: ${offer.max_order_amount}`}
</span>
</div>
{/* Bottom: Reputation + Payment + Action */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs">
{offer.seller_reputation && (
<span className="flex items-center gap-1 text-amber-400">
<Star className="w-3 h-3 fill-current" />
{offer.seller_reputation.completed_trades} {t('p2p.trades')}
</span>
)}
{offer.payment_method_name && (
<span className="text-muted-foreground">{offer.payment_method_name}</span>
)}
<span className="flex items-center gap-1 text-muted-foreground">
<Clock className="w-3 h-3" />
{offer.time_limit_minutes}m
</span>
</div>
<button
onClick={() => handleAccept(offer)}
className={cn(
'px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
adType === 'buy'
? 'bg-green-500 hover:bg-green-600 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
)}
>
{adType === 'buy' ? t('p2p.buy') : t('p2p.sell')}
</button>
</div>
</div>
))}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<button
onClick={() => fetchOffers(page - 1)}
disabled={page <= 1}
className="px-3 py-1 text-xs bg-muted rounded-lg disabled:opacity-50"
>
{t('p2p.prev')}
</button>
<span className="text-xs text-muted-foreground">{page} / {totalPages}</span>
<button
onClick={() => fetchOffers(page + 1)}
disabled={page >= totalPages}
className="px-3 py-1 text-xs bg-muted rounded-lg disabled:opacity-50"
>
{t('p2p.next')}
</button>
</div>
)}
</div>
)}
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { useState, useEffect, useRef } from 'react';
import { X, Send } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { getTradeMessages, sendTradeMessage, type P2PMessage } from '@/lib/p2p-api';
interface TradeChatProps {
tradeId: string;
onClose: () => void;
}
export function TradeChat({ tradeId, onClose }: TradeChatProps) {
const { sessionToken, user } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact } = useTelegram();
const [messages, setMessages] = useState<P2PMessage[]>([]);
const [newMessage, setNewMessage] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const userId = user?.id;
const fetchMessages = async () => {
if (!sessionToken) return;
try {
const msgs = await getTradeMessages(sessionToken, tradeId);
setMessages(msgs);
} catch (err) {
console.error('Failed to fetch messages:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMessages();
const interval = setInterval(fetchMessages, 5000);
return () => clearInterval(interval);
}, [sessionToken, tradeId]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = async () => {
if (!sessionToken || !newMessage.trim() || sending) return;
hapticImpact('light');
setSending(true);
try {
await sendTradeMessage(sessionToken, tradeId, newMessage.trim());
setNewMessage('');
fetchMessages();
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setSending(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
{/* Header */}
<div className={cn(
'flex items-center justify-between p-4 border-b border-border safe-area-top',
isRTL && 'direction-rtl'
)} dir={isRTL ? 'rtl' : 'ltr'}>
<h2 className="text-lg font-semibold text-foreground">{t('p2p.chat')}</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : messages.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-muted-foreground">{t('p2p.noMessages')}</p>
</div>
) : (
messages.map((msg) => {
const isOwn = msg.sender_id === userId;
const isSystem = msg.message_type === 'system';
return (
<div
key={msg.id}
className={cn(
'flex',
isSystem ? 'justify-center' :
isOwn ? 'justify-end' : 'justify-start'
)}
>
{isSystem ? (
<span className="text-xs text-muted-foreground bg-muted/50 rounded-full px-3 py-1">
{msg.message}
</span>
) : (
<div
className={cn(
'max-w-[75%] rounded-2xl px-3 py-2',
isOwn
? 'bg-cyan-500 text-white rounded-br-sm'
: 'bg-muted text-foreground rounded-bl-sm'
)}
>
<p className="text-sm break-words">{msg.message}</p>
<p className={cn(
'text-[10px] mt-1',
isOwn ? 'text-white/60' : 'text-muted-foreground'
)}>
{new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
)}
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className={cn(
'flex items-center gap-2 p-4 border-t border-border safe-area-bottom',
isRTL && 'direction-rtl'
)} dir={isRTL ? 'rtl' : 'ltr'}>
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('p2p.messagePlaceholder')}
className="flex-1 bg-muted rounded-full px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 border border-border focus:border-cyan-500 focus:outline-none"
maxLength={2000}
/>
<button
onClick={handleSend}
disabled={!newMessage.trim() || sending}
className="p-2.5 bg-cyan-500 hover:bg-cyan-600 rounded-full transition-colors disabled:opacity-50"
>
<Send className="w-4 h-4 text-white" />
</button>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useState } from 'react';
import { X, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useWallet } from '@/contexts/WalletContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import { acceptP2POffer, type P2POffer } from '@/lib/p2p-api';
interface TradeModalProps {
isOpen: boolean;
onClose: () => void;
offer: P2POffer | null;
onTradeCreated: (tradeId: string) => void;
}
export function TradeModal({ isOpen, onClose, offer, onTradeCreated }: TradeModalProps) {
const { sessionToken } = useAuth();
const { address } = useWallet();
const { t, isRTL } = useTranslation();
const { hapticImpact, hapticNotification } = useTelegram();
const [amount, setAmount] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
if (!isOpen || !offer) return null;
const numAmount = parseFloat(amount) || 0;
const fiatTotal = numAmount > 0 && offer.price_per_unit
? numAmount * offer.price_per_unit
: 0;
const isValid =
numAmount > 0 &&
numAmount <= offer.remaining_amount &&
(!offer.min_order_amount || numAmount >= offer.min_order_amount) &&
(!offer.max_order_amount || numAmount <= offer.max_order_amount);
const handleAccept = async () => {
if (!sessionToken || !address || !isValid) return;
setError('');
setLoading(true);
hapticImpact('medium');
try {
const result = await acceptP2POffer({
sessionToken,
offerId: offer.id,
amount: numAmount,
buyerWallet: address,
});
hapticNotification('success');
onTradeCreated(result.tradeId);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to accept offer');
hapticNotification('error');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-sm">
<div
className={cn(
'w-full max-w-md bg-card rounded-t-3xl border-t border-border overflow-hidden animate-in slide-in-from-bottom',
isRTL && 'direction-rtl'
)}
dir={isRTL ? 'rtl' : 'ltr'}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">{t('p2p.acceptOffer')}</h2>
<button onClick={onClose} className="p-2 rounded-full hover:bg-muted transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Offer Summary */}
<div className="bg-muted/50 rounded-xl p-3 space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.token')}</span>
<span className="font-medium text-foreground">{offer.token}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.pricePerUnit')}</span>
<span className="font-medium text-foreground">
{offer.price_per_unit?.toLocaleString()} {offer.fiat_currency}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.available')}</span>
<span className="font-medium text-foreground">
{offer.remaining_amount} {offer.token}
</span>
</div>
{offer.payment_method_name && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.paymentMethod')}</span>
<span className="font-medium text-foreground">{offer.payment_method_name}</span>
</div>
)}
</div>
{/* Amount Input */}
<div>
<label className="text-sm text-muted-foreground mb-1 block">{t('p2p.amount')} ({offer.token})</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder={`${offer.min_order_amount || 0} - ${offer.max_order_amount || offer.remaining_amount}`}
className="w-full bg-muted rounded-xl px-4 py-3 text-foreground placeholder:text-muted-foreground/50 border border-border focus:border-cyan-500 focus:outline-none"
step="any"
min={offer.min_order_amount || 0}
max={offer.max_order_amount || offer.remaining_amount}
/>
<button
onClick={() => setAmount(String(offer.max_order_amount || offer.remaining_amount))}
className="text-xs text-cyan-400 mt-1 hover:text-cyan-300"
>
{t('p2p.max')}: {offer.max_order_amount || offer.remaining_amount} {offer.token}
</button>
</div>
{/* Fiat Total */}
{fiatTotal > 0 && (
<div className="bg-cyan-500/10 border border-cyan-500/30 rounded-xl p-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">{t('p2p.fiatTotal')}</span>
<span className="text-lg font-bold text-cyan-400">
{fiatTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })} {offer.fiat_currency}
</span>
</div>
</div>
)}
{/* Time Limit Warning */}
<div className="flex items-start gap-2 text-xs text-amber-400/80">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{t('p2p.timeLimitWarning', { minutes: String(offer.time_limit_minutes) })}</span>
</div>
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400">
{error}
</div>
)}
</div>
{/* Action */}
<div className="p-4 pt-0">
<button
onClick={handleAccept}
disabled={!isValid || loading}
className={cn(
'w-full py-3 rounded-xl font-medium text-white transition-colors',
isValid && !loading
? 'bg-green-500 hover:bg-green-600'
: 'bg-muted text-muted-foreground cursor-not-allowed'
)}
>
{loading ? t('common.loading') : t('p2p.confirmAccept')}
</button>
</div>
</div>
</div>
);
}
+342
View File
@@ -0,0 +1,342 @@
import { useState, useEffect, useCallback } from 'react';
import { ArrowLeft, Clock, CheckCircle2, XCircle, AlertTriangle, MessageCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
import { useTranslation } from '@/i18n';
import { useTelegram } from '@/hooks/useTelegram';
import {
getP2PTrades,
markTradePaid,
confirmTradePayment,
cancelTrade,
type P2PTrade,
} from '@/lib/p2p-api';
import { TradeChat } from './TradeChat';
import { DisputeModal } from './DisputeModal';
interface TradeViewProps {
tradeId?: string;
onBack: () => void;
}
const STATUS_CONFIG: Record<string, { color: string; icon: typeof Clock }> = {
pending: { color: 'text-amber-400', icon: Clock },
payment_sent: { color: 'text-blue-400', icon: Clock },
completed: { color: 'text-green-400', icon: CheckCircle2 },
cancelled: { color: 'text-red-400', icon: XCircle },
disputed: { color: 'text-orange-400', icon: AlertTriangle },
refunded: { color: 'text-gray-400', icon: XCircle },
};
export function TradeView({ tradeId, onBack }: TradeViewProps) {
const { sessionToken, user } = useAuth();
const { t, isRTL } = useTranslation();
const { hapticImpact, hapticNotification } = useTelegram();
const [trade, setTrade] = useState<P2PTrade | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [showChat, setShowChat] = useState(false);
const [showDispute, setShowDispute] = useState(false);
const [error, setError] = useState('');
const userId = user?.id;
const isBuyer = trade?.buyer_id === userId;
const isSeller = trade?.seller_id === userId;
const fetchTrade = useCallback(async () => {
if (!sessionToken || !tradeId) return;
try {
const trades = await getP2PTrades(sessionToken, 'all');
const found = trades.find((tr) => tr.id === tradeId);
if (found) setTrade(found);
} catch (err) {
console.error('Failed to fetch trade:', err);
} finally {
setLoading(false);
}
}, [sessionToken, tradeId]);
useEffect(() => {
fetchTrade();
const interval = setInterval(fetchTrade, 15000);
return () => clearInterval(interval);
}, [fetchTrade]);
// Countdown timer
const [timeRemaining, setTimeRemaining] = useState('');
useEffect(() => {
if (!trade) return;
const deadline = trade.status === 'pending' ? trade.payment_deadline :
trade.status === 'payment_sent' ? trade.confirmation_deadline : null;
if (!deadline) { setTimeRemaining(''); return; }
const update = () => {
const diff = new Date(deadline).getTime() - Date.now();
if (diff <= 0) { setTimeRemaining(t('p2p.expired')); return; }
const mins = Math.floor(diff / 60000);
const secs = Math.floor((diff % 60000) / 1000);
setTimeRemaining(`${mins}:${secs.toString().padStart(2, '0')}`);
};
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [trade, t]);
const handleMarkPaid = async () => {
if (!sessionToken || !trade) return;
setActionLoading(true);
setError('');
hapticImpact('medium');
try {
await markTradePaid(sessionToken, trade.id);
hapticNotification('success');
fetchTrade();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
hapticNotification('error');
} finally {
setActionLoading(false);
}
};
const handleConfirm = async () => {
if (!sessionToken || !trade) return;
setActionLoading(true);
setError('');
hapticImpact('heavy');
try {
await confirmTradePayment(sessionToken, trade.id);
hapticNotification('success');
fetchTrade();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
hapticNotification('error');
} finally {
setActionLoading(false);
}
};
const handleCancel = async () => {
if (!sessionToken || !trade) return;
setActionLoading(true);
setError('');
hapticImpact('medium');
try {
await cancelTrade(sessionToken, trade.id);
hapticNotification('success');
fetchTrade();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
hapticNotification('error');
} finally {
setActionLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!trade) {
return (
<div className="text-center py-8">
<p className="text-sm text-muted-foreground">{t('p2p.tradeNotFound')}</p>
<button onClick={onBack} className="text-cyan-400 text-sm mt-2">{t('common.back')}</button>
</div>
);
}
const statusConfig = STATUS_CONFIG[trade.status] || STATUS_CONFIG.pending;
const StatusIcon = statusConfig.icon;
return (
<div className={cn('space-y-4', isRTL && 'direction-rtl')} dir={isRTL ? 'rtl' : 'ltr'}>
{/* Header */}
<div className="flex items-center gap-3">
<button onClick={onBack} className="p-2 rounded-full hover:bg-muted transition-colors">
<ArrowLeft className="w-5 h-5 text-muted-foreground" />
</button>
<h2 className="text-lg font-semibold text-foreground">{t('p2p.tradeDetails')}</h2>
</div>
{/* Status Banner */}
<div className={cn('flex items-center gap-2 p-3 rounded-xl', `${statusConfig.color} bg-current/10`)}>
<StatusIcon className={cn('w-5 h-5', statusConfig.color)} />
<span className={cn('text-sm font-medium', statusConfig.color)}>
{t(`p2p.status.${trade.status}`)}
</span>
{timeRemaining && (
<span className={cn('text-xs ml-auto', statusConfig.color)}>
{t('p2p.timeRemaining')}: {timeRemaining}
</span>
)}
</div>
{/* Trade Info */}
<div className="bg-card rounded-xl border border-border p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.role')}</span>
<span className="font-medium text-foreground">
{isBuyer ? t('p2p.buyer') : t('p2p.seller')}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.cryptoAmount')}</span>
<span className="font-medium text-foreground">
{trade.crypto_amount} {trade.token || ''}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.fiatAmount')}</span>
<span className="font-medium text-foreground">
{trade.fiat_amount?.toLocaleString()} {trade.fiat_currency || ''}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{t('p2p.pricePerUnit')}</span>
<span className="font-medium text-foreground">
{trade.price_per_unit?.toLocaleString()} {trade.fiat_currency || ''}
</span>
</div>
</div>
{/* Timeline */}
<div className="bg-card rounded-xl border border-border p-4 space-y-3">
<h3 className="text-sm font-medium text-foreground">{t('p2p.timeline')}</h3>
<TimelineStep
done
label={t('p2p.tradeCreated')}
time={trade.created_at}
/>
<TimelineStep
done={!!trade.buyer_marked_paid_at}
active={trade.status === 'pending'}
label={t('p2p.paymentSent')}
time={trade.buyer_marked_paid_at}
/>
<TimelineStep
done={!!trade.seller_confirmed_at}
active={trade.status === 'payment_sent'}
label={t('p2p.paymentConfirmed')}
time={trade.seller_confirmed_at}
/>
<TimelineStep
done={trade.status === 'completed'}
label={t('p2p.tradeCompleted')}
time={trade.completed_at}
/>
</div>
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400">
{error}
</div>
)}
{/* Action Buttons */}
<div className="space-y-2">
{/* Buyer: Mark as paid */}
{isBuyer && trade.status === 'pending' && (
<button
onClick={handleMarkPaid}
disabled={actionLoading}
className="w-full py-3 bg-blue-500 hover:bg-blue-600 text-white font-medium rounded-xl transition-colors disabled:opacity-50"
>
{actionLoading ? t('common.loading') : t('p2p.markPaid')}
</button>
)}
{/* Seller: Confirm payment received */}
{isSeller && trade.status === 'payment_sent' && (
<button
onClick={handleConfirm}
disabled={actionLoading}
className="w-full py-3 bg-green-500 hover:bg-green-600 text-white font-medium rounded-xl transition-colors disabled:opacity-50"
>
{actionLoading ? t('common.loading') : t('p2p.confirmReceived')}
</button>
)}
{/* Cancel (buyer only, pending status) */}
{isBuyer && trade.status === 'pending' && (
<button
onClick={handleCancel}
disabled={actionLoading}
className="w-full py-3 bg-red-500/10 hover:bg-red-500/20 text-red-400 font-medium rounded-xl transition-colors disabled:opacity-50"
>
{t('p2p.cancelTrade')}
</button>
)}
{/* Chat */}
{['pending', 'payment_sent', 'disputed'].includes(trade.status) && (
<button
onClick={() => setShowChat(true)}
className="w-full py-3 bg-muted hover:bg-muted/80 text-foreground font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
>
<MessageCircle className="w-4 h-4" />
{t('p2p.chat')}
</button>
)}
{/* Dispute */}
{['pending', 'payment_sent'].includes(trade.status) && (
<button
onClick={() => setShowDispute(true)}
className="w-full py-2 text-sm text-amber-400 hover:text-amber-300 transition-colors"
>
{t('p2p.openDispute')}
</button>
)}
</div>
{/* Chat Modal */}
{showChat && (
<TradeChat tradeId={trade.id} onClose={() => setShowChat(false)} />
)}
{/* Dispute Modal */}
<DisputeModal
isOpen={showDispute}
onClose={() => setShowDispute(false)}
tradeId={trade.id}
onDisputeOpened={() => { setShowDispute(false); fetchTrade(); }}
/>
</div>
);
}
// Timeline step sub-component
function TimelineStep({ done, active, label, time }: {
done?: boolean;
active?: boolean;
label: string;
time?: string | null;
}) {
return (
<div className="flex items-center gap-3">
<div
className={cn(
'w-3 h-3 rounded-full border-2 flex-shrink-0',
done ? 'bg-green-400 border-green-400' :
active ? 'border-cyan-400 animate-pulse' :
'border-muted-foreground/30'
)}
/>
<div className="flex-1">
<p className={cn('text-sm', done ? 'text-foreground' : 'text-muted-foreground')}>{label}</p>
{time && (
<p className="text-xs text-muted-foreground">
{new Date(time).toLocaleString()}
</p>
)}
</div>
</div>
);
}