refactor: modularize API client by splitting resources into individual files and standardizing HTTP request handling.

This commit is contained in:
Jose Selesan
2026-04-20 16:05:37 -03:00
parent 28fd51f926
commit 34cb63364c
11 changed files with 327 additions and 267 deletions

View File

@@ -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<void>;
onError?: (error: ApiClientError) => void | Promise<void>;
};
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 };

View File

@@ -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);
}
);

View File

@@ -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';

View File

@@ -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<PublicAvailabilityResponse>(
`/api/public-bookings/complex/${complexSlug}/availability`,
{ params }
);
return response.data;
}
export async function createPublic(complexSlug: string, payload: CreatePublicBookingInput) {
const response = await http.post<PublicBooking>(
`/api/public-bookings/complex/${complexSlug}`,
payload
);
return response.data;
}
export async function getConfirmation(complexSlug: string, bookingCode: string) {
const response = await http.get<PublicBookingConfirmation>(
`/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<AdminBooking>(
`/api/admin-bookings/complex/${complexId}`,
payload
);
return response.data;
}
export async function updateStatus(bookingId: string, payload: UpdateAdminBookingStatusInput) {
const response = await http.patch<AdminBooking>(
`/api/admin-bookings/${bookingId}/status`,
payload
);
return response.data;
}

View File

@@ -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<CreateComplexResponse>('/api/complexes', payload);
return response.data;
}
export async function getById(id: string) {
const response = await http.get<Complex>(`/api/complexes/${id}`);
return response.data;
}
export async function getBySlug(slug: string) {
const response = await http.get<Complex>(`/api/complexes/slug/${slug}`);
return response.data;
}
export async function update(id: string, payload: UpdateComplexPayload) {
const response = await http.patch<UpdateComplexResponse>(`/api/complexes/${id}`, payload);
return response.data;
}
export async function listMine() {
const response = await http.get<ComplexWithRole[]>('/api/complexes/mine');
return response.data;
}
export async function getCurrent() {
const response = await http.get<ComplexWithRole | null>('/api/complexes/me');
return response.data;
}
export async function select(payload: SelectComplexInput) {
const response = await http.post<ComplexWithRole>(
'/api/complexes/select',
JSON.stringify(payload),
{ headers: { 'Content-Type': 'application/json' } }
);
return response.data;
}

View File

@@ -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<Court[]>(`/api/courts/complex/${complexId}`);
return response.data;
}
export async function create(complexId: string, payload: CreateCourtInput) {
const response = await http.post<Court>(`/api/courts/complex/${complexId}`, payload);
return response.data;
}
export async function update(id: string, payload: UpdateCourtInput) {
const response = await http.patch<Court>(`/api/courts/${id}`, payload);
return response.data;
}

View File

@@ -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<OnboardingStartResponse>('/api/onboarding/start', payload);
return response.data;
}
export async function verifyOtp(payload: OnboardingVerifyOtpInput) {
const response = await http.post<OnboardingVerifyOtpResponse>(
'/api/onboarding/verify-otp',
payload
);
return response.data;
}
export async function resendOtp(payload: OnboardingResendOtpInput) {
const response = await http.post<OnboardingResendOtpResponse>(
'/api/onboarding/resend-otp',
payload
);
return response.data;
}
export async function complete(payload: OnboardingCompleteInput) {
const response = await http.post<OnboardingCompleteResponse>(
'/api/onboarding/complete',
payload
);
return response.data;
}

View File

@@ -0,0 +1,7 @@
import type { PlanSummary } from '@repo/api-contract';
import { http } from '../http';
export async function list() {
const response = await http.get<PlanSummary[]>('/api/plans');
return response.data;
}

View File

@@ -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<Sport[]>('/api/sports');
return response.data;
}
export async function create(payload: CreateSportInput) {
const response = await http.post<Sport>('/api/sports', payload);
return response.data;
}
export async function update(id: string, payload: UpdateSportInput) {
const response = await http.patch<Sport>(`/api/sports/${id}`, payload);
return response.data;
}

View File

@@ -0,0 +1,7 @@
import type { UserProfileResponse } from '@repo/api-contract';
import { http } from '../http';
export async function getProfile() {
const response = await http.get<UserProfileResponse>('/api/user/profile');
return response.data;
}