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';
|
'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,33 +129,112 @@ 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>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<p className="text-sm text-muted-foreground">Plan</p>
|
<div>
|
||||||
<p className="text-lg font-semibold capitalize">{subscription.plan}</p>
|
<p className="text-sm text-muted-foreground">Plan</p>
|
||||||
</div>
|
<p className="text-lg font-semibold capitalize">{subscription.plan}</p>
|
||||||
<div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Estado</p>
|
<div>
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium ${status.color}`}>
|
<p className="text-sm text-muted-foreground">Estado</p>
|
||||||
<StatusIcon className="h-4 w-4" />
|
<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}`}>
|
||||||
{status.label}
|
<StatusIcon className="h-4 w-4" />
|
||||||
</span>
|
{status.label}
|
||||||
</div>
|
</span>
|
||||||
<div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Monto Mensual</p>
|
<div>
|
||||||
<p className="text-lg font-semibold">
|
<p className="text-sm text-muted-foreground">Monto Mensual</p>
|
||||||
${Number(subscription.amount).toLocaleString('es-MX')} MXN
|
<p className="text-lg font-semibold">
|
||||||
</p>
|
${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>
|
</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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user