From 00691fd1f745bba567676251740d7da209afa667 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 18 Jan 2026 04:32:43 -0600 Subject: [PATCH] [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 --- src/App.tsx | 4 + src/components/Layout.tsx | 4 + src/lib/api.ts | 37 ++++ src/pages/CodiSpei.tsx | 449 ++++++++++++++++++++++++++++++++++++++ src/pages/Tokens.tsx | 328 ++++++++++++++++++++++++++++ 5 files changed, 822 insertions(+) create mode 100644 src/pages/CodiSpei.tsx create mode 100644 src/pages/Tokens.tsx diff --git a/src/App.tsx b/src/App.tsx index 6c79a46..94e72c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 174cc3e..0ef8465 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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 }, ]; diff --git a/src/lib/api.ts b/src/lib/api.ts index 65ac10f..6108597 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 } }), +}; diff --git a/src/pages/CodiSpei.tsx b/src/pages/CodiSpei.tsx new file mode 100644 index 0000000..0fa1d43 --- /dev/null +++ b/src/pages/CodiSpei.tsx @@ -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({ + queryKey: ['codi-transactions'], + queryFn: async () => { + const res = await codiSpeiApi.getCodiTransactions(20); + return res.data; + }, + }); + + // SPEI Queries + const { data: speiInfo, isLoading: speiLoading } = useQuery({ + queryKey: ['spei-clabe'], + queryFn: async () => { + const res = await codiSpeiApi.getClabe(); + return res.data; + }, + }); + + const { data: speiTransactions } = useQuery({ + 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 ( + + + Pendiente + + ); + case 'completed': + return ( + + + Pagado + + ); + case 'expired': + return ( + + Expirado + + ); + default: + return null; + } + }; + + const isLoading = codiLoading || speiLoading; + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

CoDi y SPEI

+

Recibe pagos con QR CoDi o transferencia SPEI

+
+ + {/* Tabs */} +
+ + +
+ + {activeTab === 'codi' && ( + <> + {/* Generate QR */} +
+

+ + Generar QR de Cobro +

+ +
+
+ +
+ $ + setQrAmount(e.target.value)} + placeholder="0.00" + className="input pl-8" + min="1" + step="0.01" + /> +
+
+
+ + setQrDescription(e.target.value)} + placeholder="Venta de productos" + className="input" + /> +
+
+ + +
+ + {/* How it works */} +
+

+ + Como funciona CoDi +

+
    +
  1. Genera un QR con el monto a cobrar
  2. +
  3. El cliente escanea el QR con su app bancaria
  4. +
  5. El pago se refleja en segundos en tu cuenta
  6. +
  7. Sin comisiones - es gratis
  8. +
+
+ + {/* CoDi Transactions */} +
+

+ + Cobros CoDi Recientes +

+ + {codiTransactions && codiTransactions.length > 0 ? ( +
+ + + + + + + + + + + {codiTransactions.map((tx) => ( + + + + + + + ))} + +
FechaMontoDescripcionEstado
+ {new Date(tx.createdAt).toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + })} + + ${tx.amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })} + + {tx.description || '-'} + {getStatusBadge(tx.status)}
+
+ ) : ( +
+ +

No hay cobros CoDi aun

+

Genera un QR para empezar a cobrar

+
+ )} +
+ + )} + + {activeTab === 'spei' && ( + <> + {/* CLABE Card */} + {speiInfo ? ( +
+
+ +
+

Tu CLABE Virtual

+

Recibe transferencias SPEI directamente

+
+
+ +
+

CLABE:

+
+ + {speiInfo.clabe} + + +
+
+ +
+
+

Beneficiario:

+

{speiInfo.beneficiaryName}

+
+
+

Banco:

+

{speiInfo.bankName}

+
+
+ + +
+ ) : ( +
+
+ +

No tienes CLABE virtual

+

+ Crea tu CLABE virtual para recibir transferencias SPEI +

+ +
+
+ )} + + {/* How it works */} +
+

+ + Como funciona SPEI +

+
    +
  1. Comparte tu CLABE con el cliente
  2. +
  3. El cliente hace una transferencia desde su banco
  4. +
  5. El dinero llega en minutos a tu cuenta
  6. +
  7. Recibes notificacion cuando llegue el pago
  8. +
+
+ + {/* SPEI Transactions */} +
+

+ + Transferencias Recibidas +

+ + {speiTransactions && speiTransactions.length > 0 ? ( +
+ + + + + + + + + + + {speiTransactions.map((tx) => ( + + + + + + + ))} + +
FechaMontoDeReferencia
+ {new Date(tx.createdAt).toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + })} + + +${tx.amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })} + +

{tx.senderName}

+

{tx.senderBank}

+
{tx.reference}
+
+ ) : ( +
+ +

No hay transferencias recibidas aun

+

Las transferencias SPEI apareceran aqui

+
+ )} +
+ + )} +
+ ); +} diff --git a/src/pages/Tokens.tsx b/src/pages/Tokens.tsx new file mode 100644 index 0000000..da6b70d --- /dev/null +++ b/src/pages/Tokens.tsx @@ -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(null); + + const { data: balance, isLoading: balanceLoading } = useQuery({ + queryKey: ['token-balance'], + queryFn: async () => { + const res = await billingApi.getTokenBalance(); + return res.data; + }, + }); + + const { data: packages } = useQuery({ + queryKey: ['token-packages'], + queryFn: async () => { + const res = await billingApi.getTokenPackages(); + return res.data; + }, + }); + + const { data: usage } = useQuery({ + 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 ; + case 'llm': + return ; + case 'ocr': + return ; + case 'invoice': + return ; + default: + return ; + } + }; + + 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 ( +
+
+
+ ); + } + + return ( +
+
+

Tienda de Tokens

+

Compra tokens para usar los servicios premium

+
+ + {/* Balance Card */} +
+
+
+
+ +
+
+

Balance disponible

+

{balance?.availableTokens?.toLocaleString() || 0}

+

tokens

+
+
+
+

Usados este mes

+

{balance?.usedTokens?.toLocaleString() || 0}

+
+
+ {balance?.expiresAt && ( +
+ + Vencen el {new Date(balance.expiresAt).toLocaleDateString('es-MX')} +
+ )} +
+ + {/* Usage Stats */} +
+
+
+
+ +
+
+

WhatsApp

+

1 token/msg

+
+
+
+ +
+
+
+ +
+
+

Asistente IA

+

5 tokens/msg

+
+
+
+ +
+
+
+ +
+
+

OCR Escaneo

+

10 tokens

+
+
+
+ +
+
+
+ +
+
+

Factura CFDI

+

20 tokens

+
+
+
+
+ + {/* Token Packages */} +
+

+ + Paquetes de Tokens +

+ +
+ {packages?.map((pkg) => ( +
setSelectedPackage(pkg.code)} + > + {pkg.popular && ( +
+ + Mas popular + +
+ )} + +
+

{pkg.name}

+
+ + ${pkg.price} + + MXN +
+
+ + {pkg.tokens.toLocaleString()} + tokens +
+

+ ${pkg.pricePerToken.toFixed(2)}/token +

+ {pkg.savings && pkg.savings > 0 && ( +

+ Ahorras {pkg.savings}% +

+ )} +
+ + {selectedPackage === pkg.code && ( +
+ +
+ )} +
+ ))} +
+ +
+ +
+
+ + {/* Usage History */} +
+

+ + Historial de Uso +

+ + {usage && usage.length > 0 ? ( +
+ + + + + + + + + + + {usage.map((item) => ( + + + + + + + ))} + +
FechaServicioDescripcionTokens
+ {new Date(item.createdAt).toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + })} + + + {getServiceIcon(item.service)} + {getServiceName(item.service)} + + {item.description} + -{item.tokensUsed} +
+
+ ) : ( +
+ +

No hay historial de uso aun

+

Los tokens que uses apareceran aqui

+
+ )} +
+
+ ); +}