156 lines
5.3 KiB
TypeScript
156 lines
5.3 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { AlertTriangle, AlertCircle, Sparkles, Clock, XCircle } from 'lucide-react';
|
|
import { cn } from '@horux/shared-ui';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useSubscription } from '@/lib/hooks/use-subscription';
|
|
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
|
import { getSubscriptionState, isGlobalAdminRfc } from '@horux/shared';
|
|
|
|
/**
|
|
* Banner persistente arriba del dashboard. Visible siempre que la suscripción
|
|
* está bloqueada (trial vencido, cancelled vencido, sin sub) O cuando faltan
|
|
* ≤7 días para que se cumpla el período (trial activo o suscripción authorized).
|
|
*
|
|
* Se oculta automáticamente cuando el admin global está impersonando un tenant
|
|
* (la barra de impersonación ya cumple la función de "estás viendo otro tenant").
|
|
*/
|
|
export function SubscriptionBanner() {
|
|
const { user } = useAuthStore();
|
|
const { viewingTenantId } = useTenantViewStore();
|
|
const { data: subscription } = useSubscription(user?.tenantId);
|
|
|
|
const isGlobalAdmin = isGlobalAdminRfc(
|
|
user?.tenantRfc,
|
|
user?.role || 'visor',
|
|
user?.platformRoles,
|
|
);
|
|
|
|
// Admin global: nunca mostramos su propio estado (su tenant es Horux 360,
|
|
// siempre activo). Si está impersonando, el banner del tenant impersonado se
|
|
// omite — la barra de impersonación ya cubre esa señal.
|
|
if (isGlobalAdmin || viewingTenantId) return null;
|
|
|
|
// Mientras la query carga, no mostramos nada para evitar flicker.
|
|
if (subscription === undefined) return null;
|
|
|
|
const state = getSubscriptionState(subscription);
|
|
|
|
// Sin estado relevante (active >7 días) no se muestra el banner.
|
|
if (state.isActive && !state.isWarning) return null;
|
|
if (state.isPending && !state.isWarning) return null;
|
|
|
|
// Variantes ordenadas por severidad
|
|
if (state.isTrialExpired) {
|
|
return (
|
|
<Banner
|
|
icon={<AlertTriangle className="h-5 w-5" />}
|
|
title="Tu prueba gratuita terminó"
|
|
message="Contrata un plan para continuar registrando movimientos."
|
|
cta="Elegir plan"
|
|
tone="red"
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (state.isCancelledExpired) {
|
|
return (
|
|
<Banner
|
|
icon={<XCircle className="h-5 w-5" />}
|
|
title="Tu suscripción venció"
|
|
message="Reactiva o elige un plan para continuar registrando movimientos."
|
|
cta="Renovar"
|
|
tone="red"
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (state.needsRenewal) {
|
|
return (
|
|
<Banner
|
|
icon={<XCircle className="h-5 w-5" />}
|
|
title="No tienes una suscripción activa"
|
|
message="Contrata un plan para usar todas las funciones."
|
|
cta="Ver planes"
|
|
tone="red"
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (state.isCancelledInPeriod) {
|
|
return (
|
|
<Banner
|
|
icon={<AlertCircle className="h-5 w-5" />}
|
|
title="Suscripción cancelada"
|
|
message={`Seguirás teniendo acceso ${state.daysUntilEnd ?? 0} día${state.daysUntilEnd === 1 ? '' : 's'} más. Reactívala para no perder continuidad.`}
|
|
cta="Reactivar"
|
|
tone="orange"
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (state.isTrial && state.isWarning) {
|
|
return (
|
|
<Banner
|
|
icon={<Sparkles className="h-5 w-5" />}
|
|
title={`Tu prueba gratuita termina en ${state.daysUntilEnd} día${state.daysUntilEnd === 1 ? '' : 's'}`}
|
|
message="Contrata un plan antes para continuar sin interrupciones."
|
|
cta="Elegir plan"
|
|
tone="blue"
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (state.isActive && state.isWarning) {
|
|
return (
|
|
<Banner
|
|
icon={<Clock className="h-5 w-5" />}
|
|
title={`Tu período termina en ${state.daysUntilEnd} día${state.daysUntilEnd === 1 ? '' : 's'}`}
|
|
message="Verifica tu método de pago para evitar interrupciones."
|
|
cta="Ver suscripción"
|
|
tone="amber"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
interface BannerProps {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
message: string;
|
|
cta: string;
|
|
tone: 'red' | 'orange' | 'amber' | 'blue';
|
|
}
|
|
|
|
const TONE_STYLES: Record<BannerProps['tone'], { bg: string; border: string; text: string; button: string }> = {
|
|
red: { bg: 'bg-red-50', border: 'border-red-300', text: 'text-red-800', button: 'bg-red-600 hover:bg-red-700 text-white' },
|
|
orange: { bg: 'bg-orange-50', border: 'border-orange-300', text: 'text-orange-800', button: 'bg-orange-600 hover:bg-orange-700 text-white' },
|
|
amber: { bg: 'bg-amber-50', border: 'border-amber-300', text: 'text-amber-800', button: 'bg-amber-600 hover:bg-amber-700 text-white' },
|
|
blue: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-800', button: 'bg-blue-600 hover:bg-blue-700 text-white' },
|
|
};
|
|
|
|
function Banner({ icon, title, message, cta, tone }: BannerProps) {
|
|
const styles = TONE_STYLES[tone];
|
|
return (
|
|
<div className={cn('flex items-center gap-3 border-b px-6 py-3', styles.bg, styles.border)}>
|
|
<div className={styles.text}>{icon}</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className={cn('text-sm font-semibold', styles.text)}>{title}</p>
|
|
<p className={cn('text-xs', styles.text, 'opacity-80')}>{message}</p>
|
|
</div>
|
|
<Link
|
|
href="/configuracion/planes-despacho"
|
|
className={cn(
|
|
'shrink-0 rounded-md px-4 py-2 text-sm font-medium transition-colors',
|
|
styles.button,
|
|
)}
|
|
>
|
|
{cta}
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|