refactor: add TanStack Query and centralized abstractions
continuous-integration/drone/push Build is passing Details

- Install @tanstack/react-query and devtools
- Add QueryProvider to app providers
- Create query hooks: useBookings, useClubs, useClubMembers, useClubPlans, useClubCredits, useClubPolicy
- Create mutation hooks: useMemberMutations, usePlanMutations
- Add ModalFormWrapper component for consistent form modals
- Centralize skeleton configuration (sizes, shapes) in skeletonConfig.ts
- Add error handling utilities (getApiErrorMessage, isApiError)
- Add size configuration utilities for consistent component sizing
- Update AvatarSkeleton, IconSkeleton, PlayerItemSkeleton to use centralized config
master
Guillermo Pages 2 weeks ago
parent 256eccc997
commit f39e3542ed

55
package-lock.json generated

@ -15,6 +15,8 @@
"@heroicons/react": "^2.2.0",
"@react-email/components": "^0.0.41",
"@tailwindcss/postcss": "^4.1.7",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.1",
"@types/jsonwebtoken": "^9.0.10",
"date-fns": "^4.1.0",
"di-why": "^0.16.0",
@ -1665,6 +1667,59 @@
"tailwindcss": "4.1.11"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
"integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.91.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz",
"integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.91.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz",
"integrity": "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.91.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.90.10",
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",

@ -17,6 +17,8 @@
"@heroicons/react": "^2.2.0",
"@react-email/components": "^0.0.41",
"@tailwindcss/postcss": "^4.1.7",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.1",
"@types/jsonwebtoken": "^9.0.10",
"date-fns": "^4.1.0",
"di-why": "^0.16.0",

@ -10,6 +10,7 @@ import ModalRenderer from "@/src/components/ModalProvider";
import { TranslationsProvider } from "../../contexts/TranslationsContext";
import { SlotsCacheProvider } from "@/src/contexts/SlotsCacheContext";
import { WebSocketProvider } from "@/src/contexts/WebSocketContext";
import QueryProvider from "@/src/components/QueryProvider";
import { TranslationsDict } from "./dictionaries";
import { Locale } from "../../../i18n-config";
@ -30,21 +31,23 @@ export default function Providers({
const backendUrl = process.env.NEXT_PUBLIC_AUTH_BACKEND_URL;
return (
<TranslationsProvider translations={translations} locale={locale}>
<SwissOIDAuthProvider backendUrl={backendUrl}>
<UserSettingsProvider>
<WebSocketProvider>
<SlotsCacheProvider>
<ModalProvider>
{children}
<SessionMonitor />
<LocaleMismatchModal />
<ModalRenderer />
</ModalProvider>
</SlotsCacheProvider>
</WebSocketProvider>
</UserSettingsProvider>
</SwissOIDAuthProvider>
</TranslationsProvider>
<QueryProvider>
<TranslationsProvider translations={translations} locale={locale}>
<SwissOIDAuthProvider backendUrl={backendUrl}>
<UserSettingsProvider>
<WebSocketProvider>
<SlotsCacheProvider>
<ModalProvider>
{children}
<SessionMonitor />
<LocaleMismatchModal />
<ModalRenderer />
</ModalProvider>
</SlotsCacheProvider>
</WebSocketProvider>
</UserSettingsProvider>
</SwissOIDAuthProvider>
</TranslationsProvider>
</QueryProvider>
);
}

@ -0,0 +1,21 @@
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { getQueryClient } from '@/src/lib/queryClient';
import { ReactNode } from 'react';
interface QueryProviderProps {
children: ReactNode;
}
export default function QueryProvider({ children }: QueryProviderProps) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}

@ -0,0 +1,95 @@
'use client';
import React from 'react';
import Modal from './Modal';
import ModalHeader from './ModalHeader';
import ModalBody from './ModalBody';
import ModalFooter from './ModalFooter';
interface ModalFormWrapperProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (e: React.FormEvent) => void;
title: string;
subtitle?: string;
isPending: boolean;
error?: string | null;
submitText?: string;
submitTextPending?: string;
cancelText?: string;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
children: React.ReactNode;
}
/**
* A wrapper component for modal forms that handles the common structure:
* - Modal with header (title + close button)
* - Error display
* - Body (form fields via children)
* - Footer with Cancel and Submit buttons
*
* Usage:
* ```tsx
* <ModalFormWrapper
* isOpen={isOpen}
* onClose={onClose}
* onSubmit={handleSubmit}
* title="Add Member"
* isPending={mutation.isPending}
* error={mutation.error?.message}
* submitText="Add Member"
* submitTextPending="Adding..."
* >
* {form fields}
* </ModalFormWrapper>
* ```
*/
export default function ModalFormWrapper({
isOpen,
onClose,
onSubmit,
title,
subtitle,
isPending,
error,
submitText = 'Save',
submitTextPending = 'Saving...',
cancelText = 'Cancel',
size = 'md',
children,
}: ModalFormWrapperProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} size={size}>
<form onSubmit={onSubmit}>
<ModalHeader title={title} subtitle={subtitle} onClose={onClose} />
<ModalBody>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
{error}
</div>
)}
{children}
</ModalBody>
<ModalFooter>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium transition-colors"
disabled={isPending}
>
{cancelText}
</button>
<button
type="submit"
disabled={isPending}
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed text-white px-6 py-2 rounded-lg font-semibold shadow-md transition-all duration-200"
>
{isPending ? submitTextPending : submitText}
</button>
</ModalFooter>
</form>
</Modal>
);
}

@ -1,43 +1,31 @@
import {
AVATAR_SIZES,
AVATAR_SIZES_MD,
SKELETON_SHAPES,
SKELETON_BASE,
SKELETON_BG_DARK,
type AvatarSize,
type SkeletonShape,
} from './skeletonConfig';
interface AvatarSkeletonProps {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
shape?: 'circle' | 'square' | 'rounded';
size?: AvatarSize;
shape?: Exclude<SkeletonShape, 'rounded-lg'>;
hasBorder?: boolean;
className?: string;
}
const sizeClasses = {
xs: 'w-6 h-6',
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12',
xl: 'w-16 h-16'
};
const sizeClassesMd = {
xs: 'md:w-8 md:h-8',
sm: 'md:w-10 md:h-10',
md: 'md:w-12 md:h-12',
lg: 'md:w-14 md:h-14',
xl: 'md:w-20 md:h-20'
};
const shapeClasses = {
circle: 'rounded-full',
square: 'rounded-none',
rounded: 'rounded-xl'
};
export default function AvatarSkeleton({
size = 'md',
export default function AvatarSkeleton({
size = 'md',
shape = 'circle',
hasBorder = false,
className = ''
className = ''
}: AvatarSkeletonProps) {
const borderClass = hasBorder ? 'border-2 border-white/80 shadow-lg' : '';
return (
<div
className={`bg-slate-300 animate-pulse ${sizeClasses[size]} ${sizeClassesMd[size]} ${shapeClasses[shape]} ${borderClass} ${className}`}
<div
className={`${SKELETON_BG_DARK} ${SKELETON_BASE} ${AVATAR_SIZES[size]} ${AVATAR_SIZES_MD[size]} ${SKELETON_SHAPES[shape]} ${borderClass} ${className}`}
/>
);
}

@ -1,30 +1,26 @@
import {
ICON_SIZES,
SKELETON_SHAPES,
SKELETON_BASE,
SKELETON_BG,
type IconSize,
type SkeletonShape,
} from './skeletonConfig';
interface IconSkeletonProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
shape?: 'circle' | 'square' | 'rounded';
size?: IconSize;
shape?: SkeletonShape;
className?: string;
}
const sizeClasses = {
sm: 'w-8 h-8',
md: 'w-12 h-12',
lg: 'w-16 h-16',
xl: 'w-20 h-20'
};
const shapeClasses = {
circle: 'rounded-full',
square: 'rounded-none',
rounded: 'rounded-lg'
};
export default function IconSkeleton({
size = 'md',
shape = 'rounded',
className = ''
export default function IconSkeleton({
size = 'md',
shape = 'rounded-lg',
className = ''
}: IconSkeletonProps) {
return (
<div
className={`bg-slate-200 animate-pulse ${sizeClasses[size]} ${shapeClasses[shape]} ${className}`}
<div
className={`${SKELETON_BG} ${SKELETON_BASE} ${ICON_SIZES[size]} ${SKELETON_SHAPES[shape]} ${className}`}
/>
);
}

@ -1,8 +1,13 @@
import React from 'react';
import {
PLAYER_ITEM_SIZE_CONFIG,
SKELETON_BASE,
SKELETON_BG,
type PlayerItemSize,
} from './skeletonConfig';
interface PlayerItemSkeletonProps {
variant?: 'default' | 'compact' | 'booking';
size?: 'sm' | 'md' | 'lg';
size?: PlayerItemSize;
showSecondaryData?: boolean;
showActions?: boolean;
hasExpandableContent?: boolean;
@ -17,37 +22,7 @@ export default function PlayerItemSkeleton({
hasExpandableContent = false,
className = ''
}: PlayerItemSkeletonProps) {
const sizeConfig = {
sm: {
container: 'p-2',
pill: 'w-8 h-8',
nameWidth: 'w-24',
nameHeight: 'h-3',
levelWidth: 'w-12',
levelHeight: 'h-3',
spacing: 'space-x-2'
},
md: {
container: 'p-3',
pill: 'w-10 h-10',
nameWidth: 'w-32',
nameHeight: 'h-4',
levelWidth: 'w-16',
levelHeight: 'h-4',
spacing: 'space-x-3'
},
lg: {
container: 'p-4',
pill: 'w-12 h-12',
nameWidth: 'w-36',
nameHeight: 'h-5',
levelWidth: 'w-20',
levelHeight: 'h-5',
spacing: 'space-x-4'
}
};
const config = sizeConfig[size];
const config = PLAYER_ITEM_SIZE_CONFIG[size];
const getContainerStyles = () => {
let base = `flex items-center ${config.spacing} ${config.container} rounded-xl`;
@ -70,32 +45,32 @@ export default function PlayerItemSkeleton({
<div className="w-full">
<div className={getContainerStyles()}>
{/* Pill skeleton */}
<div className={`${config.pill} rounded-full bg-slate-200 animate-pulse shadow-lg`} />
<div className={`${config.pill} rounded-full ${SKELETON_BG} ${SKELETON_BASE} shadow-lg`} />
{/* Name and level skeleton */}
<div className="flex-1">
<div className={`${config.nameHeight} bg-slate-200 rounded ${config.nameWidth} mb-2 animate-pulse`} />
<div className={`${config.nameHeight} ${SKELETON_BG} rounded ${config.nameWidth} mb-2 ${SKELETON_BASE}`} />
<div className="flex gap-2">
<div className={`${config.levelHeight} bg-slate-200 rounded-full ${config.levelWidth} animate-pulse`} />
<div className={`${config.levelHeight} bg-slate-200 rounded-full ${config.levelWidth} animate-pulse`} />
<div className={`${config.levelHeight} ${SKELETON_BG} rounded-full ${config.levelWidth} ${SKELETON_BASE}`} />
<div className={`${config.levelHeight} ${SKELETON_BG} rounded-full ${config.levelWidth} ${SKELETON_BASE}`} />
</div>
</div>
{/* Secondary data skeleton */}
{showSecondaryData && (
<div className="flex gap-2">
<div className="w-16 h-4 bg-slate-200 rounded animate-pulse" />
<div className={`w-16 h-4 ${SKELETON_BG} rounded ${SKELETON_BASE}`} />
</div>
)}
{/* Actions skeleton */}
{showActions && (
<div className="w-8 h-8 rounded-full bg-slate-200 animate-pulse" />
<div className={`w-8 h-8 rounded-full ${SKELETON_BG} ${SKELETON_BASE}`} />
)}
{/* Expand arrow skeleton */}
{hasExpandableContent && (
<div className="w-8 h-8 rounded-full bg-slate-200 animate-pulse" />
<div className={`w-8 h-8 rounded-full ${SKELETON_BG} ${SKELETON_BASE}`} />
)}
</div>
</div>

@ -0,0 +1,80 @@
/**
* Centralized skeleton configuration for consistent sizing and styling across skeleton components.
*/
// Avatar sizes - used for avatar/profile picture skeletons
export const AVATAR_SIZES = {
xs: 'w-6 h-6',
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12',
xl: 'w-16 h-16',
} as const;
// Avatar sizes for md breakpoint (responsive)
export const AVATAR_SIZES_MD = {
xs: 'md:w-8 md:h-8',
sm: 'md:w-10 md:h-10',
md: 'md:w-12 md:h-12',
lg: 'md:w-14 md:h-14',
xl: 'md:w-20 md:h-20',
} as const;
// Icon sizes - used for icon skeletons (generally larger than avatars)
export const ICON_SIZES = {
sm: 'w-8 h-8',
md: 'w-12 h-12',
lg: 'w-16 h-16',
xl: 'w-20 h-20',
} as const;
// Shape classes - used for border radius
export const SKELETON_SHAPES = {
circle: 'rounded-full',
square: 'rounded-none',
rounded: 'rounded-xl',
'rounded-lg': 'rounded-lg',
} as const;
// Base skeleton classes
export const SKELETON_BASE = 'animate-pulse';
export const SKELETON_BG = 'bg-slate-200';
export const SKELETON_BG_DARK = 'bg-slate-300';
// Type exports for component props
export type AvatarSize = keyof typeof AVATAR_SIZES;
export type IconSize = keyof typeof ICON_SIZES;
export type SkeletonShape = keyof typeof SKELETON_SHAPES;
// Player item size configurations
export const PLAYER_ITEM_SIZE_CONFIG = {
sm: {
container: 'p-2',
pill: 'w-8 h-8',
nameWidth: 'w-24',
nameHeight: 'h-3',
levelWidth: 'w-12',
levelHeight: 'h-3',
spacing: 'space-x-2',
},
md: {
container: 'p-3',
pill: 'w-10 h-10',
nameWidth: 'w-32',
nameHeight: 'h-4',
levelWidth: 'w-16',
levelHeight: 'h-4',
spacing: 'space-x-3',
},
lg: {
container: 'p-4',
pill: 'w-12 h-12',
nameWidth: 'w-36',
nameHeight: 'h-5',
levelWidth: 'w-20',
levelHeight: 'h-5',
spacing: 'space-x-4',
},
} as const;
export type PlayerItemSize = keyof typeof PLAYER_ITEM_SIZE_CONFIG;

@ -0,0 +1,95 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
addMember,
updateMember,
deleteMember,
} from '@/src/lib/api/facility-admin';
import { clubQueryKeys } from '@/src/hooks/queries/useClubQueries';
import type { AddMemberRequest, UpdateMemberRequest, FacilityMember } from '@/src/types/facility-admin';
// ============================================================================
// Member Mutations
// ============================================================================
/**
* Add a new member to a facility
*/
export function useAddMember(facilityId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: AddMemberRequest) => {
const result = await addMember(facilityId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
// Invalidate member list to refetch
queryClient.invalidateQueries({ queryKey: clubQueryKeys.members(facilityId) });
},
});
}
/**
* Update an existing member
*/
export function useUpdateMember(facilityId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
memberId,
data,
}: {
memberId: number;
data: UpdateMemberRequest;
}) => {
const result = await updateMember(facilityId, memberId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
// Invalidate member list to refetch
queryClient.invalidateQueries({ queryKey: clubQueryKeys.members(facilityId) });
},
});
}
/**
* Delete a member (soft delete by default)
*/
export function useDeleteMember(facilityId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
memberId,
hardDelete = false,
}: {
memberId: number;
hardDelete?: boolean;
}) => {
const result = await deleteMember(facilityId, memberId, hardDelete);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
// Invalidate member list to refetch
queryClient.invalidateQueries({ queryKey: clubQueryKeys.members(facilityId) });
},
});
}
// ============================================================================
// Types for mutation results
// ============================================================================
export type AddMemberMutation = ReturnType<typeof useAddMember>;
export type UpdateMemberMutation = ReturnType<typeof useUpdateMember>;
export type DeleteMemberMutation = ReturnType<typeof useDeleteMember>;

@ -0,0 +1,128 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
createPlan,
updatePlan,
deletePlan,
updateEntitlements,
} from '@/src/lib/api/facility-admin';
import { clubQueryKeys } from '@/src/hooks/queries/useClubQueries';
import type {
CreatePlanRequest,
UpdatePlanRequest,
PlanEntitlements,
} from '@/src/types/facility-admin';
// ============================================================================
// Plan Mutations
// ============================================================================
/**
* Create a new membership plan
*/
export function useCreatePlan(facilityId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreatePlanRequest) => {
const result = await createPlan(facilityId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
// Invalidate plans list to refetch
queryClient.invalidateQueries({ queryKey: clubQueryKeys.plans(facilityId) });
},
});
}
/**
* Update an existing plan
*/
export function useUpdatePlan(facilityId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
planId,
data,
}: {
planId: number;
data: UpdatePlanRequest;
}) => {
const result = await updatePlan(facilityId, planId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
// Invalidate plans list to refetch
queryClient.invalidateQueries({ queryKey: clubQueryKeys.plans(facilityId) });
},
});
}
/**
* Delete a plan (soft delete by default)
*/
export function useDeletePlan(facilityId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
planId,
hardDelete = false,
}: {
planId: number;
hardDelete?: boolean;
}) => {
const result = await deletePlan(facilityId, planId, hardDelete);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
// Invalidate plans list to refetch
queryClient.invalidateQueries({ queryKey: clubQueryKeys.plans(facilityId) });
},
});
}
/**
* Update plan entitlements (bulk replace)
*/
export function useUpdatePlanEntitlements(facilityId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
planId,
entitlements,
}: {
planId: number;
entitlements: PlanEntitlements;
}) => {
const result = await updateEntitlements(facilityId, planId, entitlements);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
// Invalidate plans list to refetch (entitlements might affect plan display)
queryClient.invalidateQueries({ queryKey: clubQueryKeys.plans(facilityId) });
},
});
}
// ============================================================================
// Types for mutation results
// ============================================================================
export type CreatePlanMutation = ReturnType<typeof useCreatePlan>;
export type UpdatePlanMutation = ReturnType<typeof useUpdatePlan>;
export type DeletePlanMutation = ReturnType<typeof useDeletePlan>;
export type UpdatePlanEntitlementsMutation = ReturnType<typeof useUpdatePlanEntitlements>;

@ -0,0 +1,114 @@
import { useQuery } from '@tanstack/react-query';
import apiFetch from '@/src/utils/apiFetch';
import { Locale } from '@/i18n-config';
import { Player, SlotStatus, ScoreSubmission } from '@/src/lib/types';
export interface Outcome {
status: string;
winning_team: number;
}
export interface UserBooking {
slot_id: number;
court: string;
start: string;
end: string;
duration: number;
status: SlotStatus;
players: Player[];
booked_by: number;
booked_by_app_user?: boolean;
origin_slug: string;
origin_name: string;
sport_slug: string;
sport_name: string;
src_timezone: string;
score_submissions?: ScoreSubmission[];
outcome?: Outcome;
}
interface RemoteSport {
sport_slug: string;
sport_name: string;
slots: Omit<UserBooking, 'origin_slug' | 'origin_name' | 'sport_slug' | 'sport_name' | 'src_timezone'>[];
}
interface RemoteMember {
remote_sports: RemoteSport[];
}
interface Remote {
origin_slug: string;
origin_name: string;
src_timezone: string;
remote_members: RemoteMember[];
}
interface UserBookingsResponse {
remotes: Remote[];
}
async function fetchUserBookings(): Promise<UserBooking[]> {
const res = await apiFetch('/user/bookings');
const data: UserBookingsResponse = await res.json().catch(() => ({ remotes: [] }));
if (!res.ok) {
throw new Error((data as unknown as { message?: string }).message || 'Failed to load bookings');
}
const slots: UserBooking[] = [];
for (const remote of data.remotes || []) {
for (const member of remote.remote_members || []) {
for (const sport of member.remote_sports || []) {
for (const slot of sport.slots || []) {
slots.push({
...slot,
origin_slug: remote.origin_slug,
origin_name: remote.origin_name,
sport_slug: sport.sport_slug,
sport_name: sport.sport_name,
src_timezone: remote.src_timezone,
});
}
}
}
}
return slots;
}
function sortBookings(bookings: UserBooking[], sortOrder: 'asc' | 'desc'): UserBooking[] {
return [...bookings].sort((a, b) => {
const diff = new Date(a.start).getTime() - new Date(b.start).getTime();
return sortOrder === 'asc' ? diff : -diff;
});
}
/**
* Unified hook for fetching user bookings with TanStack Query.
* Supports both ascending (upcoming) and descending (past) sort orders.
*/
export function useBookings(locale: Locale, sortOrder: 'asc' | 'desc' = 'asc') {
return useQuery({
queryKey: ['bookings', 'user', locale],
queryFn: fetchUserBookings,
select: (data) => sortBookings(data, sortOrder),
});
}
/**
* Convenience hook for fetching upcoming bookings (ascending by date).
*/
export function useUpcomingBookings(locale: Locale) {
return useBookings(locale, 'asc');
}
/**
* Convenience hook for fetching past bookings (descending by date).
*/
export function usePastBookings(locale: Locale) {
return useBookings(locale, 'desc');
}
// Re-export types for backward compatibility
export type { UserBooking as UserBookingType };

@ -0,0 +1,155 @@
import { useQuery } from '@tanstack/react-query';
import { getAdminClubs, getAdminClubDetail } from '@/src/lib/api/admin-clubs';
import {
listMembers,
listPlans,
listMemberCredits,
getPolicy,
listPlanTemplates,
} from '@/src/lib/api/facility-admin';
import type { MemberListFilters } from '@/src/types/facility-admin';
// ============================================================================
// Query Keys
// ============================================================================
export const clubQueryKeys = {
all: ['clubs'] as const,
lists: () => [...clubQueryKeys.all, 'list'] as const,
list: () => [...clubQueryKeys.lists()] as const,
details: () => [...clubQueryKeys.all, 'detail'] as const,
detail: (clubId: number) => [...clubQueryKeys.details(), clubId] as const,
members: (clubId: number) => [...clubQueryKeys.detail(clubId), 'members'] as const,
membersFiltered: (clubId: number, filters?: MemberListFilters) =>
[...clubQueryKeys.members(clubId), filters] as const,
plans: (clubId: number) => [...clubQueryKeys.detail(clubId), 'plans'] as const,
plansFiltered: (clubId: number, filters?: { sport_id?: number; include_inactive?: boolean }) =>
[...clubQueryKeys.plans(clubId), filters] as const,
credits: (clubId: number) => [...clubQueryKeys.detail(clubId), 'credits'] as const,
policy: (clubId: number) => [...clubQueryKeys.detail(clubId), 'policy'] as const,
planTemplates: (filters?: { sport_id?: number }) => ['planTemplates', filters] as const,
};
// ============================================================================
// Query Hooks
// ============================================================================
/**
* Fetch list of clubs the user can manage
*/
export function useClubs() {
return useQuery({
queryKey: clubQueryKeys.list(),
queryFn: async () => {
const result = await getAdminClubs();
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
});
}
/**
* Fetch detailed information about a specific club
*/
export function useClubDetail(clubId: number) {
return useQuery({
queryKey: clubQueryKeys.detail(clubId),
queryFn: async () => {
const result = await getAdminClubDetail(clubId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: clubId > 0,
});
}
/**
* Fetch members for a club
*/
export function useClubMembers(clubId: number, filters?: MemberListFilters) {
return useQuery({
queryKey: clubQueryKeys.membersFiltered(clubId, filters),
queryFn: async () => {
const result = await listMembers(clubId, filters);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: clubId > 0,
});
}
/**
* Fetch membership plans for a club
*/
export function useClubPlans(
clubId: number,
filters?: { sport_id?: number; include_inactive?: boolean }
) {
return useQuery({
queryKey: clubQueryKeys.plansFiltered(clubId, filters),
queryFn: async () => {
const result = await listPlans(clubId, filters);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: clubId > 0,
});
}
/**
* Fetch credit balances for a club
*/
export function useClubCredits(clubId: number, minBalance: number = 1) {
return useQuery({
queryKey: [...clubQueryKeys.credits(clubId), minBalance],
queryFn: async () => {
const result = await listMemberCredits(clubId, minBalance);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: clubId > 0,
});
}
/**
* Fetch policy for a club
*/
export function useClubPolicy(clubId: number) {
return useQuery({
queryKey: clubQueryKeys.policy(clubId),
queryFn: async () => {
const result = await getPolicy(clubId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: clubId > 0,
});
}
/**
* Fetch available plan templates
*/
export function usePlanTemplates(filters?: { sport_id?: number }) {
return useQuery({
queryKey: clubQueryKeys.planTemplates(filters),
queryFn: async () => {
const result = await listPlanTemplates(filters);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
});
}

@ -0,0 +1,32 @@
import { QueryClient } from '@tanstack/react-query';
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// 5 minutes - matches existing SlotsCacheContext TTL
staleTime: 5 * 60 * 1000,
// Only retry once on failure
retry: 1,
// Don't refetch on window focus in development
refetchOnWindowFocus: process.env.NODE_ENV === 'production',
},
},
});
}
// For server-side rendering, we need to create a new client for each request
// For client-side, we can reuse the same client
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return makeQueryClient();
}
// Browser: reuse the same query client
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}

@ -0,0 +1,80 @@
/**
* Error handling utilities for consistent error message extraction and display.
*/
/**
* Extract a user-friendly error message from various error types.
* Works with API errors, Error objects, and unknown error types.
*
* @param error - The error to extract a message from
* @param fallback - Fallback message if error message cannot be extracted
* @returns A user-friendly error message string
*/
export function getApiErrorMessage(error: unknown, fallback: string = 'An error occurred'): string {
// Handle null/undefined
if (error == null) {
return fallback;
}
// Handle Error objects (including TanStack Query errors)
if (error instanceof Error) {
return error.message || fallback;
}
// Handle API error objects with 'detail' field (RFC 7807 format)
if (typeof error === 'object' && 'detail' in error) {
const detail = (error as { detail: unknown }).detail;
if (typeof detail === 'string' && detail) {
return detail;
}
}
// Handle API error objects with 'message' field
if (typeof error === 'object' && 'message' in error) {
const message = (error as { message: unknown }).message;
if (typeof message === 'string' && message) {
return message;
}
}
// Handle string errors
if (typeof error === 'string' && error) {
return error;
}
return fallback;
}
/**
* Type guard to check if an error has a detail field (RFC 7807 format)
*/
export function hasErrorDetail(error: unknown): error is { detail: string } {
return (
typeof error === 'object' &&
error !== null &&
'detail' in error &&
typeof (error as { detail: unknown }).detail === 'string'
);
}
/**
* Type guard to check if an error is an API error with status code
*/
export function isApiError(error: unknown): error is { status: number; detail: string } {
return (
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof (error as { status: unknown }).status === 'number' &&
'detail' in error &&
typeof (error as { detail: unknown }).detail === 'string'
);
}
/**
* Format an error for display in a toast or alert
*/
export function formatErrorForDisplay(error: unknown, context?: string): string {
const message = getApiErrorMessage(error);
return context ? `${context}: ${message}` : message;
}

@ -0,0 +1,152 @@
/**
* Centralized size configurations for consistent component sizing.
* These configurations ensure visual consistency across the application.
*/
// ============================================================================
// Stats Card Sizes
// ============================================================================
export const STATS_CARD_SIZE_CONFIG = {
sm: {
iconContainer: 'w-8 h-8',
icon: 'w-4 h-4',
value: 'text-lg',
label: 'text-xs',
},
md: {
iconContainer: 'w-10 h-10 md:w-12 md:h-12',
icon: 'w-5 h-5 md:w-6 md:h-6',
value: 'text-xl md:text-2xl',
label: 'text-xs md:text-sm',
},
lg: {
iconContainer: 'w-12 h-12 md:w-14 md:h-14',
icon: 'w-6 h-6 md:w-7 md:h-7',
value: 'text-2xl md:text-3xl',
label: 'text-sm md:text-base',
},
} as const;
export type StatsCardSize = keyof typeof STATS_CARD_SIZE_CONFIG;
// ============================================================================
// Button Sizes
// ============================================================================
export const BUTTON_SIZE_CONFIG = {
xs: {
padding: 'px-2 py-1',
text: 'text-xs',
icon: 'w-3 h-3',
},
sm: {
padding: 'px-3 py-1.5',
text: 'text-sm',
icon: 'w-4 h-4',
},
md: {
padding: 'px-4 py-2',
text: 'text-base',
icon: 'w-5 h-5',
},
lg: {
padding: 'px-6 py-3',
text: 'text-lg',
icon: 'w-6 h-6',
},
} as const;
export type ButtonSize = keyof typeof BUTTON_SIZE_CONFIG;
// ============================================================================
// Icon Container Sizes
// ============================================================================
export const ICON_CONTAINER_SIZE_CONFIG = {
xs: 'w-6 h-6',
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12',
xl: 'w-16 h-16',
} as const;
export type IconContainerSize = keyof typeof ICON_CONTAINER_SIZE_CONFIG;
// ============================================================================
// Avatar/Profile Image Sizes
// ============================================================================
export const AVATAR_SIZE_CONFIG = {
xs: {
container: 'w-6 h-6',
containerMd: 'md:w-8 md:h-8',
text: 'text-xs',
},
sm: {
container: 'w-8 h-8',
containerMd: 'md:w-10 md:h-10',
text: 'text-sm',
},
md: {
container: 'w-10 h-10',
containerMd: 'md:w-12 md:h-12',
text: 'text-base',
},
lg: {
container: 'w-12 h-12',
containerMd: 'md:w-14 md:h-14',
text: 'text-lg',
},
xl: {
container: 'w-16 h-16',
containerMd: 'md:w-20 md:h-20',
text: 'text-xl',
},
} as const;
export type AvatarSize = keyof typeof AVATAR_SIZE_CONFIG;
// ============================================================================
// Card Padding Sizes
// ============================================================================
export const CARD_PADDING_CONFIG = {
none: 'p-0',
xs: 'p-2',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
xl: 'p-8',
} as const;
export type CardPadding = keyof typeof CARD_PADDING_CONFIG;
// ============================================================================
// Spacing Utilities
// ============================================================================
export const SPACING_CONFIG = {
compact: 'space-y-2',
comfortable: 'space-y-4',
spacious: 'space-y-6',
generous: 'space-y-8',
} as const;
export type SpacingSize = keyof typeof SPACING_CONFIG;
// ============================================================================
// Border Radius
// ============================================================================
export const BORDER_RADIUS_CONFIG = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
'2xl': 'rounded-2xl',
full: 'rounded-full',
} as const;
export type BorderRadius = keyof typeof BORDER_RADIUS_CONFIG;
Loading…
Cancel
Save