From 38626bd3e694ff959c23725f58f741a72cd9fc0c Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Sun, 15 Mar 2026 23:59:09 +0000 Subject: [PATCH] feat: enhance subscription page with pay button, billing period, and alerts - "Pagar ahora" button generates MercadoPago link and opens in new tab - Billing period card shows start/end dates and days until next payment - Warning banners: expired (red), expiring soon (yellow), pending payment - Improved payment history with icons and translated payment methods Co-Authored-By: Claude Opus 4.6 --- .../configuracion/suscripcion/page.tsx | 253 +++++++++++++++--- apps/web/lib/api/subscription.ts | 3 + 2 files changed, 219 insertions(+), 37 deletions(-) diff --git a/apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx b/apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx index 694b466..8cb027e 100644 --- a/apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx +++ b/apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx @@ -1,31 +1,122 @@ 'use client'; +import { useState } from 'react'; import { Header } from '@/components/layouts/header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useAuthStore } from '@/stores/auth-store'; -import { useSubscription, usePaymentHistory } from '@/lib/hooks/use-subscription'; -import { CreditCard, Calendar, CheckCircle, AlertCircle, Clock, XCircle } from 'lucide-react'; +import { + useSubscription, + usePaymentHistory, + useGeneratePaymentLink, +} from '@/lib/hooks/use-subscription'; +import { + CreditCard, + Calendar, + CheckCircle, + AlertCircle, + Clock, + XCircle, + ExternalLink, + Loader2, + AlertTriangle, + CalendarClock, +} from 'lucide-react'; -const statusConfig: Record = { - authorized: { label: 'Activa', color: 'text-green-600 bg-green-50', icon: CheckCircle }, - pending: { label: 'Pendiente', color: 'text-yellow-600 bg-yellow-50', icon: Clock }, - paused: { label: 'Pausada', color: 'text-orange-600 bg-orange-50', icon: AlertCircle }, - cancelled: { label: 'Cancelada', color: 'text-red-600 bg-red-50', icon: XCircle }, +const statusConfig: Record = { + authorized: { label: 'Activa', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle }, + pending: { label: 'Pendiente de pago', color: 'text-yellow-700', bgColor: 'bg-yellow-50 border-yellow-200', icon: Clock }, + paused: { label: 'Pausada', color: 'text-orange-700', bgColor: 'bg-orange-50 border-orange-200', icon: AlertCircle }, + cancelled: { label: 'Cancelada', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle }, }; +function getDaysUntil(dateStr: string | null): number | null { + if (!dateStr) return null; + const diff = new Date(dateStr).getTime() - Date.now(); + return Math.ceil(diff / (1000 * 60 * 60 * 24)); +} + +function formatDate(dateStr: string | null): string { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString('es-MX', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); +} + export default function SuscripcionPage() { const { user } = useAuthStore(); const { data: subscription, isLoading } = useSubscription(user?.tenantId); const { data: payments } = usePaymentHistory(user?.tenantId); + const generateLink = useGeneratePaymentLink(); + const [paymentUrl, setPaymentUrl] = useState(null); const status = statusConfig[subscription?.status || ''] || statusConfig.pending; const StatusIcon = status.icon; + const daysUntilEnd = getDaysUntil(subscription?.currentPeriodEnd ?? null); + const isExpired = daysUntilEnd !== null && daysUntilEnd <= 0; + const isExpiringSoon = daysUntilEnd !== null && daysUntilEnd > 0 && daysUntilEnd <= 5; + const needsPayment = subscription?.status === 'pending' || isExpired; + + const handleGenerateLink = async () => { + if (!user?.tenantId) return; + try { + const result = await generateLink.mutateAsync(user.tenantId); + setPaymentUrl(result.paymentUrl); + window.open(result.paymentUrl, '_blank'); + } catch { + // error handled by mutation state + } + }; + return ( <>
- {/* Subscription Status */} + + {/* Warning banner: expired */} + {!isLoading && subscription && isExpired && ( +
+ +
+

Tu suscripción ha vencido

+

+ Tu período de facturación terminó el {formatDate(subscription.currentPeriodEnd)}. + Realiza tu pago para continuar usando todas las funciones de Horux360. +

+
+
+ )} + + {/* Warning banner: expiring soon */} + {!isLoading && subscription && isExpiringSoon && !isExpired && ( +
+ +
+

Tu suscripción vence pronto

+

+ Tu período de facturación termina en {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''} ({formatDate(subscription.currentPeriodEnd)}). + Asegúrate de tener tu método de pago al día. +

+
+
+ )} + + {/* Warning banner: pending payment */} + {!isLoading && subscription && subscription.status === 'pending' && !isExpired && ( +
+ +
+

Pago pendiente

+

+ Tu suscripción está pendiente de pago. Haz clic en el botón de abajo para completar tu pago. +

+
+
+ )} + + {/* Subscription Status + Pay button */} @@ -38,33 +129,112 @@ export default function SuscripcionPage() {
+
) : subscription ? ( -
-
-

Plan

-

{subscription.plan}

-
-
-

Estado

- - - {status.label} - -
-
-

Monto Mensual

-

- ${Number(subscription.amount).toLocaleString('es-MX')} MXN -

+
+
+
+

Plan

+

{subscription.plan}

+
+
+

Estado

+ + + {status.label} + +
+
+

Monto Mensual

+

+ ${Number(subscription.amount).toLocaleString('es-MX')} MXN +

+
+
+

Frecuencia

+

{subscription.frequency === 'monthly' ? 'Mensual' : subscription.frequency}

+
+ + {/* Pay button */} + {needsPayment && Number(subscription.amount) > 0 && ( +
+ + {paymentUrl && ( + + Abrir link de pago nuevamente + + )} + {generateLink.isError && ( +

+ Error al generar el link. Intenta de nuevo o contacta soporte. +

+ )} +
+ )}
) : ( -

No se encontró información de suscripción.

+

No se encontró información de suscripción. Contacta a soporte.

)} + {/* Next payment / Billing period */} + {subscription && (subscription.currentPeriodStart || subscription.currentPeriodEnd) && ( + + + + + Período de Facturación + + + +
+
+

Inicio del período

+

{formatDate(subscription.currentPeriodStart)}

+
+
+

Fin del período

+

{formatDate(subscription.currentPeriodEnd)}

+
+
+

Próximo pago

+ {daysUntilEnd !== null ? ( + isExpired ? ( +

Vencido — pago requerido

+ ) : ( +

+ En {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''} + ({formatDate(subscription.currentPeriodEnd)}) +

+ ) + ) : ( +

Sin fecha definida

+ )} +
+
+
+
+ )} + {/* Payment History */} @@ -88,26 +258,34 @@ export default function SuscripcionPage() { {payments.map((payment) => ( - - {new Date(payment.createdAt).toLocaleDateString('es-MX')} + + {new Date(payment.createdAt).toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + year: 'numeric', + })} - - ${Number(payment.amount).toLocaleString('es-MX')} + + ${Number(payment.amount).toLocaleString('es-MX')} MXN - - + + {payment.status === 'approved' && } + {payment.status === 'rejected' && } + {payment.status !== 'approved' && payment.status !== 'rejected' && } {payment.status === 'approved' ? 'Aprobado' : payment.status === 'rejected' ? 'Rechazado' : 'Pendiente'} - - {payment.paymentMethod || '-'} + + {payment.paymentMethod === 'bank_transfer' ? 'Transferencia' : + payment.paymentMethod || '—'} ))} @@ -115,9 +293,10 @@ export default function SuscripcionPage() {
) : ( -

- No hay pagos registrados aún. -

+
+ +

No hay pagos registrados aún.

+
)} diff --git a/apps/web/lib/api/subscription.ts b/apps/web/lib/api/subscription.ts index aae8441..d1c59ee 100644 --- a/apps/web/lib/api/subscription.ts +++ b/apps/web/lib/api/subscription.ts @@ -8,7 +8,10 @@ export interface Subscription { amount: string; frequency: string; mpPreapprovalId: string | null; + currentPeriodStart: string | null; + currentPeriodEnd: string | null; createdAt: string; + updatedAt: string; } export interface Payment {