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:
@@ -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<string, { label: string; color: string; icon: typeof CheckCircle }> = {
|
||||
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<string, { label: string; color: string; bgColor: string; icon: typeof CheckCircle }> = {
|
||||
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<string | null>(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 (
|
||||
<>
|
||||
<Header title="Suscripción" />
|
||||
<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>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -38,16 +129,18 @@ export default function SuscripcionPage() {
|
||||
<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/2" />
|
||||
<div className="h-4 bg-muted rounded w-1/4" />
|
||||
</div>
|
||||
) : 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>
|
||||
<p className="text-sm text-muted-foreground">Plan</p>
|
||||
<p className="text-lg font-semibold capitalize">{subscription.plan}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
{status.label}
|
||||
</span>
|
||||
@@ -58,13 +151,90 @@ export default function SuscripcionPage() {
|
||||
${Number(subscription.amount).toLocaleString('es-MX')} MXN
|
||||
</p>
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
</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 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -88,26 +258,34 @@ export default function SuscripcionPage() {
|
||||
<tbody>
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id} className="border-b last:border-0">
|
||||
<td className="py-2 px-3">
|
||||
{new Date(payment.createdAt).toLocaleDateString('es-MX')}
|
||||
<td className="py-2.5 px-3">
|
||||
{new Date(payment.createdAt).toLocaleDateString('es-MX', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
${Number(payment.amount).toLocaleString('es-MX')}
|
||||
<td className="py-2.5 px-3 font-medium">
|
||||
${Number(payment.amount).toLocaleString('es-MX')} MXN
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
<td className="py-2.5 px-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
|
||||
payment.status === 'approved'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: payment.status === 'rejected'
|
||||
? 'bg-red-50 text-red-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 === 'rejected' ? 'Rechazado' : 'Pendiente'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-muted-foreground">
|
||||
{payment.paymentMethod || '-'}
|
||||
<td className="py-2.5 px-3 text-muted-foreground capitalize">
|
||||
{payment.paymentMethod === 'bank_transfer' ? 'Transferencia' :
|
||||
payment.paymentMethod || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -115,9 +293,10 @@ export default function SuscripcionPage() {
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-4">
|
||||
No hay pagos registrados aún.
|
||||
</p>
|
||||
<div className="text-center py-8">
|
||||
<Calendar className="h-10 w-10 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">No hay pagos registrados aún.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user