Update: nueva version Horux Despachos
This commit is contained in:
727
apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx
Normal file
727
apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user