feat: add city/state/country to complex, new settings page with sidebar, and Biome linting

- Added city, state, and country optional fields to Complex model
- Updated onboarding to include optional location fields
- Created new settings page with sidebar navigation (Datos del Complejo, Canchas)
- Replaced ESLint with Biome for frontend and backend linting
- Added parallel dev script with concurrently
- Migrated register-routes to use direct app.route() pattern
This commit is contained in:
Jose Selesan
2026-04-10 15:36:15 -03:00
parent 77004b14b0
commit 9ee98a4cb4
129 changed files with 2929 additions and 3186 deletions

View File

@@ -1,13 +1,12 @@
import axios, { AxiosError } from 'axios'
import type {
AdminBooking,
CreateAdminBookingInput,
Court,
CreateCourtInput,
CreatePublicBookingInput,
Complex,
Court,
CreateAdminBookingInput,
CreateComplexPayload,
CreateComplexResponse,
CreateCourtInput,
CreatePublicBookingInput,
CreateSportInput,
OnboardingCompleteInput,
OnboardingCompleteResponse,
@@ -23,48 +22,49 @@ import type {
PublicBookingConfirmation,
Sport,
UpdateAdminBookingStatusInput,
UpdateCourtInput,
UpdateComplexPayload,
UpdateComplexResponse,
UpdateCourtInput,
UpdateSportInput,
UserProfileResponse,
} from '@repo/api-contract'
} from '@repo/api-contract';
import axios, { AxiosError } from 'axios';
const apiBaseUrl =
import.meta.env.VITE_API_BASE_URL?.trim() ||
(import.meta.env.DEV ? 'http://localhost:3000' : window.location.origin)
let accessToken: string | null = null
(import.meta.env.DEV ? 'http://localhost:3000' : window.location.origin);
let accessToken: string | null = null;
export class ApiClientError extends Error {
status?: number
details?: unknown
causeError?: unknown
status?: number;
details?: unknown;
causeError?: unknown;
constructor(
message: string,
options?: { status?: number; details?: unknown; causeError?: unknown },
options?: { status?: number; details?: unknown; causeError?: unknown }
) {
super(message)
this.name = 'ApiClientError'
this.status = options?.status
this.details = options?.details
this.causeError = options?.causeError
super(message);
this.name = 'ApiClientError';
this.status = options?.status;
this.details = options?.details;
this.causeError = options?.causeError;
}
}
type ErrorHandlers = {
onForbidden?: (error: ApiClientError) => void | Promise<void>
onError?: (error: ApiClientError) => void | Promise<void>
}
onForbidden?: (error: ApiClientError) => void | Promise<void>;
onError?: (error: ApiClientError) => void | Promise<void>;
};
const handlers: ErrorHandlers = {}
const handlers: ErrorHandlers = {};
const http = axios.create({
baseURL: apiBaseUrl,
headers: {
'Content-Type': 'application/json',
},
})
});
function extractMessage(details: unknown, fallback: string) {
if (
@@ -73,239 +73,221 @@ function extractMessage(details: unknown, fallback: string) {
'message' in details &&
typeof details.message === 'string'
) {
return details.message
return details.message;
}
return fallback
return fallback;
}
function normalizeError(error: unknown): ApiClientError {
if (error instanceof ApiClientError) return error
if (error instanceof ApiClientError) return error;
if (axios.isAxiosError(error)) {
const status = error.response?.status
const details = error.response?.data
const status = error.response?.status;
const details = error.response?.data;
return new ApiClientError(
extractMessage(details, error.message || 'Request failed'),
{
status,
details,
causeError: error,
},
)
return new ApiClientError(extractMessage(details, error.message || 'Request failed'), {
status,
details,
causeError: error,
});
}
if (error instanceof Error) {
return new ApiClientError(error.message, {
causeError: error,
})
});
}
return new ApiClientError('Error inesperado.', {
causeError: error,
})
});
}
http.interceptors.request.use((config) => {
if (!accessToken) return config
if (!accessToken) return config;
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${accessToken}`
return config
})
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${accessToken}`;
return config;
});
http.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const normalized = normalizeError(error)
const normalized = normalizeError(error);
if (normalized.status === 403 && handlers.onForbidden) {
await handlers.onForbidden(normalized)
await handlers.onForbidden(normalized);
}
if (handlers.onError) {
await handlers.onError(normalized)
await handlers.onError(normalized);
}
return Promise.reject(normalized)
},
)
return Promise.reject(normalized);
}
);
export const apiClient = {
onboarding: {
start: async (payload: OnboardingStartInput) => {
const response = await http.post<OnboardingStartResponse>(
'/api/onboarding/start',
payload,
)
return response.data
const response = await http.post<OnboardingStartResponse>('/api/onboarding/start', payload);
return response.data;
},
verifyOtp: async (payload: OnboardingVerifyOtpInput) => {
const response = await http.post<OnboardingVerifyOtpResponse>(
'/api/onboarding/verify-otp',
payload,
)
return response.data
payload
);
return response.data;
},
resendOtp: async (payload: OnboardingResendOtpInput) => {
const response = await http.post<OnboardingResendOtpResponse>(
'/api/onboarding/resend-otp',
payload,
)
return response.data
payload
);
return response.data;
},
complete: async (payload: OnboardingCompleteInput) => {
const response = await http.post<OnboardingCompleteResponse>(
'/api/onboarding/complete',
payload,
)
return response.data
payload
);
return response.data;
},
},
plans: {
list: async () => {
const response = await http.get<PlanSummary[]>('/api/plans')
return response.data
const response = await http.get<PlanSummary[]>('/api/plans');
return response.data;
},
},
user: {
getProfile: async () => {
const response = await http.get<UserProfileResponse>('/api/user/profile')
return response.data
const response = await http.get<UserProfileResponse>('/api/user/profile');
return response.data;
},
},
complexes: {
create: async (payload: CreateComplexPayload) => {
const response = await http.post<CreateComplexResponse>(
'/api/complexes',
payload,
)
return response.data
const response = await http.post<CreateComplexResponse>('/api/complexes', payload);
return response.data;
},
getById: async (id: string) => {
const response = await http.get<Complex>(`/api/complexes/${id}`)
return response.data
const response = await http.get<Complex>(`/api/complexes/${id}`);
return response.data;
},
getBySlug: async (slug: string) => {
const response = await http.get<Complex>(`/api/complexes/slug/${slug}`)
return response.data
const response = await http.get<Complex>(`/api/complexes/slug/${slug}`);
return response.data;
},
update: async (id: string, payload: UpdateComplexPayload) => {
const response = await http.patch<UpdateComplexResponse>(
`/api/complexes/${id}`,
payload,
)
return response.data
const response = await http.patch<UpdateComplexResponse>(`/api/complexes/${id}`, payload);
return response.data;
},
listMine: async () => {
const response = await http.get<Complex[]>('/api/complexes/mine')
return response.data
const response = await http.get<Complex[]>('/api/complexes/mine');
return response.data;
},
},
sports: {
list: async () => {
const response = await http.get<Sport[]>('/api/sports')
return response.data
const response = await http.get<Sport[]>('/api/sports');
return response.data;
},
create: async (payload: CreateSportInput) => {
const response = await http.post<Sport>('/api/sports', payload)
return response.data
const response = await http.post<Sport>('/api/sports', payload);
return response.data;
},
update: async (id: string, payload: UpdateSportInput) => {
const response = await http.patch<Sport>(`/api/sports/${id}`, payload)
return response.data
const response = await http.patch<Sport>(`/api/sports/${id}`, payload);
return response.data;
},
},
courts: {
listByComplex: async (complexId: string) => {
const response = await http.get<Court[]>(`/api/courts/complex/${complexId}`)
return response.data
const response = await http.get<Court[]>(`/api/courts/complex/${complexId}`);
return response.data;
},
create: async (complexId: string, payload: CreateCourtInput) => {
const response = await http.post<Court>(
`/api/courts/complex/${complexId}`,
payload,
)
return response.data
const response = await http.post<Court>(`/api/courts/complex/${complexId}`, payload);
return response.data;
},
update: async (id: string, payload: UpdateCourtInput) => {
const response = await http.patch<Court>(`/api/courts/${id}`, payload)
return response.data
const response = await http.patch<Court>(`/api/courts/${id}`, payload);
return response.data;
},
},
publicBookings: {
getAvailability: async (
complexSlug: string,
params: {
date: string
sportId?: string
},
date: string;
sportId?: string;
}
) => {
const response = await http.get<PublicAvailabilityResponse>(
`/api/public-bookings/complex/${complexSlug}/availability`,
{
params,
},
)
return response.data
}
);
return response.data;
},
create: async (complexSlug: string, payload: CreatePublicBookingInput) => {
const response = await http.post<PublicBooking>(
`/api/public-bookings/complex/${complexSlug}`,
payload,
)
return response.data
payload
);
return response.data;
},
getConfirmation: async (complexSlug: string, bookingCode: string) => {
const response = await http.get<PublicBookingConfirmation>(
`/api/public-bookings/complex/${complexSlug}/confirmation/${bookingCode}`,
)
return response.data
`/api/public-bookings/complex/${complexSlug}/confirmation/${bookingCode}`
);
return response.data;
},
},
adminBookings: {
listByComplex: async (
complexId: string,
params: {
fromDate: string
},
fromDate: string;
}
) => {
const response = await http.get<{ bookings: AdminBooking[] }>(
`/api/admin-bookings/complex/${complexId}`,
{
params,
},
)
return response.data
}
);
return response.data;
},
create: async (complexId: string, payload: CreateAdminBookingInput) => {
const response = await http.post<AdminBooking>(
`/api/admin-bookings/complex/${complexId}`,
payload,
)
return response.data
payload
);
return response.data;
},
updateStatus: async (
bookingId: string,
payload: UpdateAdminBookingStatusInput,
) => {
updateStatus: async (bookingId: string, payload: UpdateAdminBookingStatusInput) => {
const response = await http.patch<AdminBooking>(
`/api/admin-bookings/${bookingId}/status`,
payload,
)
return response.data
payload
);
return response.data;
},
},
}
};
export type ApiClient = typeof apiClient
export type ApiClient = typeof apiClient;
export function configureApiClient(nextHandlers: ErrorHandlers) {
handlers.onForbidden = nextHandlers.onForbidden
handlers.onError = nextHandlers.onError
handlers.onForbidden = nextHandlers.onForbidden;
handlers.onError = nextHandlers.onError;
}
export function setApiAccessToken(token: string | null) {
accessToken = token
accessToken = token;
}

View File

@@ -1,119 +1,117 @@
import { setApiAccessToken } from '@/lib/api-client';
import { supabase } from '@/lib/supabase';
import type { Session, User } from '@supabase/supabase-js';
import {
type PropsWithChildren,
createContext,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from 'react'
import type { Session, User } from '@supabase/supabase-js'
import { setApiAccessToken } from '@/lib/api-client'
import { supabase } from '@/lib/supabase'
} from 'react';
type SignInParams = {
email: string
password: string
}
email: string;
password: string;
};
type SignUpParams = {
email: string
password: string
fullName: string
}
email: string;
password: string;
fullName: string;
};
type UpdateProfileParams = {
fullName: string
}
fullName: string;
};
type UpdatePasswordParams = {
password: string
}
password: string;
};
export type AuthContextValue = {
user: User | null
session: Session | null
loading: boolean
isAuthenticated: boolean
displayName: string
initials: string
avatarUrl: string | null
signInWithPassword: (params: SignInParams) => Promise<void>
signUp: (params: SignUpParams) => Promise<{ requiresEmailConfirmation: boolean }>
updateProfile: (params: UpdateProfileParams) => Promise<void>
updatePassword: (params: UpdatePasswordParams) => Promise<void>
signOut: () => Promise<void>
}
user: User | null;
session: Session | null;
loading: boolean;
isAuthenticated: boolean;
displayName: string;
initials: string;
avatarUrl: string | null;
signInWithPassword: (params: SignInParams) => Promise<void>;
signUp: (params: SignUpParams) => Promise<{ requiresEmailConfirmation: boolean }>;
updateProfile: (params: UpdateProfileParams) => Promise<void>;
updatePassword: (params: UpdatePasswordParams) => Promise<void>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null)
const AuthContext = createContext<AuthContextValue | null>(null);
function getDisplayName(user: User | null): string {
if (!user) return 'Usuario'
if (!user) return 'Usuario';
const fromMetadata =
user.user_metadata?.name ??
user.user_metadata?.full_name ??
user.user_metadata?.user_name
user.user_metadata?.name ?? user.user_metadata?.full_name ?? user.user_metadata?.user_name;
if (typeof fromMetadata === 'string' && fromMetadata.trim().length > 0) {
return fromMetadata.trim()
return fromMetadata.trim();
}
return user.email ?? 'Usuario'
return user.email ?? 'Usuario';
}
function getInitials(name: string): string {
const words = name.trim().split(/\s+/).slice(0, 2)
const initials = words.map((word) => word[0]?.toUpperCase() ?? '').join('')
return initials || 'U'
const words = name.trim().split(/\s+/).slice(0, 2);
const initials = words.map((word) => word[0]?.toUpperCase() ?? '').join('');
return initials || 'U';
}
function getAvatarUrl(user: User | null): string | null {
if (!user) return null
if (!user) return null;
const value = user.user_metadata?.avatar_url ?? user.user_metadata?.picture
return typeof value === 'string' && value.length > 0 ? value : null
const value = user.user_metadata?.avatar_url ?? user.user_metadata?.picture;
return typeof value === 'string' && value.length > 0 ? value : null;
}
export function AuthProvider({ children }: PropsWithChildren) {
const [session, setSession] = useState<Session | null>(null)
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [session, setSession] = useState<Session | null>(null);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true
let mounted = true;
const init = async () => {
const { data } = await supabase.auth.getSession()
const { data } = await supabase.auth.getSession();
if (!mounted) return
if (!mounted) return;
setSession(data.session)
setUser(data.session?.user ?? null)
setApiAccessToken(data.session?.access_token ?? null)
setLoading(false)
}
setSession(data.session);
setUser(data.session?.user ?? null);
setApiAccessToken(data.session?.access_token ?? null);
setLoading(false);
};
void init()
void init();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession)
setUser(nextSession?.user ?? null)
setApiAccessToken(nextSession?.access_token ?? null)
setLoading(false)
})
setSession(nextSession);
setUser(nextSession?.user ?? null);
setApiAccessToken(nextSession?.access_token ?? null);
setLoading(false);
});
return () => {
mounted = false
subscription.unsubscribe()
}
}, [])
mounted = false;
subscription.unsubscribe();
};
}, []);
const value = useMemo<AuthContextValue>(() => {
const displayName = getDisplayName(user)
const initials = getInitials(displayName)
const avatarUrl = getAvatarUrl(user)
const displayName = getDisplayName(user);
const initials = getInitials(displayName);
const avatarUrl = getAvatarUrl(user);
return {
user,
@@ -127,10 +125,10 @@ export function AuthProvider({ children }: PropsWithChildren) {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
});
if (error) {
throw error
throw error;
}
},
signUp: async ({ email, password, fullName }) => {
@@ -143,15 +141,15 @@ export function AuthProvider({ children }: PropsWithChildren) {
name: fullName,
},
},
})
});
if (error) {
throw error
throw error;
}
return {
requiresEmailConfirmation: !data.session,
}
};
},
updateProfile: async ({ fullName }) => {
const { error } = await supabase.auth.updateUser({
@@ -159,39 +157,39 @@ export function AuthProvider({ children }: PropsWithChildren) {
full_name: fullName,
name: fullName,
},
})
});
if (error) {
throw error
throw error;
}
},
updatePassword: async ({ password }) => {
const { error } = await supabase.auth.updateUser({
password,
})
});
if (error) {
throw error
throw error;
}
},
signOut: async () => {
const { error } = await supabase.auth.signOut()
const { error } = await supabase.auth.signOut();
if (error) {
throw error
throw error;
}
},
}
}, [loading, session, user])
};
}, [loading, session, user]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext)
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
throw new Error('useAuth must be used within AuthProvider');
}
return context
return context;
}

View File

@@ -1,11 +1,11 @@
const CURRENT_COMPLEX_SLUG_KEY = 'current-complex-slug'
const CURRENT_COMPLEX_SLUG_KEY = 'current-complex-slug';
export function getCurrentComplexSlug(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(CURRENT_COMPLEX_SLUG_KEY)
if (typeof window === 'undefined') return null;
return window.localStorage.getItem(CURRENT_COMPLEX_SLUG_KEY);
}
export function setCurrentComplexSlug(complexSlug: string) {
if (typeof window === 'undefined') return
window.localStorage.setItem(CURRENT_COMPLEX_SLUG_KEY, complexSlug)
if (typeof window === 'undefined') return;
window.localStorage.setItem(CURRENT_COMPLEX_SLUG_KEY, complexSlug);
}

View File

@@ -1,4 +1,4 @@
import { QueryClient } from '@tanstack/react-query'
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
@@ -8,4 +8,4 @@ export const queryClient = new QueryClient({
retry: 1,
},
},
})
});

View File

@@ -1,12 +1,12 @@
import { createClient } from '@supabase/supabase-js'
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error(
'Missing Supabase environment variables. Define VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY.',
)
'Missing Supabase environment variables. Define VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY.'
);
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
@@ -15,4 +15,4 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
persistSession: true,
detectSessionInUrl: true,
},
})
});

View File

@@ -1,68 +1,63 @@
import {
type PropsWithChildren,
createContext,
useContext,
useEffect,
useMemo,
useState,
type PropsWithChildren,
} from 'react'
} from 'react';
type Theme = 'light' | 'dark' | 'system'
type ResolvedTheme = 'light' | 'dark'
type Theme = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';
type ThemeContextValue = {
theme: Theme
resolvedTheme: ResolvedTheme
setTheme: (theme: Theme) => void
}
theme: Theme;
resolvedTheme: ResolvedTheme;
setTheme: (theme: Theme) => void;
};
const THEME_STORAGE_KEY = 'theme'
const ThemeContext = createContext<ThemeContextValue | null>(null)
const THEME_STORAGE_KEY = 'theme';
const ThemeContext = createContext<ThemeContextValue | null>(null);
function getSystemTheme(): ResolvedTheme {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyResolvedTheme(theme: ResolvedTheme) {
document.documentElement.classList.toggle('dark', theme === 'dark')
document.documentElement.classList.toggle('dark', theme === 'dark');
}
export function ThemeProvider({ children }: PropsWithChildren) {
const [theme, setThemeState] = useState<Theme>('system')
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light')
const [theme, setThemeState] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light');
useEffect(() => {
const saved = localStorage.getItem(THEME_STORAGE_KEY)
const saved = localStorage.getItem(THEME_STORAGE_KEY);
const initialTheme: Theme =
saved === 'light' || saved === 'dark' || saved === 'system'
? saved
: 'system'
saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system';
setThemeState(initialTheme)
}, [])
setThemeState(initialTheme);
}, []);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const updateTheme = () => {
const nextResolvedTheme =
theme === 'system' ? getSystemTheme() : theme
setResolvedTheme(nextResolvedTheme)
applyResolvedTheme(nextResolvedTheme)
}
const nextResolvedTheme = theme === 'system' ? getSystemTheme() : theme;
setResolvedTheme(nextResolvedTheme);
applyResolvedTheme(nextResolvedTheme);
};
updateTheme()
updateTheme();
mediaQuery.addEventListener('change', updateTheme)
return () => mediaQuery.removeEventListener('change', updateTheme)
}, [theme])
mediaQuery.addEventListener('change', updateTheme);
return () => mediaQuery.removeEventListener('change', updateTheme);
}, [theme]);
const setTheme = (nextTheme: Theme) => {
setThemeState(nextTheme)
localStorage.setItem(THEME_STORAGE_KEY, nextTheme)
}
setThemeState(nextTheme);
localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
};
const value = useMemo<ThemeContextValue>(
() => ({
@@ -70,20 +65,20 @@ export function ThemeProvider({ children }: PropsWithChildren) {
resolvedTheme,
setTheme,
}),
[theme, resolvedTheme],
)
[theme, resolvedTheme]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = useContext(ThemeContext)
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
throw new Error('useTheme must be used within ThemeProvider');
}
return context
return context;
}
export type { Theme }
export type { Theme };

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}