feat: implement onboarding flow with complex creation and email verification, and add Zustand for state management

This commit is contained in:
Jose Selesan
2026-04-21 09:14:45 -03:00
parent 23e4218f6f
commit 07408dbbbe
29 changed files with 843 additions and 71 deletions

View File

@@ -20,6 +20,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { ApiClientError, apiClient } from '@/lib/api-client';
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
import { zodResolver } from '@hookform/resolvers/zod';
import type { AdminBooking } from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -136,6 +137,24 @@ export function HomePage() {
queryFn: () => apiClient.complexes.getCurrent(),
});
const { setCurrentComplex } = useCurrentComplexStore();
useEffect(() => {
if (currentComplexQuery.data) {
setCurrentComplex(currentComplexQuery.data.complexSlug);
}
}, [currentComplexQuery.data, setCurrentComplex]);
useEffect(() => {
const unsubscribe = useCurrentComplexStore.subscribe((state, prevState) => {
if (state.currentComplexSlug !== prevState.currentComplexSlug) {
queryClient.invalidateQueries({ queryKey: ['current-complex'] });
queryClient.invalidateQueries({ queryKey: ['admin-bookings'] });
}
});
return unsubscribe;
}, [queryClient]);
const myComplexesQuery = useQuery({
queryKey: ['my-complexes'],
queryFn: () => apiClient.complexes.listMine(),
@@ -146,7 +165,11 @@ export function HomePage() {
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
await apiClient.complexes.select({ complexId: myComplexesQuery.data[0].id });
queryClient.invalidateQueries({ queryKey: ['current-complex'] });
} else if (myComplexesQuery.data && myComplexesQuery.data.length > 1 && !currentComplexQuery.data) {
} else if (
myComplexesQuery.data &&
myComplexesQuery.data.length > 1 &&
!currentComplexQuery.data
) {
navigate({ to: '/select-complex' });
}
}
@@ -154,7 +177,13 @@ export function HomePage() {
if (!currentComplexQuery.isLoading && !currentComplexQuery.data && myComplexesQuery.data) {
autoSelect();
}
}, [currentComplexQuery.isLoading, currentComplexQuery.data, myComplexesQuery.data, navigate, queryClient]);
}, [
currentComplexQuery.isLoading,
currentComplexQuery.data,
myComplexesQuery.data,
navigate,
queryClient,
]);
const selectedComplex = currentComplexQuery.data ?? null;

View File

@@ -10,48 +10,33 @@ import {
} from '@/components/ui/dropdown-menu';
import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import {
getCurrentComplexSlug,
setCurrentComplexSlug as persistCurrentComplexSlug,
} from '@/lib/current-complex';
import { useCurrentComplexStore } from '@/lib/stores/current-complex-store';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { LogOut, Menu, UserRound } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { ThemeSwitcher } from './theme-switcher';
export function RootLayout() {
const navigate = useNavigate();
const { isAuthenticated, displayName, initials, avatarUrl, signOut } = useAuth();
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null);
const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore();
const myComplexesQuery = useQuery({
queryKey: ['my-complexes'],
enabled: isAuthenticated,
queryFn: () => apiClient.complexes.listMine(),
});
useEffect(() => {
setCurrentComplexSlug(getCurrentComplexSlug());
}, []);
useEffect(() => {
if (currentComplexSlug) return;
const fallbackSlug = myComplexesQuery.data?.[0]?.complexSlug;
if (fallbackSlug) {
setCurrentComplexSlug(fallbackSlug);
persistCurrentComplexSlug(fallbackSlug);
}
}, [currentComplexSlug, myComplexesQuery.data]);
const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug;
const currentComplexName = useMemo(() => {
const complexes = myComplexesQuery.data ?? [];
if (complexes.length === 0) return 'Mi complejo';
const selected = complexes.find((complex) => complex.complexSlug === currentComplexSlug);
const selected = complexes.find((complex) => complex.complexSlug === complexSlug);
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
}, [currentComplexSlug, myComplexesQuery.data]);
}, [complexSlug, myComplexesQuery.data]);
const handleSignOut = async () => {
await signOut();
@@ -110,6 +95,26 @@ export function RootLayout() {
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
<DropdownMenuSeparator />
{myComplexesQuery.data && myComplexesQuery.data.length > 1 && (
<>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Cambiar complejo
</DropdownMenuLabel>
{myComplexesQuery.data.map((complex) => (
<DropdownMenuItem
key={complex.id}
onSelect={async () => {
await apiClient.complexes.select({ complexId: complex.id });
setCurrentComplex(complex.complexSlug);
}}
>
{complex.complexSlug === complexSlug && <span className="mr-2"></span>}
{complex.complexName}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/profile' });

View File

@@ -41,18 +41,13 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
async function handlePostLogin() {
const complexes = await apiClient.complexes.listMine();
if (complexes.length === 1) {
if (complexes.length === 0) {
await navigate({ to: redirectTo });
} else if (complexes.length === 1) {
await apiClient.complexes.select({ complexId: complexes[0].id });
navigate({ to: redirectTo });
} else if (complexes.length > 1) {
const current = await apiClient.complexes.getCurrent();
if (current) {
navigate({ to: redirectTo });
} else {
navigate({ to: '/select-complex' });
}
} else {
await navigate({ to: redirectTo });
navigate({ to: '/select-complex' });
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}