[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:
parent
2ae94a679f
commit
00691fd1f7
@ -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>
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
@ -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
449
src/pages/CodiSpei.tsx
Normal 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
328
src/pages/Tokens.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user