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 {
|
interface AvatarSkeletonProps {
|
||||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
size?: AvatarSize;
|
||||||
shape?: 'circle' | 'square' | 'rounded';
|
shape?: Exclude<SkeletonShape, 'rounded-lg'>;
|
||||||
hasBorder?: boolean;
|
hasBorder?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses = {
|
export default function AvatarSkeleton({
|
||||||
xs: 'w-6 h-6',
|
size = 'md',
|
||||||
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',
|
|
||||||
shape = 'circle',
|
shape = 'circle',
|
||||||
hasBorder = false,
|
hasBorder = false,
|
||||||
className = ''
|
className = ''
|
||||||
}: AvatarSkeletonProps) {
|
}: AvatarSkeletonProps) {
|
||||||
const borderClass = hasBorder ? 'border-2 border-white/80 shadow-lg' : '';
|
const borderClass = hasBorder ? 'border-2 border-white/80 shadow-lg' : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`bg-slate-300 animate-pulse ${sizeClasses[size]} ${sizeClassesMd[size]} ${shapeClasses[shape]} ${borderClass} ${className}`}
|
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 {
|
interface IconSkeletonProps {
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
size?: IconSize;
|
||||||
shape?: 'circle' | 'square' | 'rounded';
|
shape?: SkeletonShape;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses = {
|
export default function IconSkeleton({
|
||||||
sm: 'w-8 h-8',
|
size = 'md',
|
||||||
md: 'w-12 h-12',
|
shape = 'rounded-lg',
|
||||||
lg: 'w-16 h-16',
|
className = ''
|
||||||
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 = ''
|
|
||||||
}: IconSkeletonProps) {
|
}: IconSkeletonProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`bg-slate-200 animate-pulse ${sizeClasses[size]} ${shapeClasses[shape]} ${className}`}
|
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