feat: implement onboarding flow with complex creation and email verification, and add Zustand for state management
This commit is contained in:
222
apps/frontend/src/features/onboard/create-complex-page.tsx
Normal file
222
apps/frontend/src/features/onboard/create-complex-page.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { PlanSummary } from '@repo/api-contract';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const createComplexSchema = z.object({
|
||||
complexName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, 'El nombre del complejo debe tener al menos 3 caracteres.')
|
||||
.max(120, 'El nombre del complejo no puede superar los 120 caracteres.'),
|
||||
physicalAddress: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(5, 'La direccion debe tener al menos 5 caracteres.')
|
||||
.max(200, 'La direccion no puede superar los 200 caracteres.'),
|
||||
city: z.string().trim().max(100, 'La ciudad no puede superar los 100 caracteres.').optional(),
|
||||
state: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(100, 'La provincia/estado no puede superar los 100 caracteres.')
|
||||
.optional(),
|
||||
country: z.string().trim().max(100, 'El país no puede superar los 100 caracteres.').optional(),
|
||||
planCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'El planCode no puede estar vacío.')
|
||||
.max(10, 'El planCode no puede superar los 10 caracteres.')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type CreateComplexForm = z.infer<typeof createComplexSchema>;
|
||||
|
||||
export function CreateComplexPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [plans, setPlans] = useState<PlanSummary[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const form = useForm<CreateComplexForm>({
|
||||
resolver: zodResolver(createComplexSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
complexName: '',
|
||||
physicalAddress: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
planCode: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await apiClient.plans.list();
|
||||
setPlans(result);
|
||||
} catch {
|
||||
setPlans([]);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (values: CreateComplexForm) => {
|
||||
setErrorMessage(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const pendingEmail = sessionStorage.getItem('pending-signup-email');
|
||||
const payload: Parameters<typeof apiClient.complexes.create>[0] = {
|
||||
complexName: values.complexName,
|
||||
physicalAddress: values.physicalAddress,
|
||||
city: values.city,
|
||||
state: values.state,
|
||||
country: values.country,
|
||||
planCode: values.planCode,
|
||||
};
|
||||
|
||||
if (pendingEmail) {
|
||||
payload.adminEmail = pendingEmail;
|
||||
}
|
||||
|
||||
const complex = await apiClient.complexes.create(payload);
|
||||
|
||||
sessionStorage.removeItem('pending-signup-email');
|
||||
sessionStorage.removeItem('pending-signup-userId');
|
||||
setCurrentComplexSlug(complex.complexSlug);
|
||||
await navigate({
|
||||
to: '/complex/$slug/edit',
|
||||
params: { slug: complex.complexSlug },
|
||||
});
|
||||
} catch {
|
||||
setErrorMessage('No pudimos crear el complejo. Intenta nuevamente.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||
<h1 className="mb-2 text-2xl font-semibold">Crear tu complejo</h1>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Completá los datos de tu complejo para comenzar.
|
||||
</p>
|
||||
|
||||
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{user?.email && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="user-email">Email</FieldLabel>
|
||||
<Input id="user-email" type="email" value={user.email} disabled readOnly />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.complexName)}>
|
||||
<FieldLabel htmlFor="complexName">Nombre del complejo</FieldLabel>
|
||||
<Input
|
||||
id="complexName"
|
||||
type="text"
|
||||
placeholder="Complejo Las Palmeras"
|
||||
aria-invalid={Boolean(form.formState.errors.complexName)}
|
||||
{...form.register('complexName')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.complexName]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.physicalAddress)}>
|
||||
<FieldLabel htmlFor="physicalAddress">Dirección física</FieldLabel>
|
||||
<Input
|
||||
id="physicalAddress"
|
||||
type="text"
|
||||
placeholder="Av. San Martin 1234, Salta"
|
||||
aria-invalid={Boolean(form.formState.errors.physicalAddress)}
|
||||
{...form.register('physicalAddress')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.physicalAddress]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.city)}>
|
||||
<FieldLabel htmlFor="city">Ciudad</FieldLabel>
|
||||
<Input
|
||||
id="city"
|
||||
type="text"
|
||||
placeholder="Salta"
|
||||
aria-invalid={Boolean(form.formState.errors.city)}
|
||||
{...form.register('city')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.city]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.state)}>
|
||||
<FieldLabel htmlFor="state">Provincia/Estado</FieldLabel>
|
||||
<Input
|
||||
id="state"
|
||||
type="text"
|
||||
placeholder="Salta"
|
||||
aria-invalid={Boolean(form.formState.errors.state)}
|
||||
{...form.register('state')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.state]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.country)}>
|
||||
<FieldLabel htmlFor="country">País</FieldLabel>
|
||||
<Input
|
||||
id="country"
|
||||
type="text"
|
||||
placeholder="Argentina"
|
||||
aria-invalid={Boolean(form.formState.errors.country)}
|
||||
{...form.register('country')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.country]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(form.formState.errors.planCode)}>
|
||||
<FieldLabel htmlFor="planCode">Plan</FieldLabel>
|
||||
<select
|
||||
id="planCode"
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||
aria-invalid={Boolean(form.formState.errors.planCode)}
|
||||
{...form.register('planCode')}
|
||||
>
|
||||
<option value="">
|
||||
{plans.length > 0 ? 'Selecciona un plan' : 'No hay planes disponibles'}
|
||||
</option>
|
||||
{plans.map((plan) => (
|
||||
<option key={plan.code} value={plan.code}>
|
||||
{plan.name} - ${plan.price.toFixed(2)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{plans.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Carga planes en la base para continuar.
|
||||
</p>
|
||||
)}
|
||||
<FieldError errors={[form.formState.errors.planCode]} />
|
||||
</Field>
|
||||
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || !form.formState.isValid}
|
||||
>
|
||||
{isSubmitting ? 'Creando complejo...' : 'Crear complejo'}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
287
apps/frontend/src/features/onboard/onboard-login-page.tsx
Normal file
287
apps/frontend/src/features/onboard/onboard-login-page.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { apiClient, authClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Ingresá un email válido.'),
|
||||
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
|
||||
});
|
||||
|
||||
const signupSchema = z
|
||||
.object({
|
||||
fullName: z.string().min(2, 'El nombre debe tener al menos 2 caracteres.'),
|
||||
email: z.string().email('Ingresá un email válido.'),
|
||||
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden.',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
type SignupForm = z.infer<typeof signupSchema>;
|
||||
|
||||
export function OnboardLoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { signInWithPassword, signUp } = useAuth();
|
||||
const [mode, setMode] = useState<'login' | 'signup'>('login');
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const loginForm = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: { email: '', password: '' },
|
||||
});
|
||||
|
||||
const signupForm = useForm<SignupForm>({
|
||||
resolver: zodResolver(signupSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: { fullName: '', email: '', password: '', confirmPassword: '' },
|
||||
});
|
||||
|
||||
async function handlePostLogin() {
|
||||
const complexes = await apiClient.complexes.listMine();
|
||||
|
||||
if (complexes.length === 0) {
|
||||
navigate({ to: '/onboard/create-complex' });
|
||||
} else if (complexes.length === 1) {
|
||||
await apiClient.complexes.select({ complexId: complexes[0].id });
|
||||
navigate({ to: '/' });
|
||||
} else {
|
||||
navigate({ to: '/select-complex' });
|
||||
}
|
||||
}
|
||||
|
||||
const onLoginSubmit = async (values: LoginForm) => {
|
||||
setSubmitError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await signInWithPassword(values);
|
||||
await handlePostLogin();
|
||||
} catch {
|
||||
setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSignupSubmit = async (values: SignupForm) => {
|
||||
setSubmitError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await signUp({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
fullName: values.fullName,
|
||||
});
|
||||
|
||||
if (result.requiresEmailConfirmation) {
|
||||
sessionStorage.setItem('pending-signup-email', values.email);
|
||||
navigate({
|
||||
to: '/onboard/verify-email',
|
||||
search: { email: values.email },
|
||||
});
|
||||
} else {
|
||||
navigate({ to: '/onboard/create-complex' });
|
||||
}
|
||||
} catch {
|
||||
setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
await authClient.signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: `${window.location.origin}/onboard/create-complex`,
|
||||
});
|
||||
} catch {
|
||||
setSubmitError('No pudimos iniciar sesión con Google.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||
<h1 className="mb-2 text-2xl font-semibold">
|
||||
{mode === 'login' ? 'Iniciar sesión' : 'Crear cuenta'}
|
||||
</h1>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{mode === 'login'
|
||||
? 'Ingresá con tu cuenta para continuar.'
|
||||
: 'Creá tu cuenta para comenzar.'}
|
||||
</p>
|
||||
|
||||
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continuar con Google
|
||||
</Button>
|
||||
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">O</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'login' ? (
|
||||
<form className="space-y-3" onSubmit={loginForm.handleSubmit(onLoginSubmit)}>
|
||||
<Field data-invalid={Boolean(loginForm.formState.errors.email)}>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
aria-invalid={Boolean(loginForm.formState.errors.email)}
|
||||
{...loginForm.register('email')}
|
||||
/>
|
||||
<FieldError errors={[loginForm.formState.errors.email]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(loginForm.formState.errors.password)}>
|
||||
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
aria-invalid={Boolean(loginForm.formState.errors.password)}
|
||||
{...loginForm.register('password')}
|
||||
/>
|
||||
<FieldError errors={[loginForm.formState.errors.password]} />
|
||||
</Field>
|
||||
|
||||
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!loginForm.formState.isValid || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Ingresando...' : 'Ingresar'}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-3" onSubmit={signupForm.handleSubmit(onSignupSubmit)}>
|
||||
<Field data-invalid={Boolean(signupForm.formState.errors.fullName)}>
|
||||
<FieldLabel htmlFor="fullName">Nombre completo</FieldLabel>
|
||||
<Input
|
||||
id="fullName"
|
||||
type="text"
|
||||
placeholder="Juan Pérez"
|
||||
aria-invalid={Boolean(signupForm.formState.errors.fullName)}
|
||||
{...signupForm.register('fullName')}
|
||||
/>
|
||||
<FieldError errors={[signupForm.formState.errors.fullName]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(signupForm.formState.errors.email)}>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
aria-invalid={Boolean(signupForm.formState.errors.email)}
|
||||
{...signupForm.register('email')}
|
||||
/>
|
||||
<FieldError errors={[signupForm.formState.errors.email]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(signupForm.formState.errors.password)}>
|
||||
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
aria-invalid={Boolean(signupForm.formState.errors.password)}
|
||||
{...signupForm.register('password')}
|
||||
/>
|
||||
<FieldError errors={[signupForm.formState.errors.password]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(signupForm.formState.errors.confirmPassword)}>
|
||||
<FieldLabel htmlFor="confirmPassword">Confirmar contraseña</FieldLabel>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
aria-invalid={Boolean(signupForm.formState.errors.confirmPassword)}
|
||||
{...signupForm.register('confirmPassword')}
|
||||
/>
|
||||
<FieldError errors={[signupForm.formState.errors.confirmPassword]} />
|
||||
</Field>
|
||||
|
||||
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={!signupForm.formState.isValid || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Creando cuenta...' : 'Crear cuenta'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
{mode === 'login' ? (
|
||||
<>
|
||||
¿No tenés cuenta?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setMode('signup')}
|
||||
>
|
||||
Crear cuenta
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
¿Ya tenés cuenta?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setMode('login')}
|
||||
>
|
||||
Iniciar sesión
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
53
apps/frontend/src/features/onboard/verify-email-page.tsx
Normal file
53
apps/frontend/src/features/onboard/verify-email-page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Route } from '@/routes/onboard/verify-email';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function VerifyEmailPage() {
|
||||
const search = Route.useSearch();
|
||||
const email = search.email || sessionStorage.getItem('pending-signup-email') || '';
|
||||
const [resendCooldown, setResendCooldown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendCooldown > 0) {
|
||||
const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [resendCooldown]);
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!email || resendCooldown > 0) return;
|
||||
|
||||
setResendCooldown(60);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen w-full items-center justify-center px-6">
|
||||
<section className="w-full max-w-sm rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||
<h1 className="mb-2 text-2xl font-semibold">Verificá tu email</h1>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Te enviamos un email de verificación a <strong>{email}</strong>. Hacé click en el link
|
||||
para confirmar tu cuenta.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={handleResend}
|
||||
disabled={resendCooldown > 0}
|
||||
>
|
||||
{resendCooldown > 0 ? `Reenviar en ${resendCooldown}s` : 'Reenviar email'}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
¿Ya verificaste tu email?{' '}
|
||||
<Link to="/onboard/create-complex" className="text-primary hover:underline">
|
||||
Continuar
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user