Files
HoruxDespachos/apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx
2026-04-27 01:11:06 -06:00

728 lines
36 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import {
useSubscription,
usePaymentHistory,
useGeneratePaymentLink,
usePlans,
useStartTrial,
useSubscribeMe,
useChangeMyPlan,
useCancelMySubscription,
useUpgradeMe,
useCancelPendingUpgrade,
useReactivateMe,
} from '@/lib/hooks/use-subscription';
import {
CreditCard,
Calendar,
CheckCircle,
AlertCircle,
Clock,
XCircle,
ExternalLink,
Loader2,
AlertTriangle,
CalendarClock,
Building,
Sparkles,
ArrowRight,
Gift,
} from 'lucide-react';
// ============================================================================
// Helpers
// ============================================================================
const PLAN_ORDER = ['starter', 'business', 'business_ia', 'enterprise'] as const;
const PLAN_LABELS: Record<string, string> = {
starter: 'Starter',
business: 'Business',
business_ia: 'Business + IA',
custom: 'Custom',
enterprise: 'Enterprise',
};
const PLAN_FEATURES: Record<string, string[]> = {
starter: ['Dashboard básico', 'CFDI manual', 'Cálculo IVA/ISR'],
business: ['Todo Starter', '50 CFDIs / mes', '3 usuarios', 'Reportes avanzados', 'Alertas fiscales', 'Conciliación bancaria', 'Sincronización SAT', 'Documentos (Opinión Cumplimiento)'],
business_ia: ['Todo Business', 'Lolita — agente IA fiscal'],
enterprise: ['Todo Business', 'Lolita — agente IA fiscal', '100 CFDIs / mes', 'Usuarios ilimitados', 'API de integración'],
};
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 },
trial: { label: 'Prueba gratis', color: 'text-blue-700', bgColor: 'bg-blue-50 border-blue-200', icon: Sparkles },
trial_expired: { label: 'Prueba vencida', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle },
trial_converted: { label: 'Prueba convertida', 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 | undefined): 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 | undefined): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' });
}
function formatAmount(amount: number | string): string {
return `$${Number(amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} MXN`;
}
// ============================================================================
// Plan Grid — componente reusable para picker y modal
// ============================================================================
interface PlanGridProps {
frequency: 'monthly' | 'annual';
selectedPlan: string | null;
currentPlan?: string | null; // resalta el plan actual cuando está en modal
onSelect: (plan: string) => void;
prices: Array<{ plan: string; frequency: string; amount: string }>;
}
function PlanGrid({ frequency, selectedPlan, currentPlan, onSelect, prices }: PlanGridProps) {
const priceByPlan = useMemo(() => {
const m = new Map<string, number>();
for (const p of prices) {
if (p.frequency === frequency) m.set(p.plan, Number(p.amount));
}
return m;
}, [prices, frequency]);
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{PLAN_ORDER.map((plan) => {
const price = priceByPlan.get(plan) ?? 0;
const isSelected = selectedPlan === plan;
const isCurrent = currentPlan === plan;
const features = PLAN_FEATURES[plan] || [];
return (
<button
key={plan}
type="button"
onClick={() => onSelect(plan)}
className={`text-left rounded-lg border-2 p-5 transition-all ${
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border hover:border-primary/50 hover:bg-muted/40'
} ${isCurrent ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-background' : ''}`}
>
<div className="flex items-baseline justify-between mb-3">
<h3 className="font-bold text-lg">{PLAN_LABELS[plan]}</h3>
{isCurrent && (
<span className="text-xs font-medium text-blue-700 bg-blue-50 px-2 py-0.5 rounded-full">Actual</span>
)}
</div>
<div className="mb-4">
<span className="text-3xl font-bold">{formatAmount(price)}</span>
<span className="text-sm text-muted-foreground ml-1">/ {frequency === 'monthly' ? 'mes' : 'año'}</span>
</div>
<ul className="space-y-1.5 text-sm text-muted-foreground">
{features.map((f) => (
<li key={f} className="flex items-start gap-1.5">
<CheckCircle className="h-3.5 w-3.5 text-green-600 mt-0.5 shrink-0" />
<span>{f}</span>
</li>
))}
</ul>
</button>
);
})}
</div>
);
}
// ============================================================================
// Admin global: vista de todas las suscripciones
// ============================================================================
// Edición de precios de planes movida a /configuracion/precios-suscripcion.
function AdminGlobalSubscriptions() {
const { data: subscriptions, isLoading } = useQuery({
queryKey: ['all-subscriptions'],
queryFn: () => apiClient.get('/subscriptions').then(r => r.data),
});
if (isLoading) return <div className="text-center py-8 text-muted-foreground">Cargando...</div>;
const subs = (subscriptions || []) as any[];
const activas = subs.filter((s: any) => s.status === 'authorized' || s.status === 'active');
const pendientes = subs.filter((s: any) => s.status === 'pending');
const canceladas = subs.filter((s: any) => s.status === 'cancelled' || s.status === 'paused');
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4">
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-primary/10 rounded-lg"><Building className="h-5 w-5 text-primary" /></div><div><p className="text-2xl font-bold">{subs.length}</p><p className="text-xs text-muted-foreground">Total</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-green-100 rounded-lg"><CheckCircle className="h-5 w-5 text-green-600" /></div><div><p className="text-2xl font-bold">{activas.length}</p><p className="text-xs text-muted-foreground">Activas</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-yellow-100 rounded-lg"><Clock className="h-5 w-5 text-yellow-600" /></div><div><p className="text-2xl font-bold">{pendientes.length}</p><p className="text-xs text-muted-foreground">Pendientes</p></div></div></CardContent></Card>
<Card><CardContent className="pt-6"><div className="flex items-center gap-3"><div className="p-2 bg-red-100 rounded-lg"><XCircle className="h-5 w-5 text-red-600" /></div><div><p className="text-2xl font-bold">{canceladas.length}</p><p className="text-xs text-muted-foreground">Canceladas</p></div></div></CardContent></Card>
</div>
<Card>
<CardHeader><CardTitle className="text-base">Todas las Suscripciones</CardTitle></CardHeader>
<CardContent>
{subs.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">No hay suscripciones</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">Cliente</th><th className="py-2 pr-4">RFC</th><th className="py-2 pr-4">Plan</th><th className="py-2 pr-4">Estado</th><th className="py-2 pr-4 text-right">Monto</th><th className="py-2 pr-4">Frecuencia</th><th className="py-2 pr-4">Siguiente pago</th><th className="py-2">Creada</th>
</tr>
</thead>
<tbody>
{subs.map((s: any) => {
const st = statusConfig[s.status] || statusConfig.pending;
const StIcon = st.icon;
return (
<tr key={s.id} className="border-b last:border-b-0 hover:bg-muted/50">
<td className="py-3 pr-4 font-medium">{s.tenant?.nombre || '—'}</td>
<td className="py-3 pr-4 font-mono text-xs">{s.tenant?.rfc || '—'}</td>
<td className="py-3 pr-4"><span className="px-2 py-0.5 rounded text-xs font-medium bg-muted capitalize">{s.plan}</span></td>
<td className="py-3 pr-4"><span className={`inline-flex items-center gap-1 text-xs font-medium ${st.color}`}><StIcon className="h-3 w-3" />{st.label}</span></td>
<td className="py-3 pr-4 text-right font-medium">${Number(s.amount).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
<td className="py-3 pr-4 text-muted-foreground capitalize">{s.frequency}</td>
<td className="py-3 pr-4">{s.currentPeriodEnd ? new Date(s.currentPeriodEnd).toLocaleDateString('es-MX') : '—'}</td>
<td className="py-3 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString('es-MX')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ============================================================================
// Frequency Toggle
// ============================================================================
function FrequencyToggle({ value, onChange }: { value: 'monthly' | 'annual'; onChange: (v: 'monthly' | 'annual') => void }) {
return (
<div className="inline-flex items-center rounded-lg border bg-card p-1 text-sm">
<button type="button" onClick={() => onChange('monthly')} className={`px-4 py-1.5 rounded-md font-medium transition-colors ${value === 'monthly' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}>Mensual</button>
<button type="button" onClick={() => onChange('annual')} className={`px-4 py-1.5 rounded-md font-medium transition-colors ${value === 'annual' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}>Anual <span className="ml-1 text-xs opacity-75">(ahorra 17%)</span></button>
</div>
);
}
// ============================================================================
// Main Page
// ============================================================================
export default function SuscripcionPage() {
const { user } = useAuthStore();
// Admin global ve todas las suscripciones
if (isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles)) {
return (
<>
<Header title="Suscripciones" />
<main className="p-6"><AdminGlobalSubscriptions /></main>
</>
);
}
const { data: subscription, isLoading } = useSubscription(user?.tenantId);
const { data: plans = [] } = usePlans();
const { data: payments } = usePaymentHistory(user?.tenantId);
const startTrial = useStartTrial();
const subscribeMe = useSubscribeMe();
const changePlan = useChangeMyPlan();
const cancelSub = useCancelMySubscription();
const generateLink = useGeneratePaymentLink();
const upgradeMe = useUpgradeMe();
const cancelUpgrade = useCancelPendingUpgrade();
const reactivateSub = useReactivateMe();
const [pickerFrequency, setPickerFrequency] = useState<'monthly' | 'annual'>('monthly');
const [pickerSelected, setPickerSelected] = useState<string | null>(null);
const [changeModalOpen, setChangeModalOpen] = useState(false);
const [changeFreq, setChangeFreq] = useState<'monthly' | 'annual'>('monthly');
const [changeSelected, setChangeSelected] = useState<string | null>(null);
const [cancelModalOpen, setCancelModalOpen] = useState(false);
// Estado derivado
const status = subscription?.status || null;
const daysUntilEnd = getDaysUntil(subscription?.currentPeriodEnd);
const isExpired = daysUntilEnd !== null && daysUntilEnd <= 0;
const isTrial = status === 'trial' && !isExpired;
const isTrialExpired = status === 'trial_expired' || (status === 'trial' && isExpired);
const isCancelledInPeriod = status === 'cancelled' && !isExpired;
const isCancelledExpired = status === 'cancelled' && isExpired;
const isActive = status === 'authorized' && !isExpired;
const isPending = status === 'pending';
const hasUsedTrial = !!subscription && ['trial', 'trial_expired', 'trial_converted'].includes(status || '');
const needsNewSubscription = !subscription || isTrialExpired || isCancelledExpired;
const hasPendingChange = !!subscription?.pendingPlan && !!subscription?.pendingEffectiveAt;
const hasPendingUpgrade = !!subscription?.upgradePreferenceId && !!subscription?.upgradeTargetPlan;
// Handlers
const handleStartTrial = async () => {
if (!pickerSelected) return;
try {
await startTrial.mutateAsync({ plan: pickerSelected, frequency: pickerFrequency });
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al iniciar trial');
}
};
const handleSubscribe = async () => {
if (!pickerSelected) return;
try {
const result = await subscribeMe.mutateAsync({ plan: pickerSelected, frequency: pickerFrequency });
window.open(result.paymentUrl, '_blank');
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al crear suscripción');
}
};
const openChangeModal = () => {
setChangeFreq((subscription?.frequency as 'monthly' | 'annual') || 'monthly');
setChangeSelected(subscription?.plan || null);
setChangeModalOpen(true);
};
// Clasifica el cambio para decidir endpoint: upgrade inmediato (con cobro prorateado)
// vs scheduled change (al próximo período). Upgrade aplica solo si se mantiene la
// frecuencia actual Y el nuevo plan es más caro que el actual para esa frecuencia.
const classifyChange = (plan: string, freq: 'monthly' | 'annual'): 'upgrade' | 'scheduled' | 'noop' => {
if (!subscription) return 'scheduled';
if (plan === subscription.plan && freq === subscription.frequency) return 'noop';
if (freq !== subscription.frequency) return 'scheduled';
const newPrice = Number(plans.find((p) => p.plan === plan && p.frequency === freq)?.amount ?? 0);
const currentPrice = Number(subscription.amount);
return newPrice > currentPrice ? 'upgrade' : 'scheduled';
};
const handleConfirmChange = async () => {
if (!changeSelected) return;
const kind = classifyChange(changeSelected, changeFreq);
if (kind === 'noop') {
setChangeModalOpen(false);
return;
}
try {
if (kind === 'upgrade') {
const result = await upgradeMe.mutateAsync(changeSelected);
setChangeModalOpen(false);
window.open(result.checkoutUrl, '_blank');
} else {
await changePlan.mutateAsync({ plan: changeSelected, frequency: changeFreq });
setChangeModalOpen(false);
}
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cambiar plan');
}
};
const handleCancelPendingUpgrade = async () => {
try {
await cancelUpgrade.mutateAsync();
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cancelar upgrade');
}
};
const handleReactivate = async () => {
try {
const result = await reactivateSub.mutateAsync();
window.open(result.paymentUrl, '_blank');
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al reactivar suscripción');
}
};
const handleCancel = async () => {
try {
await cancelSub.mutateAsync();
setCancelModalOpen(false);
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al cancelar');
}
};
const handleGeneratePaymentLink = async () => {
if (!user?.tenantId) return;
try {
const result = await generateLink.mutateAsync(user.tenantId);
window.open(result.paymentUrl, '_blank');
} catch (err: any) {
alert(err?.response?.data?.message || err?.message || 'Error al generar link');
}
};
// ========================================================================
// Render
// ========================================================================
return (
<>
<Header title="Suscripción" />
<main className="p-6 space-y-6">
{isLoading && (
<Card><CardContent className="py-8 text-center text-muted-foreground">Cargando...</CardContent></Card>
)}
{!isLoading && (
<>
{/* Banners de estado */}
{isTrial && (
<div className="flex items-start gap-3 rounded-lg border border-blue-300 bg-blue-50 p-4">
<Sparkles className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-blue-800">Estás en prueba gratuita</p>
<p className="text-sm text-blue-700 mt-1">
Te quedan <strong>{daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''}</strong> para probar todas las funciones. Contrata un plan antes del {formatDate(subscription?.currentPeriodEnd)} para continuar sin interrupciones.
</p>
</div>
</div>
)}
{isTrialExpired && (
<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 className="flex-1">
<p className="font-semibold text-red-800">Tu prueba gratuita terminó</p>
<p className="text-sm text-red-700 mt-1">Elige un plan abajo para continuar usando Horux360.</p>
</div>
</div>
)}
{isCancelledInPeriod && (
<div className="flex items-start gap-3 rounded-lg border border-orange-300 bg-orange-50 p-4">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-orange-800">Suscripción cancelada</p>
<p className="text-sm text-orange-700 mt-1">
Seguirás teniendo acceso hasta el <strong>{formatDate(subscription?.currentPeriodEnd)}</strong>. Puedes reactivarla antes de esa fecha para no perder la continuidad; el primer cobro se hará al iniciar el próximo período.
</p>
<Button size="sm" className="mt-3" onClick={handleReactivate} disabled={reactivateSub.isPending}>
{reactivateSub.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <ArrowRight className="h-4 w-4 mr-2" />}
Reactivar suscripción
</Button>
</div>
</div>
)}
{isCancelledExpired && (
<div className="flex items-start gap-3 rounded-lg border border-red-300 bg-red-50 p-4">
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-red-800">Suscripción vencida</p>
<p className="text-sm text-red-700 mt-1">Elige un plan abajo para reactivar tu cuenta.</p>
</div>
</div>
)}
{hasPendingChange && (
<div className="flex items-start gap-3 rounded-lg border border-purple-300 bg-purple-50 p-4">
<CalendarClock className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-purple-800">Cambio de plan programado</p>
<p className="text-sm text-purple-700 mt-1">
Tu plan cambiará a <strong>{PLAN_LABELS[subscription!.pendingPlan!]}</strong> ({subscription!.pendingFrequency === 'annual' ? 'anual' : 'mensual'}) el <strong>{formatDate(subscription!.pendingEffectiveAt)}</strong>.
</p>
</div>
</div>
)}
{hasPendingUpgrade && (
<div className="flex items-start gap-3 rounded-lg border border-blue-300 bg-blue-50 p-4">
<ArrowRight className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="font-semibold text-blue-800">Upgrade pendiente de pago</p>
<p className="text-sm text-blue-700 mt-1">
Estás por cambiar a <strong>{PLAN_LABELS[subscription!.upgradeTargetPlan!]}</strong>. Completa el pago prorateado en MercadoPago para activar el plan nuevo de inmediato.
</p>
<div className="flex gap-2 mt-3">
<Button
variant="outline"
size="sm"
onClick={handleCancelPendingUpgrade}
disabled={cancelUpgrade.isPending}
>
{cancelUpgrade.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
Cancelar upgrade
</Button>
</div>
</div>
</div>
)}
{isPending && !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 className="flex-1">
<p className="font-semibold text-yellow-800">Pago pendiente</p>
<p className="text-sm text-yellow-700 mt-1">Tu suscripción está creada pero aún no autorizaste el pago en MercadoPago.</p>
<Button variant="default" size="sm" className="mt-3" onClick={handleGeneratePaymentLink} disabled={generateLink.isPending}>
{generateLink.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <ExternalLink className="h-4 w-4 mr-2" />}
Completar pago
</Button>
</div>
</div>
)}
{/* Current subscription card — solo cuando hay sub activa/en-periodo */}
{subscription && !needsNewSubscription && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Tu Suscripción
</CardTitle>
{status && (
(() => {
const st = statusConfig[status] || statusConfig.pending;
const StIcon = st.icon;
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium border ${st.bgColor} ${st.color}`}>
<StIcon className="h-4 w-4" />
{st.label}
</span>
);
})()
)}
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div>
<p className="text-sm text-muted-foreground">Plan</p>
<p className="text-xl font-bold">{PLAN_LABELS[subscription.plan] || subscription.plan}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Monto</p>
<p className="text-xl font-bold">
{Number(subscription.amount) === 0 ? 'Gratis' : formatAmount(subscription.amount)}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Frecuencia</p>
<p className="text-xl font-bold">{subscription.frequency === 'annual' ? 'Anual' : 'Mensual'}</p>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-4 border-t">
{isTrial && (
<Button onClick={() => setPickerSelected(subscription.plan)}>
<ArrowRight className="h-4 w-4 mr-1" />
Contratar ahora
</Button>
)}
{isCancelledInPeriod && (
<Button onClick={handleReactivate} disabled={reactivateSub.isPending}>
{reactivateSub.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <ArrowRight className="h-4 w-4 mr-1" />}
Reactivar suscripción
</Button>
)}
{(isActive || isPending) && (
<Button variant="outline" onClick={openChangeModal}>Cambiar plan</Button>
)}
{(isActive || isPending || isTrial) && (
<Button variant="outline" className="text-destructive hover:text-destructive" onClick={() => setCancelModalOpen(true)}>
Cancelar suscripción
</Button>
)}
</div>
</CardContent>
</Card>
)}
{/* Billing period */}
{subscription && subscription.currentPeriodEnd && !needsNewSubscription && (
<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">{isTrial ? 'Termina trial en' : 'Próximo pago'}</p>
{daysUntilEnd !== null && daysUntilEnd > 0 ? (
<p className="font-medium">En {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''}</p>
) : (
<p className="font-medium text-red-600">Vencido</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Picker — primera vez O después de trial/cancel vencido */}
{needsNewSubscription && (
<Card>
<CardHeader>
<CardTitle>{subscription ? 'Elige un plan para continuar' : 'Elige tu plan'}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Todos los planes incluyen acceso completo a la plataforma. Puedes cambiar o cancelar cuando quieras.
</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex justify-center">
<FrequencyToggle value={pickerFrequency} onChange={setPickerFrequency} />
</div>
<PlanGrid frequency={pickerFrequency} selectedPlan={pickerSelected} onSelect={setPickerSelected} prices={plans} />
<div className="flex flex-col sm:flex-row gap-3 justify-center pt-4 border-t">
{!hasUsedTrial && (
<Button variant="outline" size="lg" onClick={handleStartTrial} disabled={!pickerSelected || startTrial.isPending} className="flex-1 sm:flex-initial">
{startTrial.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Gift className="h-4 w-4 mr-2" />}
Probar 30 días gratis
</Button>
)}
<Button size="lg" onClick={handleSubscribe} disabled={!pickerSelected || subscribeMe.isPending} className="flex-1 sm:flex-initial">
{subscribeMe.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <CreditCard className="h-4 w-4 mr-2" />}
Contratar {pickerSelected ? PLAN_LABELS[pickerSelected] : 'plan'}
</Button>
</div>
{!pickerSelected && (
<p className="text-center text-sm text-muted-foreground">Selecciona un plan arriba para continuar</p>
)}
</CardContent>
</Card>
)}
{/* Payment history — siempre visible si hay pagos */}
{payments && payments.length > 0 && (
<Card>
<CardHeader><CardTitle className="flex items-center gap-2"><Calendar className="h-5 w-5" />Historial de Pagos</CardTitle></CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Fecha</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Monto</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Estado</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Método</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2.5 px-3">{new Date(p.createdAt).toLocaleDateString('es-MX')}</td>
<td className="py-2.5 px-3 font-medium">{formatAmount(p.amount)}</td>
<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 ${p.status === 'approved' ? 'bg-green-50 text-green-700' : p.status === 'rejected' ? 'bg-red-50 text-red-700' : 'bg-yellow-50 text-yellow-700'}`}>
{p.status === 'approved' && <CheckCircle className="h-3 w-3" />}
{p.status === 'rejected' && <XCircle className="h-3 w-3" />}
{p.status !== 'approved' && p.status !== 'rejected' && <Clock className="h-3 w-3" />}
{p.status === 'approved' ? 'Aprobado' : p.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
</span>
</td>
<td className="py-2.5 px-3 text-muted-foreground capitalize">{p.paymentMethod === 'bank_transfer' ? 'Transferencia' : p.paymentMethod || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</>
)}
{/* Change plan modal */}
<Dialog open={changeModalOpen} onOpenChange={setChangeModalOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Cambiar plan</DialogTitle>
<DialogDescription>
Los <strong>upgrades</strong> (plan más caro, misma frecuencia) se cobran ahora por la diferencia prorateada y se activan de inmediato. Los <strong>downgrades</strong> y cambios de frecuencia se aplican al iniciar tu próximo período ({formatDate(subscription?.currentPeriodEnd)}).
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="flex justify-center">
<FrequencyToggle value={changeFreq} onChange={setChangeFreq} />
</div>
<PlanGrid frequency={changeFreq} selectedPlan={changeSelected} currentPlan={subscription?.plan} onSelect={setChangeSelected} prices={plans} />
{changeSelected && (() => {
const kind = classifyChange(changeSelected, changeFreq);
if (kind === 'upgrade') {
return (
<div className="rounded-md border border-blue-300 bg-blue-50 p-3 text-sm text-blue-800">
<strong>Este cambio es un upgrade.</strong> Al confirmar, abriremos MercadoPago para cobrar el monto prorateado de los días restantes del período actual. El plan nuevo se activa en cuanto se confirma el pago.
</div>
);
}
if (kind === 'scheduled') {
return (
<div className="rounded-md border border-purple-300 bg-purple-50 p-3 text-sm text-purple-800">
Este cambio se aplicará el <strong>{formatDate(subscription?.currentPeriodEnd)}</strong>. Sin cargo adicional ahora.
</div>
);
}
return null;
})()}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setChangeModalOpen(false)}>Cancelar</Button>
<Button onClick={handleConfirmChange} disabled={!changeSelected || changePlan.isPending || upgradeMe.isPending}>
{(changePlan.isPending || upgradeMe.isPending) ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
{changeSelected && classifyChange(changeSelected, changeFreq) === 'upgrade' ? 'Pagar y activar' : 'Programar cambio'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Cancel confirmation modal */}
<Dialog open={cancelModalOpen} onOpenChange={setCancelModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>¿Cancelar suscripción?</DialogTitle>
<DialogDescription>
Conservarás acceso a todas las funciones hasta el <strong>{formatDate(subscription?.currentPeriodEnd)}</strong>. Después de esa fecha tendrás que elegir un nuevo plan para seguir usando Horux360.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelModalOpen(false)}>No, mantener</Button>
<Button variant="outline" className="text-destructive hover:text-destructive" onClick={handleCancel} disabled={cancelSub.isPending}>
{cancelSub.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
, cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</main>
</>
);
}