feat: migrate admin API clients to send locale/timezone headers
continuous-integration/drone/push Build is passing Details

Migrated all admin API clients to automatically send X-Locale and X-Timezone headers
with every request for proper internationalization and timezone-aware operations.

Changes:
- Migrated slot-definitions.ts, materialisation.ts, courts.ts, booking-admin.ts to use apiFetch utility
- Added getLocaleHeaders() helper to admin-clubs.ts for SSR compatibility
- Added getSlotDefinitionPresets() endpoint to fetch localized preset metadata
- Updated slot-definitions types to support API-based preset loading
- Refactored GenerateSlotDefinitionsModal to fetch presets from API with localized titles/descriptions

Benefits:
- All API requests now include user's locale (e.g., en-US, fr-CH) for i18n
- Timezone-aware operations use browser's Intl.DateTimeFormat timezone
- Preset titles and descriptions are localized per user language
- Consistent header management across all admin endpoints

Technical Details:
- apiFetch extracts locale from URL pathname via getPathnameLocale()
- apiFetch automatically adds credentials: 'include' for session auth
- admin-clubs.ts uses manual headers for SSR cookie forwarding compatibility
- GenerateSlotDefinitionsModal shows loading state while fetching presets
master
Guillermo Pages 1 month ago
parent 1ebe61fa40
commit 9c802ea2aa

@ -1,16 +1,17 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { X, Loader2, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
import { generateSlotDefinitions } from '@/src/lib/api/slot-definitions';
import { generateSlotDefinitions, getSlotDefinitionPresets } from '@/src/lib/api/slot-definitions';
import type { Court } from '@/src/types/courts';
import type {
SlotDefinitionPreset,
GenerateSlotDefinitionsRequest,
PatternOverrides,
DayOfWeek,
PresetInfo,
} from '@/src/types/slot-definitions';
import { PRESET_OPTIONS, DAY_NAMES } from '@/src/types/slot-definitions';
import { DAY_NAMES } from '@/src/types/slot-definitions';
interface GenerateSlotDefinitionsModalProps {
clubId: number;
@ -25,6 +26,10 @@ export default function GenerateSlotDefinitionsModal({
onClose,
onSuccess,
}: GenerateSlotDefinitionsModalProps) {
// Preset data
const [presets, setPresets] = useState<PresetInfo[]>([]);
const [loadingPresets, setLoadingPresets] = useState(true);
// Form state
const [preset, setPreset] = useState<SlotDefinitionPreset>('workday_standard');
const [selectedCourtIds, setSelectedCourtIds] = useState<number[]>([]);
@ -44,6 +49,21 @@ export default function GenerateSlotDefinitionsModal({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch presets on mount
useEffect(() => {
async function loadPresets() {
const result = await getSlotDefinitionPresets();
if (result.success) {
setPresets(result.data.data.presets);
setLoadingPresets(false);
} else {
setError('Failed to load presets');
setLoadingPresets(false);
}
}
loadPresets();
}, []);
function handleSelectAllCourts() {
if (selectedCourtIds.length === courts.length) {
setSelectedCourtIds([]);
@ -113,7 +133,7 @@ export default function GenerateSlotDefinitionsModal({
setLoading(false);
}
const selectedPreset = PRESET_OPTIONS.find(p => p.id === preset);
const selectedPreset = presets.find((p) => p.key === preset);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
@ -131,30 +151,43 @@ export default function GenerateSlotDefinitionsModal({
{/* Body */}
<div className="p-6 space-y-6">
{/* Loading State */}
{loadingPresets && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 text-slate-900 animate-spin" />
<span className="ml-3 text-slate-600">Loading presets...</span>
</div>
)}
{/* Preset Selection */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Preset Schedule
</label>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as SlotDefinitionPreset)}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900 font-medium"
>
{PRESET_OPTIONS.map(opt => (
<option key={opt.id} value={opt.id}>
{opt.name} ({opt.days}, {opt.hours}, {opt.duration})
</option>
))}
</select>
{selectedPreset && (
<p className="mt-2 text-sm text-slate-600">{selectedPreset.description}</p>
)}
</div>
{!loadingPresets && presets.length > 0 && (
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Preset Schedule
</label>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as SlotDefinitionPreset)}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900 font-medium"
>
{presets.map((p) => (
<option key={p.key} value={p.key}>
{p.title}
</option>
))}
</select>
{selectedPreset && (
<p className="mt-2 text-sm text-slate-600">{selectedPreset.description}</p>
)}
</div>
)}
{/* Court Selection */}
<div>
<div className="flex items-center justify-between mb-2">
{/* Form Elements */}
{!loadingPresets && (
<>
{/* Court Selection */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-semibold text-slate-700">
Select Courts
</label>
@ -323,6 +356,8 @@ export default function GenerateSlotDefinitionsModal({
</div>
)}
</div>
</>
)}
{/* Error Message */}
{error && (
@ -343,7 +378,7 @@ export default function GenerateSlotDefinitionsModal({
</button>
<button
onClick={handleSubmit}
disabled={loading || selectedCourtIds.length === 0}
disabled={loading || loadingPresets || selectedCourtIds.length === 0}
className="px-6 py-2 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
>
{loading ? (

@ -13,6 +13,7 @@ import type {
AdminApiResult,
AdminApiError,
} from '@/src/types/admin-api';
import { getPathnameLocale } from '@/src/utils/getLocale';
// ============================================================================
// Configuration
@ -24,6 +25,26 @@ if (!API_BASE_URL) {
throw new Error('NEXT_PUBLIC_PYTHON_API_URL environment variable is not set');
}
// ============================================================================
// Helpers
// ============================================================================
/**
* Add locale and timezone headers to requests
*/
function getLocaleHeaders(): Record<string, string> {
const locale = typeof window !== 'undefined'
? getPathnameLocale(window.location.pathname) || 'en-US'
: 'en-US';
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return {
'X-Locale': locale,
'X-Timezone': timezone,
};
}
// ============================================================================
// Error Handling
// ============================================================================
@ -69,6 +90,7 @@ export async function getAdminClubs(cookieHeader?: string): Promise<AdminApiResu
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...getLocaleHeaders(),
};
// Forward cookies for server-side rendering
@ -113,6 +135,7 @@ export async function getAdminClubDetail(
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...getLocaleHeaders(),
};
// Forward cookies for server-side rendering

@ -17,12 +17,7 @@ import type {
BookingAdminError,
BookingApiResult,
} from '@/src/types/booking-admin';
const API_BASE_URL = process.env.NEXT_PUBLIC_PYTHON_API_URL;
if (!API_BASE_URL) {
throw new Error('NEXT_PUBLIC_PYTHON_API_URL environment variable is not defined');
}
import apiFetch from '@/src/utils/apiFetch';
// ============================================================================
// Helper Functions
@ -92,11 +87,10 @@ export async function getBookingDetail(
}
const queryString = params.toString();
const url = `${API_BASE_URL}/admin/bookings/${bookingId}${queryString ? `?${queryString}` : ''}`;
const endpoint = `/admin/bookings/${bookingId}${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
const response = await apiFetch(endpoint, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -144,9 +138,8 @@ export async function cancelBooking(
headers['X-Idempotency-Key'] = options.idempotencyKey;
}
const response = await fetch(`${API_BASE_URL}/admin/bookings/${bookingId}`, {
const response = await apiFetch(`/admin/bookings/${bookingId}`, {
method: 'PATCH',
credentials: 'include',
headers,
body: JSON.stringify(request),
});
@ -194,9 +187,8 @@ export async function moveBooking(
headers['X-Idempotency-Key'] = options.idempotencyKey;
}
const response = await fetch(`${API_BASE_URL}/admin/bookings/${bookingId}/move`, {
const response = await apiFetch(`/admin/bookings/${bookingId}/move`, {
method: 'PATCH',
credentials: 'include',
headers,
body: JSON.stringify(request),
});
@ -243,9 +235,8 @@ export async function updateAttendees(
headers['X-Idempotency-Key'] = options.idempotencyKey;
}
const response = await fetch(`${API_BASE_URL}/admin/bookings/${bookingId}/attendees`, {
const response = await apiFetch(`/admin/bookings/${bookingId}/attendees`, {
method: 'PATCH',
credentials: 'include',
headers,
body: JSON.stringify(request),
});

@ -2,7 +2,6 @@
* Court Management API Client
*
* Handles club profile and court inventory CRUD operations.
* Uses mock data until backend endpoints are ready.
*/
import type {
@ -14,12 +13,7 @@ import type {
CourtError,
SportsResponse,
} from '@/src/types/courts';
const API_BASE_URL = process.env.NEXT_PUBLIC_PYTHON_API_URL;
if (!API_BASE_URL) {
throw new Error('NEXT_PUBLIC_PYTHON_API_URL environment variable is not defined');
}
import apiFetch from '@/src/utils/apiFetch';
type ApiResult<T> =
| { success: true; data: T }
@ -30,9 +24,8 @@ type ApiResult<T> =
*/
export async function getClubProfile(clubId: number): Promise<ApiResult<ClubProfile>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}`, {
const response = await apiFetch(`/admin/clubs/${clubId}`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -70,9 +63,8 @@ export async function updateClubProfile(
request: ClubProfileUpdateRequest
): Promise<ApiResult<ClubProfile>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}`, {
const response = await apiFetch(`/admin/clubs/${clubId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -105,9 +97,8 @@ export async function updateClubProfile(
*/
export async function getCourts(clubId: number): Promise<ApiResult<Court[]>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts`, {
const response = await apiFetch(`/admin/clubs/${clubId}/courts`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -142,9 +133,8 @@ export async function createCourt(
request: CourtRequest
): Promise<ApiResult<Court>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts`, {
const response = await apiFetch(`/admin/clubs/${clubId}/courts`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -181,9 +171,8 @@ export async function updateCourt(
request: CourtRequest
): Promise<ApiResult<Court>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts/${courtId}`, {
const response = await apiFetch(`/admin/clubs/${clubId}/courts/${courtId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -219,9 +208,8 @@ export async function getCourtDependencies(
courtId: number
): Promise<ApiResult<CourtDependencies>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts/${courtId}/dependencies`, {
const response = await apiFetch(`/admin/clubs/${clubId}/courts/${courtId}/dependencies`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -256,9 +244,8 @@ export async function deleteCourt(
courtId: number
): Promise<ApiResult<void>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/courts/${courtId}`, {
const response = await apiFetch(`/admin/clubs/${clubId}/courts/${courtId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -289,9 +276,8 @@ export async function deleteCourt(
*/
export async function getSports(): Promise<ApiResult<SportsResponse>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/sports`, {
const response = await apiFetch(`/admin/sports`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},

@ -2,7 +2,6 @@
* Materialisation API Client
*
* Handles slot materialisation status polling and manual trigger requests.
* Uses mock data until backend endpoints are ready.
*/
import type {
@ -11,12 +10,7 @@ import type {
MaterialisationTriggerResponse,
MaterialisationError,
} from '@/src/types/materialisation';
const API_BASE_URL = process.env.NEXT_PUBLIC_PYTHON_API_URL;
if (!API_BASE_URL) {
throw new Error('NEXT_PUBLIC_PYTHON_API_URL environment variable is not defined');
}
import apiFetch from '@/src/utils/apiFetch';
type ApiResult<T> =
| { success: true; data: T }
@ -29,9 +23,8 @@ export async function getMaterialisationStatus(
clubId: number
): Promise<ApiResult<MaterialisationStatus>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/materialisation-status`, {
const response = await apiFetch(`/admin/clubs/${clubId}/materialisation-status`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -66,9 +59,8 @@ export async function triggerMaterialisation(
request: MaterialisationTriggerRequest
): Promise<ApiResult<MaterialisationTriggerResponse>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/slot-materialize`, {
const response = await apiFetch(`/admin/clubs/${clubId}/slot-materialize`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},

@ -6,13 +6,9 @@ import type {
GenerateSlotDefinitionsResponse,
CloneSlotDefinitionRequest,
CloneSlotDefinitionResponse,
GetPresetsResponse,
} from '@/src/types/slot-definitions';
const API_BASE_URL = process.env.NEXT_PUBLIC_PYTHON_API_URL;
if (!API_BASE_URL) {
throw new Error('NEXT_PUBLIC_PYTHON_API_URL environment variable is not defined');
}
import apiFetch from '@/src/utils/apiFetch';
type ApiResult<T> =
| { success: true; data: T }
@ -29,11 +25,10 @@ export async function getSlotDefinitions(
if (filters?.active_on) params.append('active_on', filters.active_on);
const queryString = params.toString();
const url = `${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions${queryString ? `?${queryString}` : ''}`;
const endpoint = `/admin/clubs/${clubId}/slot-definitions${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
const response = await apiFetch(endpoint, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -66,9 +61,8 @@ export async function createSlotDefinition(
request: SlotDefinitionRequest
): Promise<ApiResult<SlotDefinition>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions`, {
const response = await apiFetch(`/admin/clubs/${clubId}/slot-definitions`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -103,11 +97,10 @@ export async function updateSlotDefinition(
request: Partial<SlotDefinitionRequest>
): Promise<ApiResult<SlotDefinition>> {
try {
const response = await fetch(
`${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions/${slotDefinitionId}`,
const response = await apiFetch(
`/admin/clubs/${clubId}/slot-definitions/${slotDefinitionId}`,
{
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -142,11 +135,10 @@ export async function deleteSlotDefinition(
slotDefinitionId: number
): Promise<ApiResult<void>> {
try {
const response = await fetch(
`${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions/${slotDefinitionId}`,
const response = await apiFetch(
`/admin/clubs/${clubId}/slot-definitions/${slotDefinitionId}`,
{
method: 'DELETE',
credentials: 'include',
}
);
@ -170,15 +162,42 @@ export async function deleteSlotDefinition(
}
}
// GET /admin/slot-definition-presets
export async function getSlotDefinitionPresets(): Promise<ApiResult<GetPresetsResponse>> {
try {
const response = await apiFetch('/admin/slot-definition-presets', {
method: 'GET',
});
if (!response.ok) {
const error: SlotDefinitionError = await response.json();
return { success: false, error };
}
const data: GetPresetsResponse = await response.json();
return { success: true, data };
} catch (error) {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: 'Failed to fetch slot definition presets. Please check your connection.',
code: 'network_error',
},
};
}
}
// POST /admin/clubs/{club_id}/slot-definitions/generate
export async function generateSlotDefinitions(
clubId: number,
request: GenerateSlotDefinitionsRequest
): Promise<ApiResult<GenerateSlotDefinitionsResponse>> {
try {
const response = await fetch(`${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions/generate`, {
const response = await apiFetch(`/admin/clubs/${clubId}/slot-definitions/generate`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@ -213,11 +232,10 @@ export async function cloneSlotDefinition(
request: CloneSlotDefinitionRequest
): Promise<ApiResult<CloneSlotDefinitionResponse>> {
try {
const response = await fetch(
`${API_BASE_URL}/admin/clubs/${clubId}/slot-definitions/${slotDefinitionId}/clone`,
const response = await apiFetch(
`/admin/clubs/${clubId}/slot-definitions/${slotDefinitionId}/clone`,
{
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},

@ -99,51 +99,37 @@ export type SlotDefinitionPreset =
| 'hourly_daytime'; // Mon-Fri, 9am-6pm, 60min
/**
* Preset metadata for UI display
* Pattern details from preset
*/
export interface PresetPattern {
days: DayOfWeek[];
start_time: string; // HH:MM:SS
end_time: string; // HH:MM:SS
duration_minutes: number;
interval_minutes: number;
capacity: number;
}
/**
* Preset info from API (localized)
*/
export interface PresetInfo {
id: SlotDefinitionPreset;
name: string;
description: string;
days: string;
hours: string;
duration: string;
}
export const PRESET_OPTIONS: PresetInfo[] = [
{
id: 'workday_standard',
name: 'Weekday Standard',
description: 'Standard weekday operations',
days: 'Mon-Fri',
hours: '8am-10pm',
duration: '90min slots',
},
{
id: 'weekend_extended',
name: 'Weekend Extended',
description: 'Weekend with early/late slots',
days: 'Sat-Sun',
hours: '7am-11pm',
duration: '90min slots',
},
{
id: 'all_week_uniform',
name: 'All Week Uniform',
description: 'Uniform schedule year-round',
days: 'Every day',
hours: '8am-10pm',
duration: '90min slots',
},
{
id: 'hourly_daytime',
name: 'Hourly Daytime',
description: 'Short slots for busy periods',
days: 'Mon-Fri',
hours: '9am-6pm',
duration: '60min slots',
},
];
key: SlotDefinitionPreset;
title: string; // Localized title
description: string; // Localized description
pattern: PresetPattern;
}
/**
* Get presets endpoint response
*/
export interface GetPresetsResponse {
status: 'success';
data: {
locale: string;
presets: PresetInfo[];
};
}
/**
* Pattern overrides for customizing presets

Loading…
Cancel
Save