Initial commit
This commit is contained in:
273
apps/frontend/src/lib/api-client.ts
Normal file
273
apps/frontend/src/lib/api-client.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import type {
|
||||
Court,
|
||||
CreateCourtInput,
|
||||
CreatePublicBookingInput,
|
||||
Complex,
|
||||
CreateComplexPayload,
|
||||
CreateComplexResponse,
|
||||
CreateSportInput,
|
||||
OnboardingCompleteInput,
|
||||
OnboardingCompleteResponse,
|
||||
OnboardingResendOtpInput,
|
||||
OnboardingResendOtpResponse,
|
||||
OnboardingStartInput,
|
||||
OnboardingStartResponse,
|
||||
OnboardingVerifyOtpInput,
|
||||
OnboardingVerifyOtpResponse,
|
||||
PlanSummary,
|
||||
PublicAvailabilityResponse,
|
||||
PublicBooking,
|
||||
PublicBookingConfirmation,
|
||||
Sport,
|
||||
UpdateCourtInput,
|
||||
UpdateComplexPayload,
|
||||
UpdateComplexResponse,
|
||||
UpdateSportInput,
|
||||
UserProfileResponse,
|
||||
} from '@repo/api-contract'
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000'
|
||||
let accessToken: string | null = null
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status?: number
|
||||
details?: unknown
|
||||
causeError?: unknown
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options?: { status?: number; details?: unknown; causeError?: unknown },
|
||||
) {
|
||||
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>
|
||||
}
|
||||
|
||||
const handlers: ErrorHandlers = {}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
function extractMessage(details: unknown, fallback: string) {
|
||||
if (
|
||||
typeof details === 'object' &&
|
||||
details &&
|
||||
'message' in details &&
|
||||
typeof details.message === 'string'
|
||||
) {
|
||||
return details.message
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): ApiClientError {
|
||||
if (error instanceof ApiClientError) return error
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status
|
||||
const details = error.response?.data
|
||||
|
||||
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
|
||||
|
||||
config.headers = config.headers ?? {}
|
||||
config.headers.Authorization = `Bearer ${accessToken}`
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const normalized = normalizeError(error)
|
||||
|
||||
if (normalized.status === 403 && handlers.onForbidden) {
|
||||
await handlers.onForbidden(normalized)
|
||||
}
|
||||
|
||||
if (handlers.onError) {
|
||||
await handlers.onError(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
|
||||
},
|
||||
verifyOtp: async (payload: OnboardingVerifyOtpInput) => {
|
||||
const response = await http.post<OnboardingVerifyOtpResponse>(
|
||||
'/api/onboarding/verify-otp',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
resendOtp: async (payload: OnboardingResendOtpInput) => {
|
||||
const response = await http.post<OnboardingResendOtpResponse>(
|
||||
'/api/onboarding/resend-otp',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
complete: async (payload: OnboardingCompleteInput) => {
|
||||
const response = await http.post<OnboardingCompleteResponse>(
|
||||
'/api/onboarding/complete',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
},
|
||||
plans: {
|
||||
list: async () => {
|
||||
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
|
||||
},
|
||||
},
|
||||
complexes: {
|
||||
create: async (payload: CreateComplexPayload) => {
|
||||
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
|
||||
},
|
||||
getBySlug: async (slug: string) => {
|
||||
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
|
||||
},
|
||||
listMine: async () => {
|
||||
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
|
||||
},
|
||||
create: async (payload: CreateSportInput) => {
|
||||
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
|
||||
},
|
||||
},
|
||||
courts: {
|
||||
listByComplex: async (complexId: string) => {
|
||||
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
|
||||
},
|
||||
update: async (id: string, payload: UpdateCourtInput) => {
|
||||
const response = await http.patch<Court>(`/api/courts/${id}`, payload)
|
||||
return response.data
|
||||
},
|
||||
},
|
||||
publicBookings: {
|
||||
getAvailability: async (
|
||||
complexSlug: string,
|
||||
params: {
|
||||
date: string
|
||||
sportId?: string
|
||||
},
|
||||
) => {
|
||||
const response = await http.get<PublicAvailabilityResponse>(
|
||||
`/api/public-bookings/complex/${complexSlug}/availability`,
|
||||
{
|
||||
params,
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
create: async (complexSlug: string, payload: CreatePublicBookingInput) => {
|
||||
const response = await http.post<PublicBooking>(
|
||||
`/api/public-bookings/complex/${complexSlug}`,
|
||||
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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export type ApiClient = typeof apiClient
|
||||
|
||||
export function configureApiClient(nextHandlers: ErrorHandlers) {
|
||||
handlers.onForbidden = nextHandlers.onForbidden
|
||||
handlers.onError = nextHandlers.onError
|
||||
}
|
||||
|
||||
export function setApiAccessToken(token: string | null) {
|
||||
accessToken = token
|
||||
}
|
||||
166
apps/frontend/src/lib/auth.tsx
Normal file
166
apps/frontend/src/lib/auth.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
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'
|
||||
|
||||
type SignInParams = {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
type SignUpParams = {
|
||||
email: string
|
||||
password: string
|
||||
fullName: 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 }>
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
function getDisplayName(user: User | null): string {
|
||||
if (!user) return 'Usuario'
|
||||
|
||||
const fromMetadata =
|
||||
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 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'
|
||||
}
|
||||
|
||||
function getAvatarUrl(user: User | null): string | 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
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: PropsWithChildren) {
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
const init = async () => {
|
||||
const { data } = await supabase.auth.getSession()
|
||||
|
||||
if (!mounted) return
|
||||
|
||||
setSession(data.session)
|
||||
setUser(data.session?.user ?? null)
|
||||
setApiAccessToken(data.session?.access_token ?? null)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
void init()
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, nextSession) => {
|
||||
setSession(nextSession)
|
||||
setUser(nextSession?.user ?? null)
|
||||
setApiAccessToken(nextSession?.access_token ?? null)
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = useMemo<AuthContextValue>(() => {
|
||||
const displayName = getDisplayName(user)
|
||||
const initials = getInitials(displayName)
|
||||
const avatarUrl = getAvatarUrl(user)
|
||||
|
||||
return {
|
||||
user,
|
||||
session,
|
||||
loading,
|
||||
isAuthenticated: Boolean(user),
|
||||
displayName,
|
||||
initials,
|
||||
avatarUrl,
|
||||
signInWithPassword: async ({ email, password }) => {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
signUp: async ({ email, password, fullName }) => {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
full_name: fullName,
|
||||
name: fullName,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
requiresEmailConfirmation: !data.session,
|
||||
}
|
||||
},
|
||||
signOut: async () => {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [loading, session, user])
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
11
apps/frontend/src/lib/current-complex.ts
Normal file
11
apps/frontend/src/lib/current-complex.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
}
|
||||
|
||||
export function setCurrentComplexSlug(complexSlug: string) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(CURRENT_COMPLEX_SLUG_KEY, complexSlug)
|
||||
}
|
||||
11
apps/frontend/src/lib/query-client.ts
Normal file
11
apps/frontend/src/lib/query-client.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
18
apps/frontend/src/lib/supabase.ts
Normal file
18
apps/frontend/src/lib/supabase.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
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.',
|
||||
)
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: true,
|
||||
},
|
||||
})
|
||||
89
apps/frontend/src/lib/theme.tsx
Normal file
89
apps/frontend/src/lib/theme.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
type ResolvedTheme = 'light' | 'dark'
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme
|
||||
resolvedTheme: ResolvedTheme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const THEME_STORAGE_KEY = 'theme'
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
function getSystemTheme(): ResolvedTheme {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
|
||||
function applyResolvedTheme(theme: ResolvedTheme) {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: PropsWithChildren) {
|
||||
const [theme, setThemeState] = useState<Theme>('system')
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light')
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
const initialTheme: Theme =
|
||||
saved === 'light' || saved === 'dark' || saved === 'system'
|
||||
? saved
|
||||
: 'system'
|
||||
|
||||
setThemeState(initialTheme)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const updateTheme = () => {
|
||||
const nextResolvedTheme =
|
||||
theme === 'system' ? getSystemTheme() : theme
|
||||
setResolvedTheme(nextResolvedTheme)
|
||||
applyResolvedTheme(nextResolvedTheme)
|
||||
}
|
||||
|
||||
updateTheme()
|
||||
|
||||
mediaQuery.addEventListener('change', updateTheme)
|
||||
return () => mediaQuery.removeEventListener('change', updateTheme)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = (nextTheme: Theme) => {
|
||||
setThemeState(nextTheme)
|
||||
localStorage.setItem(THEME_STORAGE_KEY, nextTheme)
|
||||
}
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
}),
|
||||
[theme, resolvedTheme],
|
||||
)
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within ThemeProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export type { Theme }
|
||||
6
apps/frontend/src/lib/utils.ts
Normal file
6
apps/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user