refactor: add TanStack Query and centralized abstractions
continuous-integration/drone/push Build is passing
Details
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 configmaster
parent
256eccc997
commit
f39e3542ed
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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…
Reference in New Issue