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 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-03-15 23:59:09 +00:00
parent d22e898909
commit 38626bd3e6
2 changed files with 219 additions and 37 deletions

View File

@@ -1,31 +1,122 @@
'use client'; 'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header'; import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useSubscription, usePaymentHistory } from '@/lib/hooks/use-subscription'; import {
import { CreditCard, Calendar, CheckCircle, AlertCircle, Clock, XCircle } from 'lucide-react'; 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<string, { label: string; color: string; icon: typeof CheckCircle }> = { const statusConfig: Record<string, { label: string; color: string; bgColor: string; icon: typeof CheckCircle }> = {
authorized: { label: 'Activa', color: 'text-green-600 bg-green-50', icon: CheckCircle }, authorized: { label: 'Activa', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle },
pending: { label: 'Pendiente', color: 'text-yellow-600 bg-yellow-50', icon: Clock }, pending: { label: 'Pendiente de pago', color: 'text-yellow-700', bgColor: 'bg-yellow-50 border-yellow-200', icon: Clock },
paused: { label: 'Pausada', color: 'text-orange-600 bg-orange-50', icon: AlertCircle }, paused: { label: 'Pausada', color: 'text-orange-700', bgColor: 'bg-orange-50 border-orange-200', icon: AlertCircle },
cancelled: { label: 'Cancelada', color: 'text-red-600 bg-red-50', icon: XCircle }, 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() { export default function SuscripcionPage() {
const { user } = useAuthStore(); const { user } = useAuthStore();
const { data: subscription, isLoading } = useSubscription(user?.tenantId); const { data: subscription, isLoading } = useSubscription(user?.tenantId);
const { data: payments } = usePaymentHistory(user?.tenantId); const { data: payments } = usePaymentHistory(user?.tenantId);
const generateLink = useGeneratePaymentLink();
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
const status = statusConfig[subscription?.status || ''] || statusConfig.pending; const status = statusConfig[subscription?.status || ''] || statusConfig.pending;
const StatusIcon = status.icon; 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 ( return (
<> <>
<Header title="Suscripción" /> <Header title="Suscripción" />
<main className="p-6 space-y-6"> <main className="p-6 space-y-6">
{/* Subscription Status */}
{/* Warning banner: expired */}
{!isLoading && subscription && isExpired && (
<div className="flex items-start gap-3 rounded-lg border border-red-300 bg-red-50 p-4">
<AlertTriangle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-red-800">Tu suscripción ha vencido</p>
<p className="text-sm text-red-700 mt-1">
Tu período de facturación terminó el {formatDate(subscription.currentPeriodEnd)}.
Realiza tu pago para continuar usando todas las funciones de Horux360.
</p>
</div>
</div>
)}
{/* Warning banner: expiring soon */}
{!isLoading && subscription && isExpiringSoon && !isExpired && (
<div className="flex items-start gap-3 rounded-lg border border-yellow-300 bg-yellow-50 p-4">
<AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-yellow-800">Tu suscripción vence pronto</p>
<p className="text-sm text-yellow-700 mt-1">
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.
</p>
</div>
</div>
)}
{/* Warning banner: pending payment */}
{!isLoading && subscription && subscription.status === 'pending' && !isExpired && (
<div className="flex items-start gap-3 rounded-lg border border-yellow-300 bg-yellow-50 p-4">
<Clock className="h-5 w-5 text-yellow-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-yellow-800">Pago pendiente</p>
<p className="text-sm text-yellow-700 mt-1">
Tu suscripción está pendiente de pago. Haz clic en el botón de abajo para completar tu pago.
</p>
</div>
</div>
)}
{/* Subscription Status + Pay button */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -38,16 +129,18 @@ export default function SuscripcionPage() {
<div className="animate-pulse space-y-4"> <div className="animate-pulse space-y-4">
<div className="h-4 bg-muted rounded w-1/3" /> <div className="h-4 bg-muted rounded w-1/3" />
<div className="h-4 bg-muted rounded w-1/2" /> <div className="h-4 bg-muted rounded w-1/2" />
<div className="h-4 bg-muted rounded w-1/4" />
</div> </div>
) : subscription ? ( ) : subscription ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div> <div>
<p className="text-sm text-muted-foreground">Plan</p> <p className="text-sm text-muted-foreground">Plan</p>
<p className="text-lg font-semibold capitalize">{subscription.plan}</p> <p className="text-lg font-semibold capitalize">{subscription.plan}</p>
</div> </div>
<div> <div>
<p className="text-sm text-muted-foreground">Estado</p> <p className="text-sm text-muted-foreground">Estado</p>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium ${status.color}`}> <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium border ${status.bgColor} ${status.color}`}>
<StatusIcon className="h-4 w-4" /> <StatusIcon className="h-4 w-4" />
{status.label} {status.label}
</span> </span>
@@ -58,13 +151,90 @@ export default function SuscripcionPage() {
${Number(subscription.amount).toLocaleString('es-MX')} MXN ${Number(subscription.amount).toLocaleString('es-MX')} MXN
</p> </p>
</div> </div>
<div>
<p className="text-sm text-muted-foreground">Frecuencia</p>
<p className="text-lg font-semibold capitalize">{subscription.frequency === 'monthly' ? 'Mensual' : subscription.frequency}</p>
</div>
</div>
{/* Pay button */}
{needsPayment && Number(subscription.amount) > 0 && (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 pt-4 border-t">
<button
onClick={handleGenerateLink}
disabled={generateLink.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{generateLink.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ExternalLink className="h-4 w-4" />
)}
{generateLink.isPending ? 'Generando link...' : 'Pagar ahora'}
</button>
{paymentUrl && (
<a
href={paymentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
Abrir link de pago nuevamente
</a>
)}
{generateLink.isError && (
<p className="text-sm text-red-600">
Error al generar el link. Intenta de nuevo o contacta soporte.
</p>
)}
</div>
)}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No se encontró información de suscripción.</p> <p className="text-muted-foreground">No se encontró información de suscripción. Contacta a soporte.</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Next payment / Billing period */}
{subscription && (subscription.currentPeriodStart || subscription.currentPeriodEnd) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarClock className="h-5 w-5" />
Período de Facturación
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-sm text-muted-foreground">Inicio del período</p>
<p className="font-medium">{formatDate(subscription.currentPeriodStart)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Fin del período</p>
<p className="font-medium">{formatDate(subscription.currentPeriodEnd)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Próximo pago</p>
{daysUntilEnd !== null ? (
isExpired ? (
<p className="font-medium text-red-600">Vencido pago requerido</p>
) : (
<p className="font-medium">
En {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''}
<span className="text-muted-foreground"> ({formatDate(subscription.currentPeriodEnd)})</span>
</p>
)
) : (
<p className="font-medium text-muted-foreground">Sin fecha definida</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Payment History */} {/* Payment History */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -88,26 +258,34 @@ export default function SuscripcionPage() {
<tbody> <tbody>
{payments.map((payment) => ( {payments.map((payment) => (
<tr key={payment.id} className="border-b last:border-0"> <tr key={payment.id} className="border-b last:border-0">
<td className="py-2 px-3"> <td className="py-2.5 px-3">
{new Date(payment.createdAt).toLocaleDateString('es-MX')} {new Date(payment.createdAt).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</td> </td>
<td className="py-2 px-3"> <td className="py-2.5 px-3 font-medium">
${Number(payment.amount).toLocaleString('es-MX')} ${Number(payment.amount).toLocaleString('es-MX')} MXN
</td> </td>
<td className="py-2 px-3"> <td className="py-2.5 px-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${ <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
payment.status === 'approved' payment.status === 'approved'
? 'bg-green-50 text-green-700' ? 'bg-green-50 text-green-700'
: payment.status === 'rejected' : payment.status === 'rejected'
? 'bg-red-50 text-red-700' ? 'bg-red-50 text-red-700'
: 'bg-yellow-50 text-yellow-700' : 'bg-yellow-50 text-yellow-700'
}`}> }`}>
{payment.status === 'approved' && <CheckCircle className="h-3 w-3" />}
{payment.status === 'rejected' && <XCircle className="h-3 w-3" />}
{payment.status !== 'approved' && payment.status !== 'rejected' && <Clock className="h-3 w-3" />}
{payment.status === 'approved' ? 'Aprobado' : {payment.status === 'approved' ? 'Aprobado' :
payment.status === 'rejected' ? 'Rechazado' : 'Pendiente'} payment.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
</span> </span>
</td> </td>
<td className="py-2 px-3 text-muted-foreground"> <td className="py-2.5 px-3 text-muted-foreground capitalize">
{payment.paymentMethod || '-'} {payment.paymentMethod === 'bank_transfer' ? 'Transferencia' :
payment.paymentMethod || '—'}
</td> </td>
</tr> </tr>
))} ))}
@@ -115,9 +293,10 @@ export default function SuscripcionPage() {
</table> </table>
</div> </div>
) : ( ) : (
<p className="text-muted-foreground text-center py-4"> <div className="text-center py-8">
No hay pagos registrados aún. <Calendar className="h-10 w-10 text-muted-foreground/40 mx-auto mb-3" />
</p> <p className="text-muted-foreground">No hay pagos registrados aún.</p>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -8,7 +8,10 @@ export interface Subscription {
amount: string; amount: string;
frequency: string; frequency: string;
mpPreapprovalId: string | null; mpPreapprovalId: string | null;
currentPeriodStart: string | null;
currentPeriodEnd: string | null;
createdAt: string; createdAt: string;
updatedAt: string;
} }
export interface Payment { export interface Payment {