From 36c5fe2183eb3f7b9307f2123e6ac46d8f97e76e Mon Sep 17 00:00:00 2001 From: Guillermo Pages Date: Fri, 7 Nov 2025 16:11:33 +0100 Subject: [PATCH] feat(admin): add sport variation selection to court creation + fix profile trim crash Court Sport Variation (BUILD:296-297 requirement): - Added Sport and SportVariation types to courts.ts - Created getSports() API client for GET /admin/sports - Updated Court and CourtRequest types with sport_variation_id field - Added sport variation dropdown to court creation modal - Auto-selects first variation for new courts - Sport type locked after creation (disabled in edit mode) - Mock data updated with sport_variation_id values Profile Bug Fix: - Fixed click-to-edit crash when field values are empty/undefined - Added null checks before calling .trim() in hasFieldValue() and EditableField - Error: "Cannot read properties of undefined (reading 'trim')" - Now safely handles empty string values UI Details: - Sport dropdown shows "Padel - Indoor", "Padel - Outdoor", etc. - Loading spinner while fetching sports - Validation error if no sport variation selected - Form includes both name and sport_variation_id in POST request Backend Brooke message: Court creation now requires sport_variation_id parameter --- .../clubs/[club_id]/tabs/ClubCourtsTab.tsx | 75 ++++++++++++++++++- .../clubs/[club_id]/tabs/ClubProfileTab.tsx | 4 +- src/lib/api/courts.ts | 38 ++++++++++ src/types/courts.ts | 20 +++++ 4 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubCourtsTab.tsx b/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubCourtsTab.tsx index b35f8d7..4c4ce91 100644 --- a/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubCourtsTab.tsx +++ b/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubCourtsTab.tsx @@ -8,8 +8,9 @@ import { updateCourt, deleteCourt, getCourtDependencies, + getSports, } from '@/src/lib/api/courts'; -import type { Court, CourtRequest, CourtDependencies } from '@/src/types/courts'; +import type { Court, CourtRequest, CourtDependencies, Sport, SportVariation } from '@/src/types/courts'; import { formatTimestamp } from '@/src/types/courts'; interface ClubCourtsTabProps { @@ -244,9 +245,31 @@ interface CourtFormModalProps { function CourtFormModal({ clubId, court, onClose, onSuccess }: CourtFormModalProps) { const isEditing = !!court; const [name, setName] = useState(court?.name || ''); + const [sportVariationId, setSportVariationId] = useState(court?.sport_variation_id || 0); + const [sports, setSports] = useState([]); + const [loadingSports, setLoadingSports] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [fieldError, setFieldError] = useState(''); + const [sportVariationError, setSportVariationError] = useState(''); + + useEffect(() => { + async function loadSports() { + setLoadingSports(true); + const result = await getSports(); + if (result.success) { + setSports(result.data.sports); + // Auto-select first variation if creating new court + if (!isEditing && result.data.sports.length > 0 && result.data.sports[0].variations.length > 0) { + setSportVariationId(result.data.sports[0].variations[0].sport_variation_id); + } + } else { + setError(result.error.detail); + } + setLoadingSports(false); + } + loadSports(); + }, [isEditing]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -256,12 +279,19 @@ function CourtFormModal({ clubId, court, onClose, onSuccess }: CourtFormModalPro return; } + if (!sportVariationId || sportVariationId === 0) { + setSportVariationError('Sport variation is required'); + return; + } + setSaving(true); setError(''); setFieldError(''); + setSportVariationError(''); const request: CourtRequest = { name: name.trim(), + sport_variation_id: sportVariationId, }; const result = isEditing @@ -336,9 +366,46 @@ function CourtFormModal({ clubId, court, onClose, onSuccess }: CourtFormModalPro {fieldError && (

{fieldError}

)} -

- Examples: "Court 1", "North Court", "VIP Court" -

+ + + {/* Sport Variation */} +
+ + {loadingSports ? ( +
+ +
+ ) : ( + + )} + {sportVariationError && ( +

{sportVariationError}

+ )} + {isEditing && ( +

+ Sport type cannot be changed after creation +

+ )}
{/* Actions */} diff --git a/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubProfileTab.tsx b/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubProfileTab.tsx index ca2b4c9..e9cf238 100644 --- a/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubProfileTab.tsx +++ b/src/app/[locale]/admin/clubs/[club_id]/tabs/ClubProfileTab.tsx @@ -34,7 +34,7 @@ function EditableField({ error, disabled = false, }: EditableFieldProps) { - const hasValue = value.trim().length > 0; + const hasValue = value && value.trim().length > 0; if (isEditing || !hasValue) { return ( @@ -252,7 +252,7 @@ export default function ClubProfileTab({ clubId, onUpdate }: ClubProfileTabProps } function hasFieldValue(value: string): boolean { - return value.trim().length > 0; + return value && value.trim().length > 0; } // Loading state diff --git a/src/lib/api/courts.ts b/src/lib/api/courts.ts index 8b23436..c3fd732 100644 --- a/src/lib/api/courts.ts +++ b/src/lib/api/courts.ts @@ -12,6 +12,7 @@ import type { ClubProfile, ClubProfileUpdateRequest, CourtError, + SportsResponse, } from '@/src/types/courts'; const API_BASE_URL = process.env.NEXT_PUBLIC_PYTHON_API_URL; @@ -525,6 +526,40 @@ export async function deleteCourt( } } +/** + * Get sports with variations + */ +export async function getSports(): Promise> { + try { + const response = await fetch(`${API_BASE_URL}/admin/sports`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error: CourtError = await response.json(); + return { success: false, error }; + } + + const data: SportsResponse = await response.json(); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: { + type: 'about:blank', + title: 'Network Error', + status: 0, + detail: 'Failed to fetch sports. Please check your connection.', + code: 'network_error', + }, + }; + } +} + /** * Mock data for development */ @@ -533,18 +568,21 @@ let mockCourtsData: Court[] = [ { court_id: 101, name: 'Court 1', + sport_variation_id: 2, created_at: '2024-06-15T10:30:00Z', updated_at: '2024-06-15T10:30:00Z', }, { court_id: 102, name: 'Court 2', + sport_variation_id: 2, created_at: '2024-06-15T10:30:00Z', updated_at: '2024-06-15T10:30:00Z', }, { court_id: 103, name: 'Court 3', + sport_variation_id: 1, created_at: '2024-06-15T10:30:00Z', updated_at: '2024-06-15T10:30:00Z', }, diff --git a/src/types/courts.ts b/src/types/courts.ts index ef77d7f..70dda64 100644 --- a/src/types/courts.ts +++ b/src/types/courts.ts @@ -7,12 +7,32 @@ export interface Court { court_id: number; name: string; + sport_variation_id: number; created_at: string; // ISO 8601 timestamp updated_at: string; // ISO 8601 timestamp } export interface CourtRequest { name: string; + sport_variation_id: number; +} + +export interface SportVariation { + sport_variation_id: number; + name: string; + slug: string; +} + +export interface Sport { + sport_id: number; + name: string; + slug: string; + logo_url: string | null; + variations: SportVariation[]; +} + +export interface SportsResponse { + sports: Sport[]; } export interface CourtDependencies {