From f39e3542eda6018532508c12e37800e897f6b4c2 Mon Sep 17 00:00:00 2001 From: Guillermo Pages Date: Thu, 4 Dec 2025 23:47:34 +0100 Subject: [PATCH] refactor: add TanStack Query and centralized abstractions - 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 --- package-lock.json | 55 +++++++ package.json | 2 + src/app/[locale]/providers.tsx | 35 ++-- src/components/QueryProvider.tsx | 21 +++ src/components/modals/ModalFormWrapper.tsx | 95 +++++++++++ src/components/skeletons/AvatarSkeleton.tsx | 48 ++---- src/components/skeletons/IconSkeleton.tsx | 38 ++--- .../skeletons/PlayerItemSkeleton.tsx | 63 +++---- src/components/skeletons/skeletonConfig.ts | 80 +++++++++ src/hooks/mutations/useMemberMutations.ts | 95 +++++++++++ src/hooks/mutations/usePlanMutations.ts | 128 +++++++++++++++ src/hooks/queries/useBookingQueries.ts | 114 +++++++++++++ src/hooks/queries/useClubQueries.ts | 155 ++++++++++++++++++ src/lib/queryClient.ts | 32 ++++ src/utils/errorHandling.ts | 80 +++++++++ src/utils/sizeConfigs.ts | 152 +++++++++++++++++ 16 files changed, 1082 insertions(+), 111 deletions(-) create mode 100644 src/components/QueryProvider.tsx create mode 100644 src/components/modals/ModalFormWrapper.tsx create mode 100644 src/components/skeletons/skeletonConfig.ts create mode 100644 src/hooks/mutations/useMemberMutations.ts create mode 100644 src/hooks/mutations/usePlanMutations.ts create mode 100644 src/hooks/queries/useBookingQueries.ts create mode 100644 src/hooks/queries/useClubQueries.ts create mode 100644 src/lib/queryClient.ts create mode 100644 src/utils/errorHandling.ts create mode 100644 src/utils/sizeConfigs.ts diff --git a/package-lock.json b/package-lock.json index f77294b..eb59ade 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 80294c6..fa8d421 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/[locale]/providers.tsx b/src/app/[locale]/providers.tsx index 4f0113d..6c29634 100644 --- a/src/app/[locale]/providers.tsx +++ b/src/app/[locale]/providers.tsx @@ -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 ( - - - - - - - {children} - - - - - - - - - + + + + + + + + {children} + + + + + + + + + + ); } diff --git a/src/components/QueryProvider.tsx b/src/components/QueryProvider.tsx new file mode 100644 index 0000000..4e3d674 --- /dev/null +++ b/src/components/QueryProvider.tsx @@ -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 ( + + {children} + + + ); +} diff --git a/src/components/modals/ModalFormWrapper.tsx b/src/components/modals/ModalFormWrapper.tsx new file mode 100644 index 0000000..cce85e1 --- /dev/null +++ b/src/components/modals/ModalFormWrapper.tsx @@ -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 + * + * {form fields} + * + * ``` + */ +export default function ModalFormWrapper({ + isOpen, + onClose, + onSubmit, + title, + subtitle, + isPending, + error, + submitText = 'Save', + submitTextPending = 'Saving...', + cancelText = 'Cancel', + size = 'md', + children, +}: ModalFormWrapperProps) { + return ( + +
+ + + + {error && ( +
+ {error} +
+ )} + {children} +
+ + + + + + +
+ ); +} diff --git a/src/components/skeletons/AvatarSkeleton.tsx b/src/components/skeletons/AvatarSkeleton.tsx index 5465272..16e5ce7 100644 --- a/src/components/skeletons/AvatarSkeleton.tsx +++ b/src/components/skeletons/AvatarSkeleton.tsx @@ -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; 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 ( -
); } \ No newline at end of file diff --git a/src/components/skeletons/IconSkeleton.tsx b/src/components/skeletons/IconSkeleton.tsx index c0e99f5..d0538eb 100644 --- a/src/components/skeletons/IconSkeleton.tsx +++ b/src/components/skeletons/IconSkeleton.tsx @@ -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 ( -
); } \ No newline at end of file diff --git a/src/components/skeletons/PlayerItemSkeleton.tsx b/src/components/skeletons/PlayerItemSkeleton.tsx index 4355f09..9a6327f 100644 --- a/src/components/skeletons/PlayerItemSkeleton.tsx +++ b/src/components/skeletons/PlayerItemSkeleton.tsx @@ -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({
{/* Pill skeleton */} -
- +
+ {/* Name and level skeleton */}
-
+
-
-
+
+
- + {/* Secondary data skeleton */} {showSecondaryData && (
-
+
)} - + {/* Actions skeleton */} {showActions && ( -
+
)} - + {/* Expand arrow skeleton */} {hasExpandableContent && ( -
+
)}
diff --git a/src/components/skeletons/skeletonConfig.ts b/src/components/skeletons/skeletonConfig.ts new file mode 100644 index 0000000..3e4c293 --- /dev/null +++ b/src/components/skeletons/skeletonConfig.ts @@ -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; diff --git a/src/hooks/mutations/useMemberMutations.ts b/src/hooks/mutations/useMemberMutations.ts new file mode 100644 index 0000000..56ce478 --- /dev/null +++ b/src/hooks/mutations/useMemberMutations.ts @@ -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; +export type UpdateMemberMutation = ReturnType; +export type DeleteMemberMutation = ReturnType; diff --git a/src/hooks/mutations/usePlanMutations.ts b/src/hooks/mutations/usePlanMutations.ts new file mode 100644 index 0000000..2df600c --- /dev/null +++ b/src/hooks/mutations/usePlanMutations.ts @@ -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; +export type UpdatePlanMutation = ReturnType; +export type DeletePlanMutation = ReturnType; +export type UpdatePlanEntitlementsMutation = ReturnType; diff --git a/src/hooks/queries/useBookingQueries.ts b/src/hooks/queries/useBookingQueries.ts new file mode 100644 index 0000000..8170f82 --- /dev/null +++ b/src/hooks/queries/useBookingQueries.ts @@ -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[]; +} + +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 { + 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 }; diff --git a/src/hooks/queries/useClubQueries.ts b/src/hooks/queries/useClubQueries.ts new file mode 100644 index 0000000..aa8ae96 --- /dev/null +++ b/src/hooks/queries/useClubQueries.ts @@ -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; + }, + }); +} diff --git a/src/lib/queryClient.ts b/src/lib/queryClient.ts new file mode 100644 index 0000000..c928138 --- /dev/null +++ b/src/lib/queryClient.ts @@ -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; +} diff --git a/src/utils/errorHandling.ts b/src/utils/errorHandling.ts new file mode 100644 index 0000000..ea92559 --- /dev/null +++ b/src/utils/errorHandling.ts @@ -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; +} diff --git a/src/utils/sizeConfigs.ts b/src/utils/sizeConfigs.ts new file mode 100644 index 0000000..1ff8bc9 --- /dev/null +++ b/src/utils/sizeConfigs.ts @@ -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;