[SPRINT-6] feat: Agregar paginas Tokens y CodiSpei

MCH-019 - Tienda de Tokens:
- Tokens.tsx con balance, paquetes y historial de uso
- Integracion con billingApi para checkout Stripe
- Visualizacion de costos por servicio

MCH-024 - CoDi/SPEI:
- CodiSpei.tsx con tabs CoDi y SPEI
- Generacion de QR CoDi para cobros
- Visualizacion de CLABE virtual
- Historial de transacciones

Actualizaciones:
- App.tsx: Rutas /tokens y /codi-spei
- Layout.tsx: Enlaces en navegacion
- api.ts: billingApi y subscriptionsApi

Sprint 6 - Frontend completado

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 04:32:43 -06:00
parent 2ae94a679f
commit 00691fd1f7
5 changed files with 822 additions and 0 deletions

View File

@ -12,6 +12,8 @@ import { Settings } from './pages/Settings';
import { Referrals } from './pages/Referrals';
import { Invoices } from './pages/Invoices';
import { Marketplace } from './pages/Marketplace';
import { Tokens } from './pages/Tokens';
import { CodiSpei } from './pages/CodiSpei';
import Login from './pages/Login';
import Register from './pages/Register';
@ -79,6 +81,8 @@ function App() {
<Route path="referrals" element={<Referrals />} />
<Route path="invoices" element={<Invoices />} />
<Route path="marketplace" element={<Marketplace />} />
<Route path="tokens" element={<Tokens />} />
<Route path="codi-spei" element={<CodiSpei />} />
<Route path="settings" element={<Settings />} />
</Route>
</Route>

View File

@ -14,6 +14,8 @@ import {
Gift,
FileText,
Truck,
Coins,
QrCode,
} from 'lucide-react';
import { useState } from 'react';
import clsx from 'clsx';
@ -26,8 +28,10 @@ const navigation = [
{ name: 'Clientes', href: '/customers', icon: Users },
{ name: 'Fiado', href: '/fiado', icon: CreditCard },
{ name: 'Inventario', href: '/inventory', icon: Boxes },
{ name: 'CoDi/SPEI', href: '/codi-spei', icon: QrCode },
{ name: 'Facturacion', href: '/invoices', icon: FileText },
{ name: 'Proveedores', href: '/marketplace', icon: Truck },
{ name: 'Tokens', href: '/tokens', icon: Coins },
{ name: 'Referidos', href: '/referrals', icon: Gift },
{ name: 'Ajustes', href: '/settings', icon: Settings },
];

View File

@ -214,3 +214,40 @@ export const marketplaceApi = {
// Stats
getStats: () => api.get('/marketplace/stats'),
};
// Billing API (Subscriptions & Tokens)
export const billingApi = {
// Plans
getPlans: () => api.get('/billing/plans'),
// Token Packages
getTokenPackages: () => api.get('/billing/token-packages'),
// Balance & Usage
getTokenBalance: () => api.get('/billing/token-balance'),
getTokenUsage: (limit?: number) =>
api.get('/billing/token-usage', { params: { limit } }),
getBillingSummary: () => api.get('/billing/summary'),
// Checkout
createSubscriptionCheckout: (planCode: string, urls: { successUrl: string; cancelUrl: string }) =>
api.post('/billing/checkout/subscription', { planCode, ...urls }),
purchaseTokens: (packageCode: string, urls: { successUrl: string; cancelUrl: string }) =>
api.post('/billing/checkout/tokens', { packageCode, ...urls }),
// Portal
createPortalSession: (returnUrl: string) =>
api.post('/billing/portal', { returnUrl }),
};
// Subscriptions API
export const subscriptionsApi = {
getPlans: () => api.get('/subscriptions/plans'),
getPlan: (code: string) => api.get(`/subscriptions/plans/${code}`),
getCurrent: () => api.get('/subscriptions/current'),
getStats: () => api.get('/subscriptions/stats'),
cancel: () => api.post('/subscriptions/cancel'),
getTokenBalance: () => api.get('/subscriptions/tokens/balance'),
getTokenUsage: (limit?: number) =>
api.get('/subscriptions/tokens/usage', { params: { limit } }),
};

449
src/pages/CodiSpei.tsx Normal file
View File

@ -0,0 +1,449 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { QrCode, CreditCard, Copy, Check, Clock, ArrowDownLeft, RefreshCw, Plus, Smartphone } from 'lucide-react';
import { codiSpeiApi } from '../lib/api';
interface CodiTransaction {
id: string;
amount: number;
status: 'pending' | 'completed' | 'expired' | 'cancelled';
description?: string;
qrCode?: string;
createdAt: string;
expiresAt?: string;
}
interface SpeiInfo {
clabe: string;
beneficiaryName: string;
bankName: string;
createdAt: string;
}
interface SpeiTransaction {
id: string;
amount: number;
senderName: string;
senderBank: string;
reference: string;
createdAt: string;
}
export function CodiSpei() {
const [activeTab, setActiveTab] = useState<'codi' | 'spei'>('codi');
const [qrAmount, setQrAmount] = useState('');
const [qrDescription, setQrDescription] = useState('');
const [copied, setCopied] = useState(false);
const queryClient = useQueryClient();
// CoDi Queries
const { data: codiTransactions, isLoading: codiLoading } = useQuery<CodiTransaction[]>({
queryKey: ['codi-transactions'],
queryFn: async () => {
const res = await codiSpeiApi.getCodiTransactions(20);
return res.data;
},
});
// SPEI Queries
const { data: speiInfo, isLoading: speiLoading } = useQuery<SpeiInfo>({
queryKey: ['spei-clabe'],
queryFn: async () => {
const res = await codiSpeiApi.getClabe();
return res.data;
},
});
const { data: speiTransactions } = useQuery<SpeiTransaction[]>({
queryKey: ['spei-transactions'],
queryFn: async () => {
const res = await codiSpeiApi.getSpeiTransactions(20);
return res.data;
},
});
// Mutations
const generateQrMutation = useMutation({
mutationFn: async () => {
const res = await codiSpeiApi.generateQr({
amount: parseFloat(qrAmount),
description: qrDescription || undefined,
});
return res.data;
},
onSuccess: () => {
setQrAmount('');
setQrDescription('');
queryClient.invalidateQueries({ queryKey: ['codi-transactions'] });
},
});
const createClabeMutation = useMutation({
mutationFn: async () => {
const res = await codiSpeiApi.createClabe('MiChangarrito');
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['spei-clabe'] });
},
});
const copyClabe = async () => {
if (speiInfo?.clabe) {
await navigator.clipboard.writeText(speiInfo.clabe);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<Clock className="h-3 w-3" />
Pendiente
</span>
);
case 'completed':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<Check className="h-3 w-3" />
Pagado
</span>
);
case 'expired':
return (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Expirado
</span>
);
default:
return null;
}
};
const isLoading = codiLoading || speiLoading;
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">CoDi y SPEI</h1>
<p className="text-gray-500">Recibe pagos con QR CoDi o transferencia SPEI</p>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b">
<button
onClick={() => setActiveTab('codi')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
activeTab === 'codi'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<QrCode className="inline h-5 w-5 mr-2" />
CoDi (QR)
</button>
<button
onClick={() => setActiveTab('spei')}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
activeTab === 'spei'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<CreditCard className="inline h-5 w-5 mr-2" />
SPEI (CLABE)
</button>
</div>
{activeTab === 'codi' && (
<>
{/* Generate QR */}
<div className="card">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
<Plus className="h-5 w-5" />
Generar QR de Cobro
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Monto a cobrar *
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
value={qrAmount}
onChange={(e) => setQrAmount(e.target.value)}
placeholder="0.00"
className="input pl-8"
min="1"
step="0.01"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Descripcion (opcional)
</label>
<input
type="text"
value={qrDescription}
onChange={(e) => setQrDescription(e.target.value)}
placeholder="Venta de productos"
className="input"
/>
</div>
</div>
<button
onClick={() => generateQrMutation.mutate()}
disabled={!qrAmount || generateQrMutation.isPending}
className="btn-primary mt-4 flex items-center gap-2"
>
{generateQrMutation.isPending ? (
<>
<RefreshCw className="h-5 w-5 animate-spin" />
Generando...
</>
) : (
<>
<QrCode className="h-5 w-5" />
Generar QR
</>
)}
</button>
</div>
{/* How it works */}
<div className="card bg-blue-50 border-blue-200">
<h4 className="font-medium text-blue-900 mb-2 flex items-center gap-2">
<Smartphone className="h-5 w-5" />
Como funciona CoDi
</h4>
<ol className="list-decimal list-inside text-sm text-blue-800 space-y-1">
<li>Genera un QR con el monto a cobrar</li>
<li>El cliente escanea el QR con su app bancaria</li>
<li>El pago se refleja en segundos en tu cuenta</li>
<li>Sin comisiones - es gratis</li>
</ol>
</div>
{/* CoDi Transactions */}
<div className="card">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
<QrCode className="h-5 w-5" />
Cobros CoDi Recientes
</h3>
{codiTransactions && codiTransactions.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-sm text-gray-500 border-b">
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium">Monto</th>
<th className="pb-3 font-medium">Descripcion</th>
<th className="pb-3 font-medium">Estado</th>
</tr>
</thead>
<tbody className="divide-y">
{codiTransactions.map((tx) => (
<tr key={tx.id}>
<td className="py-3 text-sm">
{new Date(tx.createdAt).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</td>
<td className="py-3 font-medium">
${tx.amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</td>
<td className="py-3 text-sm text-gray-600">
{tx.description || '-'}
</td>
<td className="py-3">{getStatusBadge(tx.status)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<QrCode className="h-12 w-12 mx-auto mb-3 text-gray-300" />
<p>No hay cobros CoDi aun</p>
<p className="text-sm">Genera un QR para empezar a cobrar</p>
</div>
)}
</div>
</>
)}
{activeTab === 'spei' && (
<>
{/* CLABE Card */}
{speiInfo ? (
<div className="card bg-gradient-to-r from-indigo-500 to-purple-600 text-white">
<div className="flex items-center gap-3 mb-4">
<CreditCard className="h-8 w-8" />
<div>
<h2 className="text-xl font-bold">Tu CLABE Virtual</h2>
<p className="text-indigo-100">Recibe transferencias SPEI directamente</p>
</div>
</div>
<div className="bg-white/10 rounded-lg p-4 mb-4">
<p className="text-sm text-indigo-100 mb-2">CLABE:</p>
<div className="flex items-center gap-3">
<span className="text-2xl font-mono font-bold tracking-wider">
{speiInfo.clabe}
</span>
<button
onClick={copyClabe}
className="p-2 rounded-lg bg-white/20 hover:bg-white/30 transition-colors"
title="Copiar CLABE"
>
{copied ? <Check className="h-5 w-5" /> : <Copy className="h-5 w-5" />}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-indigo-100">Beneficiario:</p>
<p className="font-medium">{speiInfo.beneficiaryName}</p>
</div>
<div>
<p className="text-indigo-100">Banco:</p>
<p className="font-medium">{speiInfo.bankName}</p>
</div>
</div>
<button
onClick={copyClabe}
className="mt-4 w-full flex items-center justify-center gap-2 px-4 py-3 bg-white text-indigo-600 rounded-lg font-medium hover:bg-indigo-50 transition-colors"
>
{copied ? (
<>
<Check className="h-5 w-5" />
CLABE copiada!
</>
) : (
<>
<Copy className="h-5 w-5" />
Copiar CLABE
</>
)}
</button>
</div>
) : (
<div className="card">
<div className="text-center py-8">
<CreditCard className="h-16 w-16 mx-auto mb-4 text-gray-300" />
<h3 className="text-lg font-bold mb-2">No tienes CLABE virtual</h3>
<p className="text-gray-500 mb-4">
Crea tu CLABE virtual para recibir transferencias SPEI
</p>
<button
onClick={() => createClabeMutation.mutate()}
disabled={createClabeMutation.isPending}
className="btn-primary flex items-center gap-2 mx-auto"
>
{createClabeMutation.isPending ? (
<>
<RefreshCw className="h-5 w-5 animate-spin" />
Creando...
</>
) : (
<>
<Plus className="h-5 w-5" />
Crear CLABE Virtual
</>
)}
</button>
</div>
</div>
)}
{/* How it works */}
<div className="card bg-purple-50 border-purple-200">
<h4 className="font-medium text-purple-900 mb-2 flex items-center gap-2">
<ArrowDownLeft className="h-5 w-5" />
Como funciona SPEI
</h4>
<ol className="list-decimal list-inside text-sm text-purple-800 space-y-1">
<li>Comparte tu CLABE con el cliente</li>
<li>El cliente hace una transferencia desde su banco</li>
<li>El dinero llega en minutos a tu cuenta</li>
<li>Recibes notificacion cuando llegue el pago</li>
</ol>
</div>
{/* SPEI Transactions */}
<div className="card">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
<ArrowDownLeft className="h-5 w-5" />
Transferencias Recibidas
</h3>
{speiTransactions && speiTransactions.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-sm text-gray-500 border-b">
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium">Monto</th>
<th className="pb-3 font-medium">De</th>
<th className="pb-3 font-medium">Referencia</th>
</tr>
</thead>
<tbody className="divide-y">
{speiTransactions.map((tx) => (
<tr key={tx.id}>
<td className="py-3 text-sm">
{new Date(tx.createdAt).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</td>
<td className="py-3 font-medium text-green-600">
+${tx.amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</td>
<td className="py-3 text-sm">
<p className="font-medium">{tx.senderName}</p>
<p className="text-gray-500 text-xs">{tx.senderBank}</p>
</td>
<td className="py-3 text-sm text-gray-600">{tx.reference}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<ArrowDownLeft className="h-12 w-12 mx-auto mb-3 text-gray-300" />
<p>No hay transferencias recibidas aun</p>
<p className="text-sm">Las transferencias SPEI apareceran aqui</p>
</div>
)}
</div>
</>
)}
</div>
);
}

328
src/pages/Tokens.tsx Normal file
View File

@ -0,0 +1,328 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Coins, Package, TrendingUp, Clock, Check, ShoppingCart, MessageSquare, FileText, Sparkles } from 'lucide-react';
import { billingApi } from '../lib/api';
interface TokenBalance {
availableTokens: number;
usedTokens: number;
totalTokens: number;
expiresAt?: string;
}
interface TokenPackage {
code: string;
name: string;
tokens: number;
price: number;
pricePerToken: number;
popular?: boolean;
savings?: number;
}
interface TokenUsage {
id: string;
service: 'whatsapp' | 'llm' | 'ocr' | 'invoice';
tokensUsed: number;
description: string;
createdAt: string;
}
export function Tokens() {
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
const { data: balance, isLoading: balanceLoading } = useQuery<TokenBalance>({
queryKey: ['token-balance'],
queryFn: async () => {
const res = await billingApi.getTokenBalance();
return res.data;
},
});
const { data: packages } = useQuery<TokenPackage[]>({
queryKey: ['token-packages'],
queryFn: async () => {
const res = await billingApi.getTokenPackages();
return res.data;
},
});
const { data: usage } = useQuery<TokenUsage[]>({
queryKey: ['token-usage'],
queryFn: async () => {
const res = await billingApi.getTokenUsage(20);
return res.data;
},
});
const purchaseMutation = useMutation({
mutationFn: async (packageCode: string) => {
const res = await billingApi.purchaseTokens(packageCode, {
successUrl: `${window.location.origin}/tokens?success=true`,
cancelUrl: `${window.location.origin}/tokens?canceled=true`,
});
return res.data;
},
onSuccess: (data) => {
if (data.url) {
window.location.href = data.url;
}
},
});
const getServiceIcon = (service: string) => {
switch (service) {
case 'whatsapp':
return <MessageSquare className="h-4 w-4 text-green-600" />;
case 'llm':
return <Sparkles className="h-4 w-4 text-purple-600" />;
case 'ocr':
return <FileText className="h-4 w-4 text-blue-600" />;
case 'invoice':
return <FileText className="h-4 w-4 text-orange-600" />;
default:
return <Coins className="h-4 w-4 text-gray-600" />;
}
};
const getServiceName = (service: string) => {
switch (service) {
case 'whatsapp':
return 'WhatsApp';
case 'llm':
return 'Asistente IA';
case 'ocr':
return 'Escaneo OCR';
case 'invoice':
return 'Facturacion';
default:
return service;
}
};
if (balanceLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Tienda de Tokens</h1>
<p className="text-gray-500">Compra tokens para usar los servicios premium</p>
</div>
{/* Balance Card */}
<div className="card bg-gradient-to-r from-amber-500 to-orange-600 text-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/20 rounded-xl">
<Coins className="h-8 w-8" />
</div>
<div>
<p className="text-amber-100">Balance disponible</p>
<p className="text-4xl font-bold">{balance?.availableTokens?.toLocaleString() || 0}</p>
<p className="text-sm text-amber-100">tokens</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-amber-100">Usados este mes</p>
<p className="text-2xl font-bold">{balance?.usedTokens?.toLocaleString() || 0}</p>
</div>
</div>
{balance?.expiresAt && (
<div className="mt-4 pt-4 border-t border-white/20 text-sm text-amber-100">
<Clock className="inline h-4 w-4 mr-1" />
Vencen el {new Date(balance.expiresAt).toLocaleDateString('es-MX')}
</div>
)}
</div>
{/* Usage Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<MessageSquare className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-sm text-gray-500">WhatsApp</p>
<p className="text-lg font-bold">1 token/msg</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<Sparkles className="h-5 w-5 text-purple-600" />
</div>
<div>
<p className="text-sm text-gray-500">Asistente IA</p>
<p className="text-lg font-bold">5 tokens/msg</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-gray-500">OCR Escaneo</p>
<p className="text-lg font-bold">10 tokens</p>
</div>
</div>
</div>
<div className="card">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-100 rounded-lg">
<FileText className="h-5 w-5 text-orange-600" />
</div>
<div>
<p className="text-sm text-gray-500">Factura CFDI</p>
<p className="text-lg font-bold">20 tokens</p>
</div>
</div>
</div>
</div>
{/* Token Packages */}
<div className="card">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
<Package className="h-5 w-5" />
Paquetes de Tokens
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{packages?.map((pkg) => (
<div
key={pkg.code}
className={`relative rounded-xl border-2 p-6 cursor-pointer transition-all ${
selectedPackage === pkg.code
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-primary-300'
} ${pkg.popular ? 'ring-2 ring-primary-500' : ''}`}
onClick={() => setSelectedPackage(pkg.code)}
>
{pkg.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="bg-primary-500 text-white text-xs font-medium px-3 py-1 rounded-full">
Mas popular
</span>
</div>
)}
<div className="text-center">
<h4 className="text-lg font-bold text-gray-900">{pkg.name}</h4>
<div className="my-4">
<span className="text-4xl font-bold text-gray-900">
${pkg.price}
</span>
<span className="text-gray-500 ml-1">MXN</span>
</div>
<div className="flex items-center justify-center gap-2 mb-4">
<Coins className="h-5 w-5 text-amber-500" />
<span className="text-xl font-bold">{pkg.tokens.toLocaleString()}</span>
<span className="text-gray-500">tokens</span>
</div>
<p className="text-sm text-gray-500">
${pkg.pricePerToken.toFixed(2)}/token
</p>
{pkg.savings && pkg.savings > 0 && (
<p className="text-sm text-green-600 font-medium mt-2">
Ahorras {pkg.savings}%
</p>
)}
</div>
{selectedPackage === pkg.code && (
<div className="absolute top-3 right-3">
<Check className="h-6 w-6 text-primary-500" />
</div>
)}
</div>
))}
</div>
<div className="mt-6 flex justify-center">
<button
onClick={() => selectedPackage && purchaseMutation.mutate(selectedPackage)}
disabled={!selectedPackage || purchaseMutation.isPending}
className="btn-primary px-8 py-3 text-lg flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{purchaseMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
Procesando...
</>
) : (
<>
<ShoppingCart className="h-5 w-5" />
Comprar Tokens
</>
)}
</button>
</div>
</div>
{/* Usage History */}
<div className="card">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Historial de Uso
</h3>
{usage && usage.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-sm text-gray-500 border-b">
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium">Servicio</th>
<th className="pb-3 font-medium">Descripcion</th>
<th className="pb-3 font-medium text-right">Tokens</th>
</tr>
</thead>
<tbody className="divide-y">
{usage.map((item) => (
<tr key={item.id}>
<td className="py-3 text-sm">
{new Date(item.createdAt).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</td>
<td className="py-3">
<span className="inline-flex items-center gap-1">
{getServiceIcon(item.service)}
{getServiceName(item.service)}
</span>
</td>
<td className="py-3 text-sm text-gray-600">{item.description}</td>
<td className="py-3 text-right font-medium text-red-600">
-{item.tokensUsed}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">
<Coins className="h-12 w-12 mx-auto mb-3 text-gray-300" />
<p>No hay historial de uso aun</p>
<p className="text-sm">Los tokens que uses apareceran aqui</p>
</div>
)}
</div>
</div>
);
}