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

@@ -149,3 +149,47 @@ The app supports users with multiple complexes. Each user has a role per complex
### Profile Role ### Profile Role
The profile page shows the user's role for the **selected complex**, not the global user role. This is fetched from `ComplexUser` table using the cookie value. The profile page shows the user's role for the **selected complex**, not the global user role. This is fetched from `ComplexUser` table using the cookie value.
## Frontend API Client
The API client is split into modular files for better maintainability and testability.
### Structure
```
lib/
├── api-client.ts # Main barrel file (~70 lines)
└── api/
├── base.ts # ApiClientError, configureApiClient, apiBaseUrl
├── http.ts # Axios instance with interceptors
├── index.ts # Re-exports all resources
└── resources/
├── complexes.ts # Complex endpoints
├── courts.ts # Court endpoints
├── bookings.ts # Public & admin booking endpoints
├── user.ts # User profile endpoint
├── sports.ts # Sport endpoints
├── onboarding.ts # Onboarding endpoints
└── plans.ts # Plan endpoints
```
### Using the API Client
```typescript
import { apiClient, authClient, ApiClientError, configureApiClient } from '@/lib/api-client';
// Via apiClient (barrel)
await apiClient.complexes.listMine();
await apiClient.publicBookings.getAvailability('my-club', { date: '2026-04-20' });
// Direct imports (for better tree-shaking)
import { complexes, getAvailability } from '@/lib/api';
await complexes.listMine();
await getAvailability('my-club', { date: '2026-04-20' });
```
### Adding a New Resource
1. Create `lib/api/resources/[resource].ts`
2. Export functions using `http` from `../http`
3. Add exports in `lib/api/index.ts`

View File

@@ -1,3 +1,4 @@
import { db } from '@/lib/prisma';
import { createComplex } from '@/modules/complex/services/complex.service'; import { createComplex } from '@/modules/complex/services/complex.service';
import type { AppContext } from '@/types/hono'; import type { AppContext } from '@/types/hono';
import type { CreateComplexInput } from '@repo/api-contract'; import type { CreateComplexInput } from '@repo/api-contract';
@@ -6,16 +7,32 @@ export async function createComplexHandler(c: AppContext) {
const payload = c.req.valid('json' as never) as CreateComplexInput; const payload = c.req.valid('json' as never) as CreateComplexInput;
const user = c.get('user'); const user = c.get('user');
const adminEmail = user.email; let adminEmail = user?.email;
let userId = user?.id;
if (!adminEmail) { if (!adminEmail) {
return c.json({ message: 'El usuario autenticado no tiene email.' }, 400); const body = await c.req.json().catch(() => ({}));
adminEmail = body.adminEmail;
userId = body.userId;
}
if (!adminEmail) {
return c.json({ message: 'Se requiere email del administrador.' }, 400);
}
if (!userId && adminEmail) {
const existingUser = await db.user.findUnique({
where: { email: adminEmail },
select: { id: true },
});
userId = existingUser?.id;
} }
const complex = await createComplex({ const complex = await createComplex({
complexName: payload.complexName, complexName: payload.complexName,
physicalAddress: payload.physicalAddress, physicalAddress: payload.physicalAddress,
adminEmail, adminEmail,
userId,
planCode: payload.planCode, planCode: payload.planCode,
city: payload.city, city: payload.city,
state: payload.state, state: payload.state,

View File

@@ -6,6 +6,7 @@ export type CreateComplexInput = {
complexName: string; complexName: string;
physicalAddress: string; physicalAddress: string;
adminEmail: string; adminEmail: string;
userId?: string;
planCode?: string; planCode?: string;
city?: string; city?: string;
state?: string; state?: string;
@@ -64,9 +65,12 @@ async function buildUniqueSlug(source: string, excludeComplexId?: string): Promi
} }
export async function createComplex(input: CreateComplexInput) { export async function createComplex(input: CreateComplexInput) {
const { userId, ...rest } = input;
return db.$transaction(async (tx) => {
const complexSlug = await buildUniqueSlug(input.complexName); const complexSlug = await buildUniqueSlug(input.complexName);
return db.complex.create({ const complex = await tx.complex.create({
data: { data: {
id: uuidv7(), id: uuidv7(),
complexName: input.complexName, complexName: input.complexName,
@@ -79,6 +83,19 @@ export async function createComplex(input: CreateComplexInput) {
planCode: input.planCode, planCode: input.planCode,
}, },
}); });
if (userId) {
await tx.complexUser.create({
data: {
complexId: complex.id,
userId,
role: 'ADMIN',
},
});
}
return complex;
});
} }
export async function getComplexById(id: string) { export async function getComplexById(id: string) {

View File

@@ -38,7 +38,8 @@
"simple-icons": "^16.15.0", "simple-icons": "^16.15.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vaul": "^1.1.2" "vaul": "^1.1.2",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",

View File

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

View File

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

View File

@@ -41,19 +41,14 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
async function handlePostLogin() { async function handlePostLogin() {
const complexes = await apiClient.complexes.listMine(); 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 }); await apiClient.complexes.select({ complexId: complexes[0].id });
navigate({ to: redirectTo }); navigate({ to: redirectTo });
} else if (complexes.length > 1) {
const current = await apiClient.complexes.getCurrent();
if (current) {
navigate({ to: redirectTo });
} else { } else {
navigate({ to: '/select-complex' }); navigate({ to: '/select-complex' });
} }
} else {
await navigate({ to: redirectTo });
}
} }
const onSubmit = async (values: LoginForm) => { const onSubmit = async (values: LoginForm) => {

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

View File

@@ -9,7 +9,9 @@ import type {
} from '@repo/api-contract'; } from '@repo/api-contract';
import { http } from '../http'; import { http } from '../http';
export async function create(payload: CreateComplexPayload) { export async function create(
payload: CreateComplexPayload & { adminEmail?: string; userId?: string }
) {
const response = await http.post<CreateComplexResponse>('/api/complexes', payload); const response = await http.post<CreateComplexResponse>('/api/complexes', payload);
return response.data; return response.data;
} }

View File

@@ -32,9 +32,6 @@ export async function resendOtp(payload: OnboardingResendOtpInput) {
} }
export async function complete(payload: OnboardingCompleteInput) { export async function complete(payload: OnboardingCompleteInput) {
const response = await http.post<OnboardingCompleteResponse>( const response = await http.post<OnboardingCompleteResponse>('/api/onboarding/complete', payload);
'/api/onboarding/complete',
payload
);
return response.data; return response.data;
} }

View File

@@ -1,4 +1,5 @@
const CURRENT_COMPLEX_SLUG_KEY = 'current-complex-slug'; const CURRENT_COMPLEX_SLUG_KEY = 'current-complex-slug';
export const COMPLEX_CHANGE_EVENT = 'complex-change';
export function getCurrentComplexSlug(): string | null { export function getCurrentComplexSlug(): string | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
@@ -8,4 +9,5 @@ export function getCurrentComplexSlug(): string | null {
export function setCurrentComplexSlug(complexSlug: string) { export function setCurrentComplexSlug(complexSlug: string) {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.localStorage.setItem(CURRENT_COMPLEX_SLUG_KEY, complexSlug); window.localStorage.setItem(CURRENT_COMPLEX_SLUG_KEY, complexSlug);
window.dispatchEvent(new Event(COMPLEX_CHANGE_EVENT));
} }

View File

@@ -0,0 +1,19 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface CurrentComplexState {
currentComplexSlug: string | null;
setCurrentComplex: (slug: string | null) => void;
}
export const useCurrentComplexStore = create<CurrentComplexState>()(
persist(
(set) => ({
currentComplexSlug: null,
setCurrentComplex: (slug) => set({ currentComplexSlug: slug }),
}),
{
name: 'current-complex-slug',
}
)
);

View File

@@ -16,7 +16,9 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthCallbackRouteImport } from './routes/auth-callback' import { Route as AuthCallbackRouteImport } from './routes/auth-callback'
import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppRouteRouteImport } from './routes/_app/route'
import { Route as OnboardIndexRouteImport } from './routes/onboard/index' import { Route as OnboardIndexRouteImport } from './routes/onboard/index'
import { Route as OnboardVerifyEmailRouteImport } from './routes/onboard/verify-email'
import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify' import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify'
import { Route as OnboardCreateComplexRouteImport } from './routes/onboard/create-complex'
import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete' import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete'
import { Route as AppProfileRouteImport } from './routes/_app/profile' import { Route as AppProfileRouteImport } from './routes/_app/profile'
import { Route as AppAboutRouteImport } from './routes/_app/about' import { Route as AppAboutRouteImport } from './routes/_app/about'
@@ -61,11 +63,21 @@ const OnboardIndexRoute = OnboardIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => OnboardRoute, getParentRoute: () => OnboardRoute,
} as any) } as any)
const OnboardVerifyEmailRoute = OnboardVerifyEmailRouteImport.update({
id: '/verify-email',
path: '/verify-email',
getParentRoute: () => OnboardRoute,
} as any)
const OnboardVerifyRoute = OnboardVerifyRouteImport.update({ const OnboardVerifyRoute = OnboardVerifyRouteImport.update({
id: '/verify', id: '/verify',
path: '/verify', path: '/verify',
getParentRoute: () => OnboardRoute, getParentRoute: () => OnboardRoute,
} as any) } as any)
const OnboardCreateComplexRoute = OnboardCreateComplexRouteImport.update({
id: '/create-complex',
path: '/create-complex',
getParentRoute: () => OnboardRoute,
} as any)
const OnboardCompleteRoute = OnboardCompleteRouteImport.update({ const OnboardCompleteRoute = OnboardCompleteRouteImport.update({
id: '/complete', id: '/complete',
path: '/complete', path: '/complete',
@@ -124,7 +136,9 @@ export interface FileRoutesByFullPath {
'/about': typeof AppAboutRoute '/about': typeof AppAboutRoute
'/profile': typeof AppProfileRoute '/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute '/onboard/complete': typeof OnboardCompleteRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute '/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard/': typeof OnboardIndexRoute '/onboard/': typeof OnboardIndexRoute
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
@@ -139,7 +153,9 @@ export interface FileRoutesByTo {
'/about': typeof AppAboutRoute '/about': typeof AppAboutRoute
'/profile': typeof AppProfileRoute '/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute '/onboard/complete': typeof OnboardCompleteRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute '/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard': typeof OnboardIndexRoute '/onboard': typeof OnboardIndexRoute
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
@@ -158,7 +174,9 @@ export interface FileRoutesById {
'/_app/about': typeof AppAboutRoute '/_app/about': typeof AppAboutRoute
'/_app/profile': typeof AppProfileRoute '/_app/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute '/onboard/complete': typeof OnboardCompleteRoute
'/onboard/create-complex': typeof OnboardCreateComplexRoute
'/onboard/verify': typeof OnboardVerifyRoute '/onboard/verify': typeof OnboardVerifyRoute
'/onboard/verify-email': typeof OnboardVerifyEmailRoute
'/onboard/': typeof OnboardIndexRoute '/onboard/': typeof OnboardIndexRoute
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute '/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
@@ -178,7 +196,9 @@ export interface FileRouteTypes {
| '/about' | '/about'
| '/profile' | '/profile'
| '/onboard/complete' | '/onboard/complete'
| '/onboard/create-complex'
| '/onboard/verify' | '/onboard/verify'
| '/onboard/verify-email'
| '/onboard/' | '/onboard/'
| '/$complexSlug/booking/' | '/$complexSlug/booking/'
| '/$complexSlug/booking/confirmed/$bookingCode' | '/$complexSlug/booking/confirmed/$bookingCode'
@@ -193,7 +213,9 @@ export interface FileRouteTypes {
| '/about' | '/about'
| '/profile' | '/profile'
| '/onboard/complete' | '/onboard/complete'
| '/onboard/create-complex'
| '/onboard/verify' | '/onboard/verify'
| '/onboard/verify-email'
| '/onboard' | '/onboard'
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/$complexSlug/booking/confirmed/$bookingCode' | '/$complexSlug/booking/confirmed/$bookingCode'
@@ -211,7 +233,9 @@ export interface FileRouteTypes {
| '/_app/about' | '/_app/about'
| '/_app/profile' | '/_app/profile'
| '/onboard/complete' | '/onboard/complete'
| '/onboard/create-complex'
| '/onboard/verify' | '/onboard/verify'
| '/onboard/verify-email'
| '/onboard/' | '/onboard/'
| '/$complexSlug/booking/' | '/$complexSlug/booking/'
| '/_app/_authenticated/' | '/_app/_authenticated/'
@@ -280,6 +304,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OnboardIndexRouteImport preLoaderRoute: typeof OnboardIndexRouteImport
parentRoute: typeof OnboardRoute parentRoute: typeof OnboardRoute
} }
'/onboard/verify-email': {
id: '/onboard/verify-email'
path: '/verify-email'
fullPath: '/onboard/verify-email'
preLoaderRoute: typeof OnboardVerifyEmailRouteImport
parentRoute: typeof OnboardRoute
}
'/onboard/verify': { '/onboard/verify': {
id: '/onboard/verify' id: '/onboard/verify'
path: '/verify' path: '/verify'
@@ -287,6 +318,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OnboardVerifyRouteImport preLoaderRoute: typeof OnboardVerifyRouteImport
parentRoute: typeof OnboardRoute parentRoute: typeof OnboardRoute
} }
'/onboard/create-complex': {
id: '/onboard/create-complex'
path: '/create-complex'
fullPath: '/onboard/create-complex'
preLoaderRoute: typeof OnboardCreateComplexRouteImport
parentRoute: typeof OnboardRoute
}
'/onboard/complete': { '/onboard/complete': {
id: '/onboard/complete' id: '/onboard/complete'
path: '/complete' path: '/complete'
@@ -386,13 +424,17 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
interface OnboardRouteChildren { interface OnboardRouteChildren {
OnboardCompleteRoute: typeof OnboardCompleteRoute OnboardCompleteRoute: typeof OnboardCompleteRoute
OnboardCreateComplexRoute: typeof OnboardCreateComplexRoute
OnboardVerifyRoute: typeof OnboardVerifyRoute OnboardVerifyRoute: typeof OnboardVerifyRoute
OnboardVerifyEmailRoute: typeof OnboardVerifyEmailRoute
OnboardIndexRoute: typeof OnboardIndexRoute OnboardIndexRoute: typeof OnboardIndexRoute
} }
const OnboardRouteChildren: OnboardRouteChildren = { const OnboardRouteChildren: OnboardRouteChildren = {
OnboardCompleteRoute: OnboardCompleteRoute, OnboardCompleteRoute: OnboardCompleteRoute,
OnboardCreateComplexRoute: OnboardCreateComplexRoute,
OnboardVerifyRoute: OnboardVerifyRoute, OnboardVerifyRoute: OnboardVerifyRoute,
OnboardVerifyEmailRoute: OnboardVerifyEmailRoute,
OnboardIndexRoute: OnboardIndexRoute, OnboardIndexRoute: OnboardIndexRoute,
} }

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useEffect } from 'react';
function AuthCallbackPage() { function AuthCallbackPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -28,10 +28,10 @@ function AuthCallbackPage() {
navigate({ to: '/select-complex' }); navigate({ to: '/select-complex' });
} }
} else { } else {
navigate({ to: '/' }); navigate({ to: '/onboard/create-complex' });
} }
} catch { } catch {
navigate({ to: '/login' }); navigate({ to: '/onboard' });
} }
} }

View File

@@ -0,0 +1,14 @@
import { CreateComplexPage } from '@/features/onboard/create-complex-page';
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/onboard/create-complex')({
beforeLoad: ({ context }) => {
const hasSession = context.auth.isAuthenticated;
const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email'));
if (!hasSession && !hasPendingSignup) {
throw redirect({ to: '/onboard' });
}
},
component: CreateComplexPage,
});

View File

@@ -1,6 +1,11 @@
import { OnboardStartPage } from '@/features/onboard/onboard-start-page'; import { OnboardLoginPage } from '@/features/onboard/onboard-login-page';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/onboard/')({ export const Route = createFileRoute('/onboard/')({
component: OnboardStartPage, beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login', search: { redirect: '/onboard/create-complex' } });
}
},
component: OnboardLoginPage,
}); });

View File

@@ -0,0 +1,18 @@
import { VerifyEmailPage } from '@/features/onboard/verify-email-page';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { z } from 'zod';
const verifyEmailSearchSchema = z.object({
email: z.string().email().optional(),
});
export const Route = createFileRoute('/onboard/verify-email')({
validateSearch: verifyEmailSearchSchema,
beforeLoad: () => {
const hasPendingSignup = Boolean(sessionStorage.getItem('pending-signup-email'));
if (!hasPendingSignup) {
throw redirect({ to: '/onboard' });
}
},
component: VerifyEmailPage,
});

View File

@@ -69,6 +69,7 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zustand": "^5.0.12",
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
@@ -1467,6 +1468,8 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@dotenvx/dotenvx/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], "@dotenvx/dotenvx/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],