feat: implement complex selection flow

This commit is contained in:
Jose Selesan
2026-04-20 15:47:47 -03:00
parent a5e39c94c5
commit 28fd51f926
17 changed files with 453 additions and 38 deletions

View File

@@ -111,3 +111,41 @@ To enable login with Google:
3. The backend configures `socialProviders.google` automatically. 3. The backend configures `socialProviders.google` automatically.
4. Frontend uses `authClient.signIn.social({ provider: 'google', callbackURL: 'http://localhost:5173' })`. 4. Frontend uses `authClient.signIn.social({ provider: 'google', callbackURL: 'http://localhost:5173' })`.
## Complex Selection (Multi-tenancy)
The app supports users with multiple complexes. Each user has a role per complex (`ADMIN` or `EMPLOYEE`), stored in `ComplexUser` table.
### Flow
1. **Login** (password or Google OAuth):
- After auth, call `GET /api/complexes/mine` to get user's complexes
- If 1 complex → auto-select via `POST /api/complexes/select` → redirect to `/`
- If >1 complexes → redirect to `/select-complex`
2. **Select Complex Page** (`/select-complex`):
- List all complexes with roles
- User selects one → `POST /api/complexes/select` → set cookie → redirect to `/`
3. **Home Page** (`/`):
- If no cookie (first visit) → auto-select if 1 complex, else redirect
- Use `GET /api/complexes/me` to get current selected complex
### Cookie
- Name: `selected-complex-id`
- httpOnly, secure (prod), sameSite: strict
- MaxAge: 30 days
- Set by `POST /api/complexes/select`
### Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/complexes/mine` | List all complexes with role |
| GET | `/api/complexes/me` | Get currently selected complex |
| POST | `/api/complexes/select` | Select complex (sets cookie) |
### 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.

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,9 @@ import { requireAuth } from '@/middlewares/require-auth.middleware';
import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler'; import { createComplexHandler } from '@/modules/complex/handlers/create-complex.handler';
import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler'; import { getComplexByIdHandler } from '@/modules/complex/handlers/get-complex-by-id.handler';
import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler'; import { getComplexBySlugHandler } from '@/modules/complex/handlers/get-complex-by-slug.handler';
import { getCurrentComplexHandler } from '@/modules/complex/handlers/get-current-complex.handler';
import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler'; import { listMyComplexesHandler } from '@/modules/complex/handlers/list-my-complexes.handler';
import { selectComplexHandler } from '@/modules/complex/handlers/select-complex.handler';
import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler'; import { updateComplexHandler } from '@/modules/complex/handlers/update-complex.handler';
import type { AppEnv } from '@/types/hono'; import type { AppEnv } from '@/types/hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
@@ -17,6 +19,8 @@ const complexSlugParamsSchema = z.object({ slug: z.string().trim().min(1) });
complexRoutes.use('*', requireAuth); complexRoutes.use('*', requireAuth);
complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler); complexRoutes.post('/', zValidator('json', createComplexSchema), createComplexHandler);
complexRoutes.get('/me', getCurrentComplexHandler);
complexRoutes.post('/select', selectComplexHandler);
complexRoutes.get('/mine', listMyComplexesHandler); complexRoutes.get('/mine', listMyComplexesHandler);
complexRoutes.get( complexRoutes.get(
'/slug/:slug', '/slug/:slug',

View File

@@ -0,0 +1,22 @@
import { getCurrentComplex } from '@/modules/complex/services/complex.service';
import type { AppContext } from '@/types/hono';
import { getCookie } from 'hono/cookie';
const SELECTED_COMPLEX_COOKIE = 'selected-complex-id';
export async function getCurrentComplexHandler(c: AppContext) {
const user = c.get('user');
const complexId = getCookie(c, SELECTED_COMPLEX_COOKIE);
if (!complexId) {
return c.json(null);
}
const complex = await getCurrentComplex(user.id, complexId);
if (!complex) {
return c.json(null);
}
return c.json(complex);
}

View File

@@ -0,0 +1,38 @@
import { selectComplex } from '@/modules/complex/services/complex.service';
import type { AppContext } from '@/types/hono';
import { setCookie } from 'hono/cookie';
import { z } from 'zod';
const SELECTED_COMPLEX_COOKIE = 'selected-complex-id';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
const selectComplexSchema = z.object({
complexId: z.string().uuid(),
});
export async function selectComplexHandler(c: AppContext) {
const user = c.get('user');
const body = await c.req.json();
const result = selectComplexSchema.safeParse(body);
if (!result.success) {
return c.json({ message: 'Invalid payload', issues: result.error.issues }, { status: 400 });
}
const { complexId } = result.data;
const complex = await selectComplex(user.id, complexId);
if (!complex) {
return c.json({ message: 'Complex not found or not associated with user' }, { status: 404 });
}
setCookie(c, SELECTED_COMPLEX_COOKIE, complexId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: COOKIE_MAX_AGE,
});
return c.json(complex);
}

View File

@@ -104,7 +104,52 @@ export async function listMyComplexes(userId: string) {
}, },
}); });
return complexUsers.map((complexUser) => complexUser.complex); return complexUsers.map((complexUser) => ({
...complexUser.complex,
role: complexUser.role,
}));
}
export async function getCurrentComplex(userId: string, complexId: string) {
const complexUser = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId,
},
},
include: {
complex: true,
},
});
if (!complexUser) return null;
return {
...complexUser.complex,
role: complexUser.role,
};
}
export async function selectComplex(userId: string, complexId: string) {
const complexUser = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId,
},
},
include: {
complex: true,
},
});
if (!complexUser) return null;
return {
...complexUser.complex,
role: complexUser.role,
};
} }
export async function updateComplex(id: string, input: UpdateComplexInput) { export async function updateComplex(id: string, input: UpdateComplexInput) {

View File

@@ -1,8 +1,12 @@
import { getUserProfile } from '@/modules/user/services/user.service'; import { getUserProfile } from '@/modules/user/services/user.service';
import type { AppContext } from '@/types/hono'; import type { AppContext } from '@/types/hono';
import { getCookie } from 'hono/cookie';
export function getUserProfileHandler(c: AppContext) { const SELECTED_COMPLEX_COOKIE = 'selected-complex-id';
export async function getUserProfileHandler(c: AppContext) {
const user = c.get('user'); const user = c.get('user');
const profile = getUserProfile(user); const complexId = getCookie(c, SELECTED_COMPLEX_COOKIE);
const profile = await getUserProfile(user, complexId);
return c.json(profile); return c.json(profile);
} }

View File

@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import type { User } from '@/lib/auth'; import type { User } from '@/lib/auth';
import { db } from '@/lib/prisma';
import type { UserProfile } from '@repo/api-contract'; import type { UserProfile } from '@repo/api-contract';
function getGravatarUrl(email: string): string { function getGravatarUrl(email: string): string {
@@ -7,12 +8,29 @@ function getGravatarUrl(email: string): string {
return `https://www.gravatar.com/avatar/${hash}?d=identicon`; return `https://www.gravatar.com/avatar/${hash}?d=identicon`;
} }
export function getUserProfile(user: User): UserProfile { export async function getUserProfile(user: User, complexId?: string): Promise<UserProfile> {
let role = 'member';
if (complexId) {
const complexUser = await db.complexUser.findUnique({
where: {
complexId_userId: {
complexId,
userId: user.id,
},
},
});
if (complexUser) {
role = complexUser.role.toLowerCase();
}
}
return { return {
id: user.id, id: user.id,
fullName: user.name, fullName: user.name,
email: user.email, email: user.email,
role: (user as any).role || 'member', role,
avatarUrl: user.image || getGravatarUrl(user.email), avatarUrl: user.image || getGravatarUrl(user.email),
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
}; };

View File

@@ -20,13 +20,10 @@ 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 {
getCurrentComplexSlug,
setCurrentComplexSlug as persistCurrentComplexSlug,
} from '@/lib/current-complex';
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';
import { useNavigate } from '@tanstack/react-router';
import { Check, Copy } from 'lucide-react'; import { Check, Copy } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@@ -123,9 +120,9 @@ function effectiveStatusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED
export function HomePage() { export function HomePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate();
const todayIso = useMemo(() => toIsoDateLocal(new Date()), []); const todayIso = useMemo(() => toIsoDateLocal(new Date()), []);
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null);
const [fromDate, setFromDate] = useState(todayIso); const [fromDate, setFromDate] = useState(todayIso);
const [manualDate, setManualDate] = useState(todayIso); const [manualDate, setManualDate] = useState(todayIso);
const [selectedSportId, setSelectedSportId] = useState<string | undefined>(); const [selectedSportId, setSelectedSportId] = useState<string | undefined>();
@@ -134,35 +131,32 @@ export function HomePage() {
const [isManualBookingOpen, setIsManualBookingOpen] = useState(false); const [isManualBookingOpen, setIsManualBookingOpen] = useState(false);
const [urlCopied, setUrlCopied] = useState(false); const [urlCopied, setUrlCopied] = useState(false);
useEffect(() => { const currentComplexQuery = useQuery({
setCurrentComplexSlug(getCurrentComplexSlug()); queryKey: ['current-complex'],
}, []); queryFn: () => apiClient.complexes.getCurrent(),
});
const myComplexesQuery = useQuery({ const myComplexesQuery = useQuery({
queryKey: ['my-complexes'], queryKey: ['my-complexes'],
queryFn: () => apiClient.complexes.listMine(), queryFn: () => apiClient.complexes.listMine(),
}); });
const selectedComplex = useMemo(() => { useEffect(() => {
const complexes = myComplexesQuery.data ?? []; async function autoSelect() {
if (myComplexesQuery.data && myComplexesQuery.data.length === 1) {
if (complexes.length === 0) return null; await apiClient.complexes.select({ complexId: myComplexesQuery.data[0].id });
queryClient.invalidateQueries({ queryKey: ['current-complex'] });
if (currentComplexSlug) { } else if (myComplexesQuery.data && myComplexesQuery.data.length > 1 && !currentComplexQuery.data) {
const match = complexes.find((complex) => complex.complexSlug === currentComplexSlug); navigate({ to: '/select-complex' });
}
if (match) return match;
} }
return complexes[0]; if (!currentComplexQuery.isLoading && !currentComplexQuery.data && myComplexesQuery.data) {
}, [currentComplexSlug, myComplexesQuery.data]); autoSelect();
}
}, [currentComplexQuery.isLoading, currentComplexQuery.data, myComplexesQuery.data, navigate, queryClient]);
useEffect(() => { const selectedComplex = currentComplexQuery.data ?? null;
if (!selectedComplex) return;
setCurrentComplexSlug(selectedComplex.complexSlug);
persistCurrentComplexSlug(selectedComplex.complexSlug);
}, [selectedComplex]);
useEffect(() => { useEffect(() => {
if (!selectedComplex?.id) return; if (!selectedComplex?.id) return;

View File

@@ -1,7 +1,7 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Field, FieldError, FieldLabel } from '@/components/ui/field'; import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { authClient } from '@/lib/api-client'; import { apiClient, authClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Link, useNavigate } from '@tanstack/react-router'; import { Link, useNavigate } from '@tanstack/react-router';
@@ -38,12 +38,30 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
}, },
}); });
async function handlePostLogin() {
const complexes = await apiClient.complexes.listMine();
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 });
}
}
const onSubmit = async (values: LoginForm) => { const onSubmit = async (values: LoginForm) => {
setSubmitError(null); setSubmitError(null);
try { try {
await signInWithPassword(values); await signInWithPassword(values);
await navigate({ to: redirectTo }); await handlePostLogin();
} catch { } catch {
setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.'); setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.');
} }
@@ -55,9 +73,7 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
try { try {
await authClient.signIn.social({ await authClient.signIn.social({
provider: 'google', provider: 'google',
callbackURL: redirectTo.startsWith('http') callbackURL: `${window.location.origin}/auth-callback`,
? redirectTo
: `${window.location.origin}${redirectTo}`,
}); });
} catch { } catch {
setSubmitError('No pudimos iniciar sesión con Google.'); setSubmitError('No pudimos iniciar sesión con Google.');

View File

@@ -0,0 +1,98 @@
import { Button } from '@/components/ui/button';
import { apiClient } from '@/lib/api-client';
import type { ComplexWithRole } from '@repo/api-contract';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { Building2, Loader2 } from 'lucide-react';
export function SelectComplexPage() {
const navigate = useNavigate();
const complexesQuery = useQuery({
queryKey: ['my-complexes'],
queryFn: () => apiClient.complexes.listMine(),
});
const selectMutation = useMutation({
mutationFn: async (complexId: string) => {
const response = await apiClient.complexes.select({ complexId });
return response;
},
onSuccess: () => {
navigate({ to: '/' });
},
});
const handleSelect = (complex: ComplexWithRole) => {
selectMutation.mutate(complex.id);
};
if (complexesQuery.isLoading) {
return (
<main className="flex min-h-screen w-full items-center justify-center px-6">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</main>
);
}
if (complexesQuery.isError) {
return (
<main className="flex min-h-screen w-full items-center justify-center px-6">
<div className="text-center">
<p className="text-destructive">Error al cargar tus complejos.</p>
<Button variant="link" className="mt-2" onClick={() => complexesQuery.refetch()}>
Reintentar
</Button>
</div>
</main>
);
}
const complexes = complexesQuery.data ?? [];
if (complexes.length === 0) {
return (
<main className="flex min-h-screen w-full items-center justify-center px-6">
<div className="text-center">
<p className="text-muted-foreground">No tenés complejos asociados.</p>
</div>
</main>
);
}
return (
<main className="flex min-h-screen w-full items-center justify-center px-6">
<section className="w-full max-w-md space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Seleccioná un complejo</h1>
<p className="text-sm text-muted-foreground">
Elegí el complejo con el que vas a trabajar.
</p>
</div>
<div className="space-y-2">
{complexes.map((complex) => (
<button
key={complex.id}
type="button"
className="flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors hover:bg-accent"
onClick={() => handleSelect(complex)}
disabled={selectMutation.isPending}
>
<Building2 className="h-5 w-5 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium">{complex.complexName}</p>
<p className="text-sm text-muted-foreground">
{complex.city ?? 'Sin ciudad'} · {complex.role}
</p>
</div>
{selectMutation.isPending && selectMutation.variables === complex.id && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</button>
))}
</div>
</section>
</main>
);
}

View File

@@ -1,6 +1,7 @@
import type { import type {
AdminBooking, AdminBooking,
Complex, Complex,
ComplexWithRole,
Court, Court,
CreateAdminBookingInput, CreateAdminBookingInput,
CreateComplexPayload, CreateComplexPayload,
@@ -20,6 +21,7 @@ import type {
PublicAvailabilityResponse, PublicAvailabilityResponse,
PublicBooking, PublicBooking,
PublicBookingConfirmation, PublicBookingConfirmation,
SelectComplexInput,
Sport, Sport,
UpdateAdminBookingStatusInput, UpdateAdminBookingStatusInput,
UpdateComplexPayload, UpdateComplexPayload,
@@ -187,7 +189,17 @@ export const apiClient = {
return response.data; return response.data;
}, },
listMine: async () => { listMine: async () => {
const response = await http.get<Complex[]>('/api/complexes/mine'); const response = await http.get<ComplexWithRole[]>('/api/complexes/mine');
return response.data;
},
getCurrent: async () => {
const response = await http.get<ComplexWithRole | null>('/api/complexes/me');
return response.data;
},
select: async (payload: SelectComplexInput) => {
const response = await http.post<ComplexWithRole>('/api/complexes/select', JSON.stringify(payload), {
headers: { 'Content-Type': 'application/json' }
});
return response.data; return response.data;
}, },
}, },

View File

@@ -9,9 +9,11 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as SelectComplexRouteImport } from './routes/select-complex'
import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as OnboardRouteImport } from './routes/onboard' import { Route as OnboardRouteImport } from './routes/onboard'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
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 OnboardVerifyRouteImport } from './routes/onboard/verify' import { Route as OnboardVerifyRouteImport } from './routes/onboard/verify'
@@ -25,6 +27,11 @@ import { Route as ComplexSlugBookingIndexRouteImport } from './routes/$complexSl
import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode' import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode'
import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit' import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit'
const SelectComplexRoute = SelectComplexRouteImport.update({
id: '/select-complex',
path: '/select-complex',
getParentRoute: () => rootRouteImport,
} as any)
const ResetPasswordRoute = ResetPasswordRouteImport.update({ const ResetPasswordRoute = ResetPasswordRouteImport.update({
id: '/reset-password', id: '/reset-password',
path: '/reset-password', path: '/reset-password',
@@ -40,6 +47,11 @@ const LoginRoute = LoginRouteImport.update({
path: '/login', path: '/login',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AuthCallbackRoute = AuthCallbackRouteImport.update({
id: '/auth-callback',
path: '/auth-callback',
getParentRoute: () => rootRouteImport,
} as any)
const AppRouteRoute = AppRouteRouteImport.update({ const AppRouteRoute = AppRouteRouteImport.update({
id: '/_app', id: '/_app',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
@@ -103,9 +115,11 @@ const AppAuthenticatedComplexSlugEditRoute =
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof AppAuthenticatedIndexRoute '/': typeof AppAuthenticatedIndexRoute
'/auth-callback': typeof AuthCallbackRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/onboard': typeof OnboardRouteWithChildren '/onboard': typeof OnboardRouteWithChildren
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/select-complex': typeof SelectComplexRoute
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/about': typeof AppAboutRoute '/about': typeof AppAboutRoute
'/profile': typeof AppProfileRoute '/profile': typeof AppProfileRoute
@@ -118,8 +132,10 @@ export interface FileRoutesByFullPath {
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof AppAuthenticatedIndexRoute '/': typeof AppAuthenticatedIndexRoute
'/auth-callback': typeof AuthCallbackRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/select-complex': typeof SelectComplexRoute
'/about': typeof AppAboutRoute '/about': typeof AppAboutRoute
'/profile': typeof AppProfileRoute '/profile': typeof AppProfileRoute
'/onboard/complete': typeof OnboardCompleteRoute '/onboard/complete': typeof OnboardCompleteRoute
@@ -132,9 +148,11 @@ export interface FileRoutesByTo {
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/_app': typeof AppRouteRouteWithChildren '/_app': typeof AppRouteRouteWithChildren
'/auth-callback': typeof AuthCallbackRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/onboard': typeof OnboardRouteWithChildren '/onboard': typeof OnboardRouteWithChildren
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/select-complex': typeof SelectComplexRoute
'/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren '/_app/_authenticated': typeof AppAuthenticatedRouteRouteWithChildren
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren '/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
'/_app/about': typeof AppAboutRoute '/_app/about': typeof AppAboutRoute
@@ -151,9 +169,11 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/auth-callback'
| '/login' | '/login'
| '/onboard' | '/onboard'
| '/reset-password' | '/reset-password'
| '/select-complex'
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/about' | '/about'
| '/profile' | '/profile'
@@ -166,8 +186,10 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/auth-callback'
| '/login' | '/login'
| '/reset-password' | '/reset-password'
| '/select-complex'
| '/about' | '/about'
| '/profile' | '/profile'
| '/onboard/complete' | '/onboard/complete'
@@ -179,9 +201,11 @@ export interface FileRouteTypes {
id: id:
| '__root__' | '__root__'
| '/_app' | '/_app'
| '/auth-callback'
| '/login' | '/login'
| '/onboard' | '/onboard'
| '/reset-password' | '/reset-password'
| '/select-complex'
| '/_app/_authenticated' | '/_app/_authenticated'
| '/$complexSlug/booking' | '/$complexSlug/booking'
| '/_app/about' | '/_app/about'
@@ -197,14 +221,23 @@ export interface FileRouteTypes {
} }
export interface RootRouteChildren { export interface RootRouteChildren {
AppRouteRoute: typeof AppRouteRouteWithChildren AppRouteRoute: typeof AppRouteRouteWithChildren
AuthCallbackRoute: typeof AuthCallbackRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
OnboardRoute: typeof OnboardRouteWithChildren OnboardRoute: typeof OnboardRouteWithChildren
ResetPasswordRoute: typeof ResetPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute
SelectComplexRoute: typeof SelectComplexRoute
ComplexSlugBookingRoute: typeof ComplexSlugBookingRouteWithChildren ComplexSlugBookingRoute: typeof ComplexSlugBookingRouteWithChildren
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/select-complex': {
id: '/select-complex'
path: '/select-complex'
fullPath: '/select-complex'
preLoaderRoute: typeof SelectComplexRouteImport
parentRoute: typeof rootRouteImport
}
'/reset-password': { '/reset-password': {
id: '/reset-password' id: '/reset-password'
path: '/reset-password' path: '/reset-password'
@@ -226,6 +259,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/auth-callback': {
id: '/auth-callback'
path: '/auth-callback'
fullPath: '/auth-callback'
preLoaderRoute: typeof AuthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_app': { '/_app': {
id: '/_app' id: '/_app'
path: '' path: ''
@@ -375,9 +415,11 @@ const ComplexSlugBookingRouteWithChildren =
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
AppRouteRoute: AppRouteRouteWithChildren, AppRouteRoute: AppRouteRouteWithChildren,
AuthCallbackRoute: AuthCallbackRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
OnboardRoute: OnboardRouteWithChildren, OnboardRoute: OnboardRouteWithChildren,
ResetPasswordRoute: ResetPasswordRoute, ResetPasswordRoute: ResetPasswordRoute,
SelectComplexRoute: SelectComplexRoute,
ComplexSlugBookingRoute: ComplexSlugBookingRouteWithChildren, ComplexSlugBookingRoute: ComplexSlugBookingRouteWithChildren,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport

View File

@@ -0,0 +1,50 @@
import { useEffect } from 'react';
import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
function AuthCallbackPage() {
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
useEffect(() => {
async function handleCallback() {
if (!isAuthenticated) {
navigate({ to: '/login' });
return;
}
try {
const complexes = await apiClient.complexes.listMine();
if (complexes.length === 1) {
await apiClient.complexes.select({ complexId: complexes[0].id });
navigate({ to: '/' });
} else if (complexes.length > 1) {
const current = await apiClient.complexes.getCurrent();
if (current) {
navigate({ to: '/' });
} else {
navigate({ to: '/select-complex' });
}
} else {
navigate({ to: '/' });
}
} catch {
navigate({ to: '/login' });
}
}
handleCallback();
}, [isAuthenticated, navigate]);
return (
<main className="flex min-h-screen w-full items-center justify-center px-6">
<p className="text-sm text-muted-foreground">Completando sesión...</p>
</main>
);
}
export const Route = createFileRoute('/auth-callback')({
component: AuthCallbackPage,
});

View File

@@ -0,0 +1,6 @@
import { SelectComplexPage } from '@/features/select-complex/select-complex-page';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/select-complex')({
component: SelectComplexPage,
});

View File

@@ -113,6 +113,24 @@ export const updateComplexSchema = z
}, },
) )
export const complexUserRoleSchema = z.enum(['ADMIN', 'EMPLOYEE'])
export type ComplexUserRole = z.infer<typeof complexUserRoleSchema>
export const selectComplexSchema = z.object({
complexId: z.string().uuid(),
})
export type SelectComplexInput = z.infer<typeof selectComplexSchema>
export const complexWithRoleSchema = complexSchema.merge(
z.object({
role: complexUserRoleSchema,
})
)
export type ComplexWithRole = z.infer<typeof complexWithRoleSchema>
export type Complex = z.infer<typeof complexSchema> export type Complex = z.infer<typeof complexSchema>
export type CreateComplexInput = z.infer<typeof createComplexSchema> export type CreateComplexInput = z.infer<typeof createComplexSchema>
export type UpdateComplexInput = z.infer<typeof updateComplexSchema> export type UpdateComplexInput = z.infer<typeof updateComplexSchema>

View File

@@ -26,12 +26,22 @@ export type {
OnboardingVerifyOtpInput, OnboardingVerifyOtpInput,
OnboardingVerifyOtpResponse, OnboardingVerifyOtpResponse,
} from './onboarding' } from './onboarding'
export { complexSchema, createComplexSchema, updateComplexSchema } from './complex' export {
complexSchema,
complexUserRoleSchema,
complexWithRoleSchema,
createComplexSchema,
selectComplexSchema,
updateComplexSchema,
} from './complex'
export type { export type {
Complex, Complex,
ComplexUserRole,
ComplexWithRole,
CreateComplexInput, CreateComplexInput,
CreateComplexPayload, CreateComplexPayload,
CreateComplexResponse, CreateComplexResponse,
SelectComplexInput,
UpdateComplexInput, UpdateComplexInput,
UpdateComplexPayload, UpdateComplexPayload,
UpdateComplexResponse, UpdateComplexResponse,