diff --git a/apps/frontend/src/lib/api-client.ts b/apps/frontend/src/lib/api-client.ts index 66823dc..eabe813 100644 --- a/apps/frontend/src/lib/api-client.ts +++ b/apps/frontend/src/lib/api-client.ts @@ -1,41 +1,7 @@ -import type { - AdminBooking, - Complex, - ComplexWithRole, - Court, - CreateAdminBookingInput, - CreateComplexPayload, - CreateComplexResponse, - CreateCourtInput, - CreatePublicBookingInput, - CreateSportInput, - OnboardingCompleteInput, - OnboardingCompleteResponse, - OnboardingResendOtpInput, - OnboardingResendOtpResponse, - OnboardingStartInput, - OnboardingStartResponse, - OnboardingVerifyOtpInput, - OnboardingVerifyOtpResponse, - PlanSummary, - PublicAvailabilityResponse, - PublicBooking, - PublicBookingConfirmation, - SelectComplexInput, - Sport, - UpdateAdminBookingStatusInput, - UpdateComplexPayload, - UpdateComplexResponse, - UpdateCourtInput, - UpdateSportInput, - UserProfileResponse, -} from '@repo/api-contract'; -import axios, { AxiosError } from 'axios'; import { createAuthClient } from 'better-auth/react'; +import * as api from './api'; -const apiBaseUrl = - import.meta.env.VITE_API_BASE_URL?.trim() || - (import.meta.env.DEV ? 'http://localhost:3000' : window.location.origin); +const apiBaseUrl = api.apiBaseUrl; export const authClient = createAuthClient({ baseURL: apiBaseUrl, @@ -61,245 +27,30 @@ export class ApiClientError extends Error { } } -type ErrorHandlers = { - onForbidden?: (error: ApiClientError) => void | Promise; - onError?: (error: ApiClientError) => void | Promise; -}; - -const handlers: ErrorHandlers = {}; - -const http = axios.create({ - baseURL: apiBaseUrl, - withCredentials: true, - 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.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('/api/onboarding/start', payload); - return response.data; - }, - verifyOtp: async (payload: OnboardingVerifyOtpInput) => { - const response = await http.post( - '/api/onboarding/verify-otp', - payload - ); - return response.data; - }, - resendOtp: async (payload: OnboardingResendOtpInput) => { - const response = await http.post( - '/api/onboarding/resend-otp', - payload - ); - return response.data; - }, - complete: async (payload: OnboardingCompleteInput) => { - const response = await http.post( - '/api/onboarding/complete', - payload - ); - return response.data; - }, - }, - plans: { - list: async () => { - const response = await http.get('/api/plans'); - return response.data; - }, - }, - user: { - getProfile: async () => { - const response = await http.get('/api/user/profile'); - return response.data; - }, - }, - complexes: { - create: async (payload: CreateComplexPayload) => { - const response = await http.post('/api/complexes', payload); - return response.data; - }, - getById: async (id: string) => { - const response = await http.get(`/api/complexes/${id}`); - return response.data; - }, - getBySlug: async (slug: string) => { - const response = await http.get(`/api/complexes/slug/${slug}`); - return response.data; - }, - update: async (id: string, payload: UpdateComplexPayload) => { - const response = await http.patch(`/api/complexes/${id}`, payload); - return response.data; - }, - listMine: async () => { - const response = await http.get('/api/complexes/mine'); - return response.data; - }, - getCurrent: async () => { - const response = await http.get('/api/complexes/me'); - return response.data; - }, - select: async (payload: SelectComplexInput) => { - const response = await http.post('/api/complexes/select', JSON.stringify(payload), { - headers: { 'Content-Type': 'application/json' } - }); - return response.data; - }, - }, - sports: { - list: async () => { - const response = await http.get('/api/sports'); - return response.data; - }, - create: async (payload: CreateSportInput) => { - const response = await http.post('/api/sports', payload); - return response.data; - }, - update: async (id: string, payload: UpdateSportInput) => { - const response = await http.patch(`/api/sports/${id}`, payload); - return response.data; - }, - }, - courts: { - listByComplex: async (complexId: string) => { - const response = await http.get(`/api/courts/complex/${complexId}`); - return response.data; - }, - create: async (complexId: string, payload: CreateCourtInput) => { - const response = await http.post(`/api/courts/complex/${complexId}`, payload); - return response.data; - }, - update: async (id: string, payload: UpdateCourtInput) => { - const response = await http.patch(`/api/courts/${id}`, payload); - return response.data; - }, - }, + onboarding: api.onboarding, + plans: api.plans, + user: api.user, + complexes: api.complexes, + sports: api.sports, + courts: api.courts, publicBookings: { - getAvailability: async ( - complexSlug: string, - params: { - date: string; - sportId?: string; - } - ) => { - const response = await http.get( - `/api/public-bookings/complex/${complexSlug}/availability`, - { - params, - } - ); - return response.data; - }, - create: async (complexSlug: string, payload: CreatePublicBookingInput) => { - const response = await http.post( - `/api/public-bookings/complex/${complexSlug}`, - payload - ); - return response.data; - }, - getConfirmation: async (complexSlug: string, bookingCode: string) => { - const response = await http.get( - `/api/public-bookings/complex/${complexSlug}/confirmation/${bookingCode}` - ); - return response.data; - }, + getAvailability: api.getAvailability, + create: api.createPublic, + getConfirmation: api.getConfirmation, }, adminBookings: { - listByComplex: async ( - complexId: string, - params: { - fromDate: string; - } - ) => { - const response = await http.get<{ bookings: AdminBooking[] }>( - `/api/admin-bookings/complex/${complexId}`, - { - params, - } - ); - return response.data; - }, - create: async (complexId: string, payload: CreateAdminBookingInput) => { - const response = await http.post( - `/api/admin-bookings/complex/${complexId}`, - payload - ); - return response.data; - }, - updateStatus: async (bookingId: string, payload: UpdateAdminBookingStatusInput) => { - const response = await http.patch( - `/api/admin-bookings/${bookingId}/status`, - payload - ); - return response.data; - }, + listByComplex: api.listByComplex, + create: api.createAdmin, + updateStatus: api.updateStatus, }, }; -export type ApiClient = typeof apiClient; - -export function configureApiClient(nextHandlers: ErrorHandlers) { - handlers.onForbidden = nextHandlers.onForbidden; - handlers.onError = nextHandlers.onError; -} +export type { ErrorHandlers } from './api'; +export { configureApiClient } from './api'; export function setApiAccessToken(_token: string | null) { // accessToken = token; } + +export type ApiClient = typeof apiClient; \ No newline at end of file diff --git a/apps/frontend/src/lib/api/base.ts b/apps/frontend/src/lib/api/base.ts new file mode 100644 index 0000000..34c6776 --- /dev/null +++ b/apps/frontend/src/lib/api/base.ts @@ -0,0 +1,34 @@ +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; + } +} + +export type ErrorHandlers = { + onForbidden?: (error: ApiClientError) => void | Promise; + onError?: (error: ApiClientError) => void | Promise; +}; + +const handlers: ErrorHandlers = {}; + +export const apiBaseUrl = + import.meta.env.VITE_API_BASE_URL?.trim() || + (import.meta.env.DEV ? 'http://localhost:3000' : window.location.origin); + +export function configureApiClient(nextHandlers: ErrorHandlers) { + handlers.onForbidden = nextHandlers.onForbidden; + handlers.onError = nextHandlers.onError; +} + +export { handlers }; \ No newline at end of file diff --git a/apps/frontend/src/lib/api/http.ts b/apps/frontend/src/lib/api/http.ts new file mode 100644 index 0000000..9e0c38f --- /dev/null +++ b/apps/frontend/src/lib/api/http.ts @@ -0,0 +1,60 @@ +import axios, { AxiosError, type AxiosInstance } from 'axios'; +import { ApiClientError, apiBaseUrl, handlers } from './base'; + +export const http: AxiosInstance = axios.create({ + baseURL: apiBaseUrl, + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, +}); + +function extractMessage(details: unknown, fallback: string): 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.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); + } +); \ No newline at end of file diff --git a/apps/frontend/src/lib/api/index.ts b/apps/frontend/src/lib/api/index.ts new file mode 100644 index 0000000..73faa9f --- /dev/null +++ b/apps/frontend/src/lib/api/index.ts @@ -0,0 +1,18 @@ +export { ApiClientError, configureApiClient, apiBaseUrl, type ErrorHandlers } from './base'; +export { http } from './http'; + +export * as complexes from './resources/complexes'; +export * as courts from './resources/courts'; +export * as user from './resources/user'; +export * as sports from './resources/sports'; +export * as plans from './resources/plans'; +export * as onboarding from './resources/onboarding'; + +export { + getAvailability, + createPublic, + getConfirmation, + listByComplex, + createAdmin, + updateStatus, +} from './resources/bookings'; \ No newline at end of file diff --git a/apps/frontend/src/lib/api/resources/bookings.ts b/apps/frontend/src/lib/api/resources/bookings.ts new file mode 100644 index 0000000..538a776 --- /dev/null +++ b/apps/frontend/src/lib/api/resources/bookings.ts @@ -0,0 +1,60 @@ +import type { + AdminBooking, + CreateAdminBookingInput, + CreatePublicBookingInput, + PublicAvailabilityResponse, + PublicBooking, + PublicBookingConfirmation, + UpdateAdminBookingStatusInput, +} from '@repo/api-contract'; +import { http } from '../http'; + +export async function getAvailability( + complexSlug: string, + params: { date: string; sportId?: string } +) { + const response = await http.get( + `/api/public-bookings/complex/${complexSlug}/availability`, + { params } + ); + return response.data; +} + +export async function createPublic(complexSlug: string, payload: CreatePublicBookingInput) { + const response = await http.post( + `/api/public-bookings/complex/${complexSlug}`, + payload + ); + return response.data; +} + +export async function getConfirmation(complexSlug: string, bookingCode: string) { + const response = await http.get( + `/api/public-bookings/complex/${complexSlug}/confirmation/${bookingCode}` + ); + return response.data; +} + +export async function listByComplex(complexId: string, params: { fromDate: string }) { + const response = await http.get<{ bookings: AdminBooking[] }>( + `/api/admin-bookings/complex/${complexId}`, + { params } + ); + return response.data; +} + +export async function createAdmin(complexId: string, payload: CreateAdminBookingInput) { + const response = await http.post( + `/api/admin-bookings/complex/${complexId}`, + payload + ); + return response.data; +} + +export async function updateStatus(bookingId: string, payload: UpdateAdminBookingStatusInput) { + const response = await http.patch( + `/api/admin-bookings/${bookingId}/status`, + 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 new file mode 100644 index 0000000..8b5cbce --- /dev/null +++ b/apps/frontend/src/lib/api/resources/complexes.ts @@ -0,0 +1,49 @@ +import type { + Complex, + ComplexWithRole, + CreateComplexPayload, + CreateComplexResponse, + SelectComplexInput, + UpdateComplexPayload, + UpdateComplexResponse, +} from '@repo/api-contract'; +import { http } from '../http'; + +export async function create(payload: CreateComplexPayload) { + const response = await http.post('/api/complexes', payload); + return response.data; +} + +export async function getById(id: string) { + const response = await http.get(`/api/complexes/${id}`); + return response.data; +} + +export async function getBySlug(slug: string) { + const response = await http.get(`/api/complexes/slug/${slug}`); + return response.data; +} + +export async function update(id: string, payload: UpdateComplexPayload) { + const response = await http.patch(`/api/complexes/${id}`, payload); + return response.data; +} + +export async function listMine() { + const response = await http.get('/api/complexes/mine'); + return response.data; +} + +export async function getCurrent() { + const response = await http.get('/api/complexes/me'); + return response.data; +} + +export async function select(payload: SelectComplexInput) { + const response = await http.post( + '/api/complexes/select', + JSON.stringify(payload), + { 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 new file mode 100644 index 0000000..5c2dab9 --- /dev/null +++ b/apps/frontend/src/lib/api/resources/courts.ts @@ -0,0 +1,17 @@ +import type { Court, CreateCourtInput, UpdateCourtInput } from '@repo/api-contract'; +import { http } from '../http'; + +export async function listByComplex(complexId: string) { + const response = await http.get(`/api/courts/complex/${complexId}`); + return response.data; +} + +export async function create(complexId: string, payload: CreateCourtInput) { + const response = await http.post(`/api/courts/complex/${complexId}`, payload); + return response.data; +} + +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 new file mode 100644 index 0000000..bc8a70a --- /dev/null +++ b/apps/frontend/src/lib/api/resources/onboarding.ts @@ -0,0 +1,40 @@ +import type { + OnboardingCompleteInput, + OnboardingCompleteResponse, + OnboardingResendOtpInput, + OnboardingResendOtpResponse, + OnboardingStartInput, + OnboardingStartResponse, + OnboardingVerifyOtpInput, + OnboardingVerifyOtpResponse, +} from '@repo/api-contract'; +import { http } from '../http'; + +export async function start(payload: OnboardingStartInput) { + const response = await http.post('/api/onboarding/start', payload); + return response.data; +} + +export async function verifyOtp(payload: OnboardingVerifyOtpInput) { + const response = await http.post( + '/api/onboarding/verify-otp', + payload + ); + return response.data; +} + +export async function resendOtp(payload: OnboardingResendOtpInput) { + const response = await http.post( + '/api/onboarding/resend-otp', + payload + ); + return response.data; +} + +export async function complete(payload: OnboardingCompleteInput) { + 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 new file mode 100644 index 0000000..1db8e02 --- /dev/null +++ b/apps/frontend/src/lib/api/resources/plans.ts @@ -0,0 +1,7 @@ +import type { PlanSummary } from '@repo/api-contract'; +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 new file mode 100644 index 0000000..112e56b --- /dev/null +++ b/apps/frontend/src/lib/api/resources/sports.ts @@ -0,0 +1,17 @@ +import type { CreateSportInput, Sport, UpdateSportInput } from '@repo/api-contract'; +import { http } from '../http'; + +export async function list() { + const response = await http.get('/api/sports'); + return response.data; +} + +export async function create(payload: CreateSportInput) { + const response = await http.post('/api/sports', payload); + return response.data; +} + +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 new file mode 100644 index 0000000..654dc11 --- /dev/null +++ b/apps/frontend/src/lib/api/resources/user.ts @@ -0,0 +1,7 @@ +import type { UserProfileResponse } from '@repo/api-contract'; +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