Backend: - Notificación email al admin cuando llega primer pago aprobado (sin factura auto) - Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global - Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d) - Fix webhook MP: validación defensiva de x-signature header - Fix autocompleto RFCs: eliminado filtro por contribuyenteId - Fix autocompleto conceptos: eliminado filtro por contribuyenteId - SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds - SAT sync request reuse across jobs para evitar agotar cuota diaria - Typo fix MP_ACCESS_TOKEN en .env - Trial invitations system backend Frontend: - Nueva página /admin/facturas-pendientes con tabla y emisión manual - Métrica 'Facturas pendientes' en /clientes (clickable) - Navegación onboarding FIEL/CSD corregida - Sidebar themes sincronizados - Fix SAT portal migration scraper (NetIQ) - Trial invitation acceptance pages
201 lines
7.3 KiB
TypeScript
201 lines
7.3 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import { Button, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
|
|
import { apiClient } from '@/lib/api/client';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { CheckCircle2, Clock, AlertTriangle, Loader2 } from 'lucide-react';
|
|
import Link from 'next/link';
|
|
|
|
interface InvitationData {
|
|
id: string;
|
|
tenantId: string;
|
|
plan: string;
|
|
durationDays: number;
|
|
status: string;
|
|
expiresAt: string;
|
|
tenant: {
|
|
nombre: string;
|
|
rfc: string;
|
|
} | null;
|
|
}
|
|
|
|
export default function InvitacionTrialPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const { user } = useAuthStore();
|
|
const token = typeof params.token === 'string' ? params.token : '';
|
|
|
|
const [invitation, setInvitation] = useState<InvitationData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [accepting, setAccepting] = useState(false);
|
|
const [accepted, setAccepted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setError('Token de invitación inválido');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
apiClient.get<InvitationData>(`/invitations/trial/token/${token}`)
|
|
.then((res) => {
|
|
setInvitation(res.data);
|
|
setLoading(false);
|
|
})
|
|
.catch((err: any) => {
|
|
setError(err.response?.data?.message || 'Invitación no encontrada o expirada');
|
|
setLoading(false);
|
|
});
|
|
}, [token]);
|
|
|
|
async function handleAccept() {
|
|
if (!token) return;
|
|
setAccepting(true);
|
|
setError('');
|
|
try {
|
|
await apiClient.post(`/invitations/trial/${token}/accept`);
|
|
setAccepted(true);
|
|
setTimeout(() => {
|
|
router.push('/configuracion/planes-despacho');
|
|
}, 2000);
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.message || 'Error al aceptar la invitación');
|
|
} finally {
|
|
setAccepting(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && !invitation) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center p-4">
|
|
<Card className="w-full max-w-md">
|
|
<CardHeader className="text-center">
|
|
<AlertTriangle className="h-12 w-12 text-amber-500 mx-auto mb-2" />
|
|
<CardTitle>Invitación no válida</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-center space-y-4">
|
|
<p className="text-muted-foreground">{error}</p>
|
|
<Button className="w-full" asChild>
|
|
<Link href="/login">Ir al inicio de sesión</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (accepted) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center p-4">
|
|
<Card className="w-full max-w-md">
|
|
<CardHeader className="text-center">
|
|
<CheckCircle2 className="h-12 w-12 text-green-500 mx-auto mb-2" />
|
|
<CardTitle>¡Invitación aceptada!</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-center space-y-4">
|
|
<p className="text-muted-foreground">
|
|
Tu despacho ahora tiene acceso a <strong>Business Control Prueba</strong> por {invitation?.durationDays} días.
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">Redirigiendo a tu panel...</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isExpired = invitation ? new Date(invitation.expiresAt) < new Date() : false;
|
|
const isPending = invitation?.status === 'pending';
|
|
const planDisplay = invitation?.plan === 'business_control' ? 'Business Control' : invitation?.plan;
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-4">
|
|
<Card className="w-full max-w-lg">
|
|
<CardHeader className="text-center">
|
|
<div className="mx-auto bg-blue-100 dark:bg-blue-900 rounded-full p-3 w-fit mb-2">
|
|
<Clock className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<CardTitle className="text-2xl">Invitación especial</CardTitle>
|
|
<p className="text-muted-foreground">
|
|
Has sido invitado a probar <strong>{planDisplay}</strong>
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="bg-muted rounded-lg p-4 space-y-3">
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-muted-foreground">Despacho</span>
|
|
<span className="text-sm font-medium">{invitation?.tenant?.nombre || '—'}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-muted-foreground">Plan</span>
|
|
<span className="text-sm font-medium">{planDisplay}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-muted-foreground">Duración</span>
|
|
<span className="text-sm font-medium">{invitation?.durationDays} días</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-muted-foreground">Expira el</span>
|
|
<span className="text-sm font-medium">
|
|
{invitation?.expiresAt
|
|
? new Date(invitation.expiresAt).toLocaleDateString('es-MX')
|
|
: '—'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
|
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-2">¿Qué incluye?</h4>
|
|
<ul className="text-sm text-blue-700 dark:text-blue-400 space-y-1">
|
|
<li>• Hasta 100 RFCs</li>
|
|
<li>• Usuarios ilimitados</li>
|
|
<li>• API de integración</li>
|
|
<li>• SAT incremental</li>
|
|
<li>• Todas las funciones de Business Control</li>
|
|
</ul>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">{error}</p>
|
|
)}
|
|
|
|
{!user ? (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-muted-foreground text-center">
|
|
Debes iniciar sesión con la cuenta del dueño del despacho para aceptar esta invitación.
|
|
</p>
|
|
<Button className="w-full" asChild>
|
|
<Link href={`/login?redirect=/invitacion/trial/${token}`}>Iniciar sesión</Link>
|
|
</Button>
|
|
</div>
|
|
) : isExpired || !isPending ? (
|
|
<div className="text-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
Esta invitación ya no está disponible ({invitation?.status}).
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
className="w-full"
|
|
onClick={handleAccept}
|
|
disabled={accepting}
|
|
>
|
|
{accepting ? 'Activando...' : 'Aceptar invitación'}
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|