[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 { Referrals } from './pages/Referrals';
|
||||||
import { Invoices } from './pages/Invoices';
|
import { Invoices } from './pages/Invoices';
|
||||||
import { Marketplace } from './pages/Marketplace';
|
import { Marketplace } from './pages/Marketplace';
|
||||||
|
import { Tokens } from './pages/Tokens';
|
||||||
|
import { CodiSpei } from './pages/CodiSpei';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
|
|
||||||
@ -79,6 +81,8 @@ function App() {
|
|||||||
<Route path="referrals" element={<Referrals />} />
|
<Route path="referrals" element={<Referrals />} />
|
||||||
<Route path="invoices" element={<Invoices />} />
|
<Route path="invoices" element={<Invoices />} />
|
||||||
<Route path="marketplace" element={<Marketplace />} />
|
<Route path="marketplace" element={<Marketplace />} />
|
||||||
|
<Route path="tokens" element={<Tokens />} />
|
||||||
|
<Route path="codi-spei" element={<CodiSpei />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import {
|
|||||||
Gift,
|
Gift,
|
||||||
FileText,
|
FileText,
|
||||||
Truck,
|
Truck,
|
||||||
|
Coins,
|
||||||
|
QrCode,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@ -26,8 +28,10 @@ const navigation = [
|
|||||||
{ name: 'Clientes', href: '/customers', icon: Users },
|
{ name: 'Clientes', href: '/customers', icon: Users },
|
||||||
{ name: 'Fiado', href: '/fiado', icon: CreditCard },
|
{ name: 'Fiado', href: '/fiado', icon: CreditCard },
|
||||||
{ name: 'Inventario', href: '/inventory', icon: Boxes },
|
{ name: 'Inventario', href: '/inventory', icon: Boxes },
|
||||||
|
{ name: 'CoDi/SPEI', href: '/codi-spei', icon: QrCode },
|
||||||
{ name: 'Facturacion', href: '/invoices', icon: FileText },
|
{ name: 'Facturacion', href: '/invoices', icon: FileText },
|
||||||
{ name: 'Proveedores', href: '/marketplace', icon: Truck },
|
{ name: 'Proveedores', href: '/marketplace', icon: Truck },
|
||||||
|
{ name: 'Tokens', href: '/tokens', icon: Coins },
|
||||||
{ name: 'Referidos', href: '/referrals', icon: Gift },
|
{ name: 'Referidos', href: '/referrals', icon: Gift },
|
||||||
{ name: 'Ajustes', href: '/settings', icon: Settings },
|
{ name: 'Ajustes', href: '/settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -214,3 +214,40 @@ export const marketplaceApi = {
|
|||||||
// Stats
|
// Stats
|
||||||
getStats: () => api.get('/marketplace/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