728 lines
36 KiB
TypeScript
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}
|
|
Sí, cancelar
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|