diff --git a/AGENTS.md b/AGENTS.md index 14357be..7e9f804 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,3 +149,47 @@ The app supports users with multiple complexes. Each user has a role per complex ### 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. + +## 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` diff --git a/apps/backend/src/modules/complex/handlers/create-complex.handler.ts b/apps/backend/src/modules/complex/handlers/create-complex.handler.ts index 55e9fb0..10df5ad 100644 --- a/apps/backend/src/modules/complex/handlers/create-complex.handler.ts +++ b/apps/backend/src/modules/complex/handlers/create-complex.handler.ts @@ -1,3 +1,4 @@ +import { db } from '@/lib/prisma'; import { createComplex } from '@/modules/complex/services/complex.service'; import type { AppContext } from '@/types/hono'; 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 user = c.get('user'); - const adminEmail = user.email; + let adminEmail = user?.email; + let userId = user?.id; 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({ complexName: payload.complexName, physicalAddress: payload.physicalAddress, adminEmail, + userId, planCode: payload.planCode, city: payload.city, state: payload.state, diff --git a/apps/backend/src/modules/complex/services/complex.service.ts b/apps/backend/src/modules/complex/services/complex.service.ts index 027f005..11956fd 100644 --- a/apps/backend/src/modules/complex/services/complex.service.ts +++ b/apps/backend/src/modules/complex/services/complex.service.ts @@ -6,6 +6,7 @@ export type CreateComplexInput = { complexName: string; physicalAddress: string; adminEmail: string; + userId?: string; planCode?: string; city?: string; state?: string; @@ -64,20 +65,36 @@ async function buildUniqueSlug(source: string, excludeComplexId?: string): Promi } export async function createComplex(input: CreateComplexInput) { - const complexSlug = await buildUniqueSlug(input.complexName); + const { userId, ...rest } = input; - return db.complex.create({ - data: { - id: uuidv7(), - complexName: input.complexName, - physicalAddress: input.physicalAddress.trim(), - city: input.city?.trim() || null, - state: input.state?.trim() || null, - country: input.country?.trim() || null, - complexSlug, - adminEmail: input.adminEmail, - planCode: input.planCode, - }, + return db.$transaction(async (tx) => { + const complexSlug = await buildUniqueSlug(input.complexName); + + const complex = await tx.complex.create({ + data: { + id: uuidv7(), + complexName: input.complexName, + physicalAddress: input.physicalAddress.trim(), + city: input.city?.trim() || null, + state: input.state?.trim() || null, + country: input.country?.trim() || null, + complexSlug, + adminEmail: input.adminEmail, + planCode: input.planCode, + }, + }); + + if (userId) { + await tx.complexUser.create({ + data: { + complexId: complex.id, + userId, + role: 'ADMIN', + }, + }); + } + + return complex; }); } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 78f8456..ce127af 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -38,7 +38,8 @@ "simple-icons": "^16.15.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "zustand": "^5.0.12" }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", diff --git a/apps/frontend/src/features/home/home-page.tsx b/apps/frontend/src/features/home/home-page.tsx index a8e16ca..f4aec80 100644 --- a/apps/frontend/src/features/home/home-page.tsx +++ b/apps/frontend/src/features/home/home-page.tsx @@ -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; diff --git a/apps/frontend/src/features/layout/root-layout.tsx b/apps/frontend/src/features/layout/root-layout.tsx index f0b88a4..4b39a63 100644 --- a/apps/frontend/src/features/layout/root-layout.tsx +++ b/apps/frontend/src/features/layout/root-layout.tsx @@ -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(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() { Mi cuenta + {myComplexesQuery.data && myComplexesQuery.data.length > 1 && ( + <> + + Cambiar complejo + + {myComplexesQuery.data.map((complex) => ( + { + await apiClient.complexes.select({ complexId: complex.id }); + setCurrentComplex(complex.complexSlug); + }} + > + {complex.complexSlug === complexSlug && } + {complex.complexName} + + ))} + + + )} { void navigate({ to: '/profile' }); diff --git a/apps/frontend/src/features/login/login-page.tsx b/apps/frontend/src/features/login/login-page.tsx index 67fd3b9..41c0678 100644 --- a/apps/frontend/src/features/login/login-page.tsx +++ b/apps/frontend/src/features/login/login-page.tsx @@ -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' }); } } diff --git a/apps/frontend/src/features/onboard/create-complex-page.tsx b/apps/frontend/src/features/onboard/create-complex-page.tsx new file mode 100644 index 0000000..bbdac55 --- /dev/null +++ b/apps/frontend/src/features/onboard/create-complex-page.tsx @@ -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; + +export function CreateComplexPage() { + const navigate = useNavigate(); + const { user } = useAuth(); + const [plans, setPlans] = useState([]); + const [errorMessage, setErrorMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm({ + 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[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 ( +
+
+

Crear tu complejo

+

+ Completá los datos de tu complejo para comenzar. +

+ +
+ {user?.email && ( + + Email + + + )} + + + Nombre del complejo + + + + + + Dirección física + + + + + + Ciudad + + + + + + Provincia/Estado + + + + + + País + + + + + + Plan + + {plans.length === 0 && ( +

+ Carga planes en la base para continuar. +

+ )} + +
+ + {errorMessage &&

{errorMessage}

} + + +
+
+
+ ); +} diff --git a/apps/frontend/src/features/onboard/onboard-login-page.tsx b/apps/frontend/src/features/onboard/onboard-login-page.tsx new file mode 100644 index 0000000..5a23e0c --- /dev/null +++ b/apps/frontend/src/features/onboard/onboard-login-page.tsx @@ -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; +type SignupForm = z.infer; + +export function OnboardLoginPage() { + const navigate = useNavigate(); + const { signInWithPassword, signUp } = useAuth(); + const [mode, setMode] = useState<'login' | 'signup'>('login'); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const loginForm = useForm({ + resolver: zodResolver(loginSchema), + mode: 'onChange', + defaultValues: { email: '', password: '' }, + }); + + const signupForm = useForm({ + 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 ( +
+
+

+ {mode === 'login' ? 'Iniciar sesión' : 'Crear cuenta'} +

+

+ {mode === 'login' + ? 'Ingresá con tu cuenta para continuar.' + : 'Creá tu cuenta para comenzar.'} +

+ + + +
+
+ +
+
+ O +
+
+ + {mode === 'login' ? ( +
+ + Email + + + + + + Contraseña + + + + + {submitError &&

{submitError}

} + + +
+ ) : ( +
+ + Nombre completo + + + + + + Email + + + + + + Contraseña + + + + + + Confirmar contraseña + + + + + {submitError &&

{submitError}

} + + +
+ )} + +

+ {mode === 'login' ? ( + <> + ¿No tenés cuenta?{' '} + + + ) : ( + <> + ¿Ya tenés cuenta?{' '} + + + )} +

+
+
+ ); +} diff --git a/apps/frontend/src/features/onboard/verify-email-page.tsx b/apps/frontend/src/features/onboard/verify-email-page.tsx new file mode 100644 index 0000000..e7a0f5f --- /dev/null +++ b/apps/frontend/src/features/onboard/verify-email-page.tsx @@ -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 ( +
+
+

Verificá tu email

+

+ Te enviamos un email de verificación a {email}. Hacé click en el link + para confirmar tu cuenta. +

+ +
+ + +

+ ¿Ya verificaste tu email?{' '} + + Continuar + +

+
+
+
+ ); +} diff --git a/apps/frontend/src/lib/api-client.ts b/apps/frontend/src/lib/api-client.ts index eabe813..8b5c54f 100644 --- a/apps/frontend/src/lib/api-client.ts +++ b/apps/frontend/src/lib/api-client.ts @@ -53,4 +53,4 @@ export function setApiAccessToken(_token: string | null) { // accessToken = token; } -export type ApiClient = typeof apiClient; \ No newline at end of file +export type ApiClient = typeof apiClient; diff --git a/apps/frontend/src/lib/api/base.ts b/apps/frontend/src/lib/api/base.ts index 34c6776..0201e21 100644 --- a/apps/frontend/src/lib/api/base.ts +++ b/apps/frontend/src/lib/api/base.ts @@ -31,4 +31,4 @@ export function configureApiClient(nextHandlers: ErrorHandlers) { handlers.onError = nextHandlers.onError; } -export { handlers }; \ No newline at end of file +export { handlers }; diff --git a/apps/frontend/src/lib/api/http.ts b/apps/frontend/src/lib/api/http.ts index 9e0c38f..089344f 100644 --- a/apps/frontend/src/lib/api/http.ts +++ b/apps/frontend/src/lib/api/http.ts @@ -57,4 +57,4 @@ http.interceptors.response.use( return Promise.reject(normalized); } -); \ No newline at end of file +); diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts index 73faa9f..861bcd6 100644 --- a/apps/frontend/src/lib/api/index.ts +++ b/apps/frontend/src/lib/api/index.ts @@ -15,4 +15,4 @@ export { listByComplex, createAdmin, updateStatus, -} from './resources/bookings'; \ No newline at end of file +} from './resources/bookings'; diff --git a/apps/frontend/src/lib/api/resources/bookings.ts b/apps/frontend/src/lib/api/resources/bookings.ts index 538a776..d850ae7 100644 --- a/apps/frontend/src/lib/api/resources/bookings.ts +++ b/apps/frontend/src/lib/api/resources/bookings.ts @@ -57,4 +57,4 @@ export async function updateStatus(bookingId: string, payload: UpdateAdminBookin payload ); return response.data; -} \ No newline at end of file +} diff --git a/apps/frontend/src/lib/api/resources/complexes.ts b/apps/frontend/src/lib/api/resources/complexes.ts index 8b5cbce..f7759f8 100644 --- a/apps/frontend/src/lib/api/resources/complexes.ts +++ b/apps/frontend/src/lib/api/resources/complexes.ts @@ -9,7 +9,9 @@ import type { } from '@repo/api-contract'; 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('/api/complexes', payload); return response.data; } @@ -46,4 +48,4 @@ export async function select(payload: SelectComplexInput) { { headers: { 'Content-Type': 'application/json' } } ); return response.data; -} \ No newline at end of file +} diff --git a/apps/frontend/src/lib/api/resources/courts.ts b/apps/frontend/src/lib/api/resources/courts.ts index 5c2dab9..9e2d469 100644 --- a/apps/frontend/src/lib/api/resources/courts.ts +++ b/apps/frontend/src/lib/api/resources/courts.ts @@ -14,4 +14,4 @@ export async function create(complexId: string, payload: CreateCourtInput) { export async function update(id: string, payload: UpdateCourtInput) { const response = await http.patch(`/api/courts/${id}`, payload); return response.data; -} \ No newline at end of file +} diff --git a/apps/frontend/src/lib/api/resources/onboarding.ts b/apps/frontend/src/lib/api/resources/onboarding.ts index bc8a70a..02b5d36 100644 --- a/apps/frontend/src/lib/api/resources/onboarding.ts +++ b/apps/frontend/src/lib/api/resources/onboarding.ts @@ -32,9 +32,6 @@ export async function resendOtp(payload: OnboardingResendOtpInput) { } export async function complete(payload: OnboardingCompleteInput) { - const response = await http.post( - '/api/onboarding/complete', - payload - ); + const response = await http.post('/api/onboarding/complete', payload); return response.data; -} \ No newline at end of file +} diff --git a/apps/frontend/src/lib/api/resources/plans.ts b/apps/frontend/src/lib/api/resources/plans.ts index 1db8e02..ebf115d 100644 --- a/apps/frontend/src/lib/api/resources/plans.ts +++ b/apps/frontend/src/lib/api/resources/plans.ts @@ -4,4 +4,4 @@ import { http } from '../http'; export async function list() { const response = await http.get('/api/plans'); return response.data; -} \ No newline at end of file +} diff --git a/apps/frontend/src/lib/api/resources/sports.ts b/apps/frontend/src/lib/api/resources/sports.ts index 112e56b..49404e9 100644 --- a/apps/frontend/src/lib/api/resources/sports.ts +++ b/apps/frontend/src/lib/api/resources/sports.ts @@ -14,4 +14,4 @@ export async function create(payload: CreateSportInput) { export async function update(id: string, payload: UpdateSportInput) { const response = await http.patch(`/api/sports/${id}`, payload); return response.data; -} \ No newline at end of file +} diff --git a/apps/frontend/src/lib/api/resources/user.ts b/apps/frontend/src/lib/api/resources/user.ts index 654dc11..113cd91 100644 --- a/apps/frontend/src/lib/api/resources/user.ts +++ b/apps/frontend/src/lib/api/resources/user.ts @@ -4,4 +4,4 @@ import { http } from '../http'; export async function getProfile() { const response = await http.get('/api/user/profile'); return response.data; -} \ No newline at end of file +} diff --git a/apps/frontend/src/lib/current-complex.ts b/apps/frontend/src/lib/current-complex.ts index 44af08a..97e84ea 100644 --- a/apps/frontend/src/lib/current-complex.ts +++ b/apps/frontend/src/lib/current-complex.ts @@ -1,4 +1,5 @@ const CURRENT_COMPLEX_SLUG_KEY = 'current-complex-slug'; +export const COMPLEX_CHANGE_EVENT = 'complex-change'; export function getCurrentComplexSlug(): string | null { if (typeof window === 'undefined') return null; @@ -8,4 +9,5 @@ export function getCurrentComplexSlug(): string | null { export function setCurrentComplexSlug(complexSlug: string) { if (typeof window === 'undefined') return; window.localStorage.setItem(CURRENT_COMPLEX_SLUG_KEY, complexSlug); + window.dispatchEvent(new Event(COMPLEX_CHANGE_EVENT)); } diff --git a/apps/frontend/src/lib/stores/current-complex-store.ts b/apps/frontend/src/lib/stores/current-complex-store.ts new file mode 100644 index 0000000..8b81b13 --- /dev/null +++ b/apps/frontend/src/lib/stores/current-complex-store.ts @@ -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()( + persist( + (set) => ({ + currentComplexSlug: null, + setCurrentComplex: (slug) => set({ currentComplexSlug: slug }), + }), + { + name: 'current-complex-slug', + } + ) +); diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 75feeb7..2e7d9b3 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -16,7 +16,9 @@ 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 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 OnboardCreateComplexRouteImport } from './routes/onboard/create-complex' import { Route as OnboardCompleteRouteImport } from './routes/onboard/complete' import { Route as AppProfileRouteImport } from './routes/_app/profile' import { Route as AppAboutRouteImport } from './routes/_app/about' @@ -61,11 +63,21 @@ const OnboardIndexRoute = OnboardIndexRouteImport.update({ path: '/', getParentRoute: () => OnboardRoute, } as any) +const OnboardVerifyEmailRoute = OnboardVerifyEmailRouteImport.update({ + id: '/verify-email', + path: '/verify-email', + getParentRoute: () => OnboardRoute, +} as any) const OnboardVerifyRoute = OnboardVerifyRouteImport.update({ id: '/verify', path: '/verify', getParentRoute: () => OnboardRoute, } as any) +const OnboardCreateComplexRoute = OnboardCreateComplexRouteImport.update({ + id: '/create-complex', + path: '/create-complex', + getParentRoute: () => OnboardRoute, +} as any) const OnboardCompleteRoute = OnboardCompleteRouteImport.update({ id: '/complete', path: '/complete', @@ -124,7 +136,9 @@ export interface FileRoutesByFullPath { '/about': typeof AppAboutRoute '/profile': typeof AppProfileRoute '/onboard/complete': typeof OnboardCompleteRoute + '/onboard/create-complex': typeof OnboardCreateComplexRoute '/onboard/verify': typeof OnboardVerifyRoute + '/onboard/verify-email': typeof OnboardVerifyEmailRoute '/onboard/': typeof OnboardIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute @@ -139,7 +153,9 @@ export interface FileRoutesByTo { '/about': typeof AppAboutRoute '/profile': typeof AppProfileRoute '/onboard/complete': typeof OnboardCompleteRoute + '/onboard/create-complex': typeof OnboardCreateComplexRoute '/onboard/verify': typeof OnboardVerifyRoute + '/onboard/verify-email': typeof OnboardVerifyEmailRoute '/onboard': typeof OnboardIndexRoute '/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute '/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute @@ -158,7 +174,9 @@ export interface FileRoutesById { '/_app/about': typeof AppAboutRoute '/_app/profile': typeof AppProfileRoute '/onboard/complete': typeof OnboardCompleteRoute + '/onboard/create-complex': typeof OnboardCreateComplexRoute '/onboard/verify': typeof OnboardVerifyRoute + '/onboard/verify-email': typeof OnboardVerifyEmailRoute '/onboard/': typeof OnboardIndexRoute '/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute '/_app/_authenticated/': typeof AppAuthenticatedIndexRoute @@ -178,7 +196,9 @@ export interface FileRouteTypes { | '/about' | '/profile' | '/onboard/complete' + | '/onboard/create-complex' | '/onboard/verify' + | '/onboard/verify-email' | '/onboard/' | '/$complexSlug/booking/' | '/$complexSlug/booking/confirmed/$bookingCode' @@ -193,7 +213,9 @@ export interface FileRouteTypes { | '/about' | '/profile' | '/onboard/complete' + | '/onboard/create-complex' | '/onboard/verify' + | '/onboard/verify-email' | '/onboard' | '/$complexSlug/booking' | '/$complexSlug/booking/confirmed/$bookingCode' @@ -211,7 +233,9 @@ export interface FileRouteTypes { | '/_app/about' | '/_app/profile' | '/onboard/complete' + | '/onboard/create-complex' | '/onboard/verify' + | '/onboard/verify-email' | '/onboard/' | '/$complexSlug/booking/' | '/_app/_authenticated/' @@ -280,6 +304,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OnboardIndexRouteImport 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': { id: '/onboard/verify' path: '/verify' @@ -287,6 +318,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OnboardVerifyRouteImport 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': { id: '/onboard/complete' path: '/complete' @@ -386,13 +424,17 @@ const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( interface OnboardRouteChildren { OnboardCompleteRoute: typeof OnboardCompleteRoute + OnboardCreateComplexRoute: typeof OnboardCreateComplexRoute OnboardVerifyRoute: typeof OnboardVerifyRoute + OnboardVerifyEmailRoute: typeof OnboardVerifyEmailRoute OnboardIndexRoute: typeof OnboardIndexRoute } const OnboardRouteChildren: OnboardRouteChildren = { OnboardCompleteRoute: OnboardCompleteRoute, + OnboardCreateComplexRoute: OnboardCreateComplexRoute, OnboardVerifyRoute: OnboardVerifyRoute, + OnboardVerifyEmailRoute: OnboardVerifyEmailRoute, OnboardIndexRoute: OnboardIndexRoute, } diff --git a/apps/frontend/src/routes/auth-callback.tsx b/apps/frontend/src/routes/auth-callback.tsx index 321f255..2395365 100644 --- a/apps/frontend/src/routes/auth-callback.tsx +++ b/apps/frontend/src/routes/auth-callback.tsx @@ -1,7 +1,7 @@ -import { useEffect } from 'react'; import { apiClient } from '@/lib/api-client'; import { useAuth } from '@/lib/auth'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { useEffect } from 'react'; function AuthCallbackPage() { const navigate = useNavigate(); @@ -28,10 +28,10 @@ function AuthCallbackPage() { navigate({ to: '/select-complex' }); } } else { - navigate({ to: '/' }); + navigate({ to: '/onboard/create-complex' }); } } catch { - navigate({ to: '/login' }); + navigate({ to: '/onboard' }); } } @@ -47,4 +47,4 @@ function AuthCallbackPage() { export const Route = createFileRoute('/auth-callback')({ component: AuthCallbackPage, -}); \ No newline at end of file +}); diff --git a/apps/frontend/src/routes/onboard/create-complex.tsx b/apps/frontend/src/routes/onboard/create-complex.tsx new file mode 100644 index 0000000..05b9bd5 --- /dev/null +++ b/apps/frontend/src/routes/onboard/create-complex.tsx @@ -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, +}); diff --git a/apps/frontend/src/routes/onboard/index.tsx b/apps/frontend/src/routes/onboard/index.tsx index 5710af4..003a135 100644 --- a/apps/frontend/src/routes/onboard/index.tsx +++ b/apps/frontend/src/routes/onboard/index.tsx @@ -1,6 +1,11 @@ -import { OnboardStartPage } from '@/features/onboard/onboard-start-page'; -import { createFileRoute } from '@tanstack/react-router'; +import { OnboardLoginPage } from '@/features/onboard/onboard-login-page'; +import { createFileRoute, redirect } from '@tanstack/react-router'; export const Route = createFileRoute('/onboard/')({ - component: OnboardStartPage, + beforeLoad: ({ context }) => { + if (!context.auth.isAuthenticated) { + throw redirect({ to: '/login', search: { redirect: '/onboard/create-complex' } }); + } + }, + component: OnboardLoginPage, }); diff --git a/apps/frontend/src/routes/onboard/verify-email.tsx b/apps/frontend/src/routes/onboard/verify-email.tsx new file mode 100644 index 0000000..94c51a6 --- /dev/null +++ b/apps/frontend/src/routes/onboard/verify-email.tsx @@ -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, +}); diff --git a/bun.lock b/bun.lock index 5686ad0..2a7407a 100644 --- a/bun.lock +++ b/bun.lock @@ -69,6 +69,7 @@ "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", + "zustand": "^5.0.12", }, "devDependencies": { "@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=="], + "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/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],