Initial commit - Horux Despachos NL
This commit is contained in:
118
apps/web/app/(auth)/forgot-password/page.tsx
Normal file
118
apps/web/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
import { requestPasswordReset } from '@/lib/api/auth';
|
||||
import { Mail, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
|
||||
try {
|
||||
await requestPasswordReset(email);
|
||||
setSubmitted(true);
|
||||
} catch (err: any) {
|
||||
// Rate limit u otro error explícito
|
||||
setError(err.response?.data?.message || 'Error al enviar el enlace. Intenta más tarde.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="rounded-full bg-green-100 p-3">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle>Revisa tu correo</CardTitle>
|
||||
<CardDescription>
|
||||
Si el email que ingresaste está registrado, recibirás un enlace para restablecer tu contraseña.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg bg-muted/40 border p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">¿No recibiste el correo?</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-xs">
|
||||
<li>Revisa tu carpeta de spam o promociones</li>
|
||||
<li>El enlace expira en 1 hora — si pasó más tiempo, vuelve a solicitar</li>
|
||||
<li>Asegúrate de haber escrito bien tu email</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button variant="outline" className="w-full" onClick={() => setSubmitted(false)}>
|
||||
Solicitar de nuevo
|
||||
</Button>
|
||||
<Link href="/login" className="text-sm text-primary hover:underline">
|
||||
Volver al login
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Image src="/logo.jpg" alt="Horux360" width={80} height={80} className="rounded-full" priority />
|
||||
</div>
|
||||
<CardTitle className="text-2xl flex items-center justify-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
Recuperar contraseña
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ingresa tu email y te enviaremos un enlace para crear una nueva contraseña.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Enviando...' : 'Enviar enlace'}
|
||||
</Button>
|
||||
<Link href="/login" className="text-sm text-primary hover:underline">
|
||||
Volver al login
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
apps/web/app/(auth)/layout.tsx
Normal file
11
apps/web/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
apps/web/app/(auth)/login/page.tsx
Normal file
122
apps/web/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
import { login } from '@/lib/api/auth';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { isGlobalAdminRfc } from '@horux/shared';
|
||||
import { shouldShowOnboarding } from '@/lib/onboarding';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { setUser, setTokens } = useAuthStore();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const email = formData.get('email') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
try {
|
||||
const response = await login({ email, password });
|
||||
setTokens(response.accessToken, response.refreshToken);
|
||||
setUser(response.user);
|
||||
|
||||
const userRole = response.user?.role;
|
||||
// Admin global aterriza directo en `/clientes` — su home natural es la
|
||||
// gestión de tenants, no el dashboard operativo del despacho.
|
||||
const platformRoles = (response.user as { platformRoles?: string[] }).platformRoles;
|
||||
const isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles);
|
||||
if (isGlobalAdmin) {
|
||||
router.push('/clientes');
|
||||
} else if (userRole === 'cliente' || userRole === 'auxiliar' || userRole === 'supervisor') {
|
||||
// Clients and roles without onboarding go straight to dashboard
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
// Owner/CFO/Contador: onboarding hasta 4 logins o hasta que el user
|
||||
// complete los pasos requeridos (lo que pase primero).
|
||||
router.push(shouldShowOnboarding(response.user) ? '/onboarding' : '/dashboard');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error al iniciar sesión');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Image
|
||||
src="/logo.jpg"
|
||||
alt="Horux360"
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-full"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Iniciar Sesión</CardTitle>
|
||||
<CardDescription>
|
||||
Ingresa tus credenciales para acceder a tu cuenta
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Contraseña</Label>
|
||||
<Link href="/forgot-password" className="text-xs text-primary hover:underline">
|
||||
¿Olvidaste tu contraseña?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Iniciando sesión...' : 'Iniciar Sesión'}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
¿No tienes cuenta?{' '}
|
||||
<Link href="/register-despacho" className="text-primary hover:underline">
|
||||
Regístrate
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
415
apps/web/app/(auth)/register-despacho/page.tsx
Normal file
415
apps/web/app/(auth)/register-despacho/page.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, cn } from '@horux/shared-ui';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { CheckCircle2, Server, Cloud, ArrowLeft, Clock, Building, Sparkles } from 'lucide-react';
|
||||
|
||||
type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||
type PlanType = 'trial' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud';
|
||||
|
||||
export default function RegisterDespachoPage() {
|
||||
const router = useRouter();
|
||||
const { setUser, setTokens } = useAuthStore();
|
||||
const [step, setStep] = useState(1);
|
||||
const [verticalProfile, setVerticalProfile] = useState<VerticalProfile | null>(null);
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanType | null>(null);
|
||||
// Default 'annual' — sesgo intencional al cash-flow del negocio (10 meses
|
||||
// = 17% descuento para el cliente, año completo cobrado upfront para nosotros).
|
||||
const [billingFrequency, setBillingFrequency] = useState<'monthly' | 'annual'>('annual');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
despachoNombre: '',
|
||||
ownerNombre: '',
|
||||
ownerEmail: '',
|
||||
ownerPassword: '',
|
||||
acceptedTerms: false,
|
||||
});
|
||||
|
||||
const handleChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.acceptedTerms) { setError('Debes aceptar los términos y condiciones'); return; }
|
||||
if (!verticalProfile || !selectedPlan) { setError('Completa todos los pasos'); return; }
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await apiClient.post('/despachos/signup', {
|
||||
despacho: {
|
||||
nombre: form.despachoNombre,
|
||||
verticalProfile,
|
||||
plan: selectedPlan,
|
||||
// Solo mi_empresa(+) acepta monthly; el backend ignora frequency
|
||||
// para los demás planes. Mandamos siempre el state para coherencia.
|
||||
frequency: billingFrequency,
|
||||
},
|
||||
owner: {
|
||||
nombre: form.ownerNombre,
|
||||
email: form.ownerEmail,
|
||||
password: form.ownerPassword,
|
||||
},
|
||||
});
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
setUser(data.user);
|
||||
|
||||
// If paid plan with payment URL, redirect to MercadoPago
|
||||
if (data.paymentUrl) {
|
||||
window.location.href = data.paymentUrl;
|
||||
} else {
|
||||
router.push('/onboarding');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error al registrar el despacho');
|
||||
setStep(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// =================== STEP 1: Registration Form ===================
|
||||
if (step === 1) {
|
||||
const canProceed = form.despachoNombre && form.ownerNombre && form.ownerEmail && form.ownerPassword.length >= 10 && form.acceptedTerms;
|
||||
|
||||
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="flex items-center justify-center gap-2 text-xs text-muted-foreground mb-4">
|
||||
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">1</span>
|
||||
<span className="w-8 h-px bg-muted" />
|
||||
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">2</span>
|
||||
<span className="w-8 h-px bg-muted" />
|
||||
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">3</span>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Crea tu cuenta</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">Plataforma para despachos profesionales</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div><Label htmlFor="dn">Nombre del despacho</Label><Input id="dn" value={form.despachoNombre} onChange={handleChange('despachoNombre')} placeholder="Despacho Pérez y Asociados" required /></div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div><Label htmlFor="on">Tu nombre completo</Label><Input id="on" value={form.ownerNombre} onChange={handleChange('ownerNombre')} placeholder="Juan Pérez" required /></div>
|
||||
<div><Label htmlFor="oe">Email</Label><Input id="oe" type="email" value={form.ownerEmail} onChange={handleChange('ownerEmail')} placeholder="juan@despacho.com" required /></div>
|
||||
<div><Label htmlFor="op">Contraseña</Label><Input id="op" type="password" value={form.ownerPassword} onChange={handleChange('ownerPassword')} placeholder="Mínimo 10 caracteres" minLength={10} required /></div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<input type="checkbox" id="terms" checked={form.acceptedTerms} onChange={(e) => setForm((p) => ({ ...p, acceptedTerms: e.target.checked }))} className="mt-1" />
|
||||
<label htmlFor="terms" className="text-sm text-muted-foreground">Acepto los <Link href="/terminos" target="_blank" className="underline text-primary">términos y condiciones</Link></label>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">{error}</p>}
|
||||
<Button onClick={() => canProceed && setStep(2)} className="w-full" disabled={!canProceed}>
|
||||
Continuar
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
¿Ya tienes cuenta? <Link href="/login" className="text-primary underline">Inicia sesión</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =================== STEP 2: Vertical Selection ===================
|
||||
if (step === 2) {
|
||||
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">
|
||||
<div className="w-full max-w-3xl space-y-8 text-center">
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground mb-4">
|
||||
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
|
||||
<span className="w-8 h-px bg-primary" />
|
||||
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">2</span>
|
||||
<span className="w-8 h-px bg-muted" />
|
||||
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">3</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">¿Qué tipo de despacho eres?</h1>
|
||||
<p className="text-muted-foreground mt-2">Selecciona tu área profesional</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={() => { setVerticalProfile('CONTABLE'); setStep(3); }}
|
||||
className="p-8 rounded-xl border-2 border-primary bg-card hover:bg-accent transition-all text-center space-y-3"
|
||||
>
|
||||
<div className="text-4xl">📊</div>
|
||||
<h3 className="text-lg font-semibold">Contable</h3>
|
||||
<p className="text-sm text-muted-foreground">Gestión fiscal, CFDI, IVA/ISR, SAT sync</p>
|
||||
</button>
|
||||
<div className="p-8 rounded-xl border-2 border-dashed border-muted bg-muted/30 text-center space-y-3 opacity-50 cursor-not-allowed">
|
||||
<div className="text-4xl">⚖️</div>
|
||||
<h3 className="text-lg font-semibold">Jurídico</h3>
|
||||
<p className="text-sm text-muted-foreground">Próximamente</p>
|
||||
</div>
|
||||
<div className="p-8 rounded-xl border-2 border-dashed border-muted bg-muted/30 text-center space-y-3 opacity-50 cursor-not-allowed">
|
||||
<div className="text-4xl">🏗️</div>
|
||||
<h3 className="text-lg font-semibold">Arquitectura</h3>
|
||||
<p className="text-sm text-muted-foreground">Próximamente</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setStep(1)} className="text-sm text-muted-foreground underline">
|
||||
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver al formulario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =================== STEP 3: Subscription Selection ===================
|
||||
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 py-8 px-4">
|
||||
<div className="w-full max-w-7xl space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground mb-4">
|
||||
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
|
||||
<span className="w-8 h-px bg-green-500" />
|
||||
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
|
||||
<span className="w-8 h-px bg-primary" />
|
||||
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">3</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">Elige tu plan</h1>
|
||||
<p className="text-muted-foreground mt-2">Empieza con el trial gratuito de 30 días o contrata un plan directo.</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle facturación mensual / anual (afecta solo Mi Empresa y Mi Empresa+) */}
|
||||
<div className="flex justify-center">
|
||||
<div className="inline-flex items-center gap-1 rounded-full border bg-muted/30 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingFrequency('monthly')}
|
||||
className={cn(
|
||||
'px-4 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
billingFrequency === 'monthly'
|
||||
? 'bg-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Mensual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingFrequency('annual')}
|
||||
className={cn(
|
||||
'px-4 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-2',
|
||||
billingFrequency === 'annual'
|
||||
? 'bg-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Anual
|
||||
<span className={cn(
|
||||
'text-[10px] px-1.5 py-0.5 rounded-full',
|
||||
billingFrequency === 'annual'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}>
|
||||
Ahorra 17%
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{/* Trial Gratuito */}
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all hover:shadow-lg',
|
||||
selectedPlan === 'trial' && 'border-primary ring-2 ring-primary/20'
|
||||
)}
|
||||
onClick={() => setSelectedPlan('trial')}
|
||||
>
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-green-100 dark:bg-green-900 rounded-full p-3 w-fit mb-2">
|
||||
<Clock className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Trial Gratuito</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Prueba sin compromiso</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">$0</div>
|
||||
<p className="text-xs text-muted-foreground">30 días</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Sin tarjeta</p>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Hasta 3 RFCs</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>1 usuario</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Todas las funcionalidades</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en la nube</span></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mi Empresa */}
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all hover:shadow-lg',
|
||||
selectedPlan === 'mi_empresa' && 'border-primary ring-2 ring-primary/20'
|
||||
)}
|
||||
onClick={() => setSelectedPlan('mi_empresa')}
|
||||
>
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-orange-100 dark:bg-orange-900 rounded-full p-3 w-fit mb-2">
|
||||
<Building className="h-6 w-6 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Mi Empresa</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Para 1 contribuyente</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-center">
|
||||
{billingFrequency === 'annual' ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">$5,800</div>
|
||||
<p className="text-xs text-muted-foreground">por año</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1 font-medium">Equivale a 10 meses</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">$580</div>
|
||||
<p className="text-xs text-muted-foreground">mensual</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">o $5,800/año (10 meses)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>1 RFC</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>3 usuarios</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>50 timbres/mes</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en la nube</span></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mi Empresa + */}
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all hover:shadow-lg',
|
||||
selectedPlan === 'mi_empresa_plus' && 'border-primary ring-2 ring-primary/20'
|
||||
)}
|
||||
onClick={() => setSelectedPlan('mi_empresa_plus')}
|
||||
>
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
|
||||
<Sparkles className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Mi Empresa +</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Con IA + API</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-center">
|
||||
{billingFrequency === 'annual' ? (
|
||||
<>
|
||||
<div className="text-2xl font-bold">$9,000</div>
|
||||
<p className="text-xs text-muted-foreground">por año</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1 font-medium">Equivale a 10 meses</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">$900</div>
|
||||
<p className="text-xs text-muted-foreground">mensual</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">o $9,000/año (10 meses)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Todo Mi Empresa</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Lolita IA Fiscal</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>API de integración</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental</span></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business Control */}
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all hover:shadow-lg relative',
|
||||
selectedPlan === 'business_control' && 'border-primary ring-2 ring-primary/20'
|
||||
)}
|
||||
onClick={() => setSelectedPlan('business_control')}
|
||||
>
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
|
||||
Más popular
|
||||
</div>
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-blue-100 dark:bg-blue-900 rounded-full p-3 w-fit mb-2">
|
||||
<Server className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Business Control</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Despachos contables</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">$25,850</div>
|
||||
<p className="text-xs text-muted-foreground">por año (IVA inc.)</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por RFC extra</p>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>100 RFCs incluidos</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Usuarios ilimitados</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en tu servidor</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Servidor backup</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>API de integración</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental</span></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Enterprise (business_cloud) */}
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all hover:shadow-lg',
|
||||
selectedPlan === 'business_cloud' && 'border-primary ring-2 ring-primary/20'
|
||||
)}
|
||||
onClick={() => setSelectedPlan('business_cloud')}
|
||||
>
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto bg-amber-100 dark:bg-amber-900 rounded-full p-3 w-fit mb-2">
|
||||
<Cloud className="h-6 w-6 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Enterprise</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Despachos de alto volumen</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">$43,000</div>
|
||||
<p className="text-xs text-muted-foreground">por año (IVA inc.)</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por RFC extra</p>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>100 RFCs incluidos</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>3M CFDIs por contribuyente</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Usuarios ilimitados</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en tu servidor</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Servidor backup</span></div>
|
||||
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental + API</span></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md text-center max-w-lg mx-auto">{error}</p>}
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedPlan || loading}
|
||||
size="lg"
|
||||
className="px-12"
|
||||
>
|
||||
{loading ? 'Creando tu despacho...' : selectedPlan === 'trial' ? 'Comenzar trial gratuito' : 'Continuar al pago'}
|
||||
</Button>
|
||||
<button onClick={() => setStep(2)} className="text-sm text-muted-foreground underline">
|
||||
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
apps/web/app/(auth)/register/page.tsx
Normal file
145
apps/web/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
import { register } from '@/lib/api/auth';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { setUser, setTokens } = useAuthStore();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!acceptedTerms) {
|
||||
setError('Debes aceptar los términos y condiciones para continuar.');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
try {
|
||||
const response = await register({
|
||||
empresa: {
|
||||
nombre: formData.get('empresaNombre') as string,
|
||||
rfc: formData.get('empresaRfc') as string,
|
||||
},
|
||||
usuario: {
|
||||
nombre: formData.get('nombre') as string,
|
||||
email: formData.get('email') as string,
|
||||
password: formData.get('password') as string,
|
||||
},
|
||||
});
|
||||
setTokens(response.accessToken, response.refreshToken);
|
||||
setUser(response.user);
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error al registrarse');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Crear Cuenta</CardTitle>
|
||||
<CardDescription>
|
||||
Registra tu empresa y comienza tu prueba gratuita
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Datos de la Empresa
|
||||
</Label>
|
||||
<Input
|
||||
name="empresaNombre"
|
||||
placeholder="Nombre de la empresa"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
name="empresaRfc"
|
||||
placeholder="RFC (ej: ABC123456XY1)"
|
||||
maxLength={13}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Datos del Dueño
|
||||
</Label>
|
||||
<Input
|
||||
name="nombre"
|
||||
placeholder="Tu nombre completo"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Contraseña (mín. 8 caracteres)"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer select-none pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptedTerms}
|
||||
onChange={(e) => setAcceptedTerms(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-input accent-primary cursor-pointer"
|
||||
required
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
Acepto los{' '}
|
||||
<Link
|
||||
href="/terminos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline hover:no-underline"
|
||||
>
|
||||
Términos y Condiciones
|
||||
</Link>
|
||||
{' '}de Horux 360.
|
||||
</span>
|
||||
</label>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button type="submit" className="w-full" disabled={isLoading || !acceptedTerms}>
|
||||
{isLoading ? 'Creando cuenta...' : 'Crear Cuenta Gratis'}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
¿Ya tienes cuenta?{' '}
|
||||
<Link href="/login" className="text-primary hover:underline">
|
||||
Inicia sesión
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
apps/web/app/(auth)/reset-password/page.tsx
Normal file
166
apps/web/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
import { confirmPasswordReset } from '@/lib/api/auth';
|
||||
import { KeyRound, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token') || '';
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="rounded-full bg-red-100 p-3">
|
||||
<AlertTriangle className="h-10 w-10 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle>Enlace inválido</CardTitle>
|
||||
<CardDescription>
|
||||
El enlace no incluye un token de recuperación válido. Solicita uno nuevo desde la pantalla de login.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Link href="/forgot-password" className="w-full">
|
||||
<Button className="w-full">Solicitar nuevo enlace</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('La contraseña debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Las contraseñas no coinciden');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await confirmPasswordReset(token, newPassword);
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push('/login'), 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error al actualizar contraseña');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="rounded-full bg-green-100 p-3">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle>Contraseña actualizada</CardTitle>
|
||||
<CardDescription>
|
||||
Tu contraseña fue cambiada exitosamente. Redireccionando al login...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Link href="/login" className="w-full">
|
||||
<Button className="w-full">Ir al login</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Image src="/logo.jpg" alt="Horux360" width={80} height={80} className="rounded-full" priority />
|
||||
</div>
|
||||
<CardTitle className="text-2xl flex items-center justify-center gap-2">
|
||||
<KeyRound className="h-5 w-5" />
|
||||
Nueva contraseña
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ingresa tu nueva contraseña. Mínimo 8 caracteres.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">Nueva contraseña</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmar contraseña</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Al actualizar, se cerrarán todas tus sesiones activas por seguridad. Tendrás que volver a iniciar sesión.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button type="submit" className="w-full" disabled={isLoading || !newPassword || !confirmPassword}>
|
||||
{isLoading ? 'Actualizando...' : 'Actualizar contraseña'}
|
||||
</Button>
|
||||
<Link href="/login" className="text-sm text-primary hover:underline">
|
||||
Volver al login
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<Suspense fallback={<Card><CardContent className="py-8 text-center">Cargando...</CardContent></Card>}>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user