feat(admin): add sport variation selection to court creation + fix profile trim crash
continuous-integration/drone/push Build is passing Details

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
master
Guillermo Pages 1 month ago
parent 47dbb96c47
commit 36c5fe2183

@ -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<number>(court?.sport_variation_id || 0);
const [sports, setSports] = useState<Sport[]>([]);
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 && (
<p className="mt-1 text-sm text-red-600">{fieldError}</p>
)}
<p className="mt-2 text-xs text-slate-500">
Examples: "Court 1", "North Court", "VIP Court"
</p>
</div>
{/* Sport Variation */}
<div>
<label className="block text-sm font-semibold text-slate-900 mb-2">
Sport & Type <span className="text-red-600">*</span>
</label>
{loadingSports ? (
<div className="flex items-center justify-center py-3 border-2 border-slate-200 rounded-lg">
<Loader2 className="w-5 h-5 text-slate-600 animate-spin" />
</div>
) : (
<select
value={sportVariationId}
onChange={(e) => setSportVariationId(Number(e.target.value))}
className={`w-full px-4 py-3 border-2 rounded-lg font-medium transition-colors ${
sportVariationError
? 'border-red-300 focus:border-red-500'
: 'border-slate-200 focus:border-slate-900'
} focus:outline-none`}
disabled={saving || isEditing}
>
<option value={0} disabled>Select sport variation</option>
{sports.map((sport) =>
sport.variations.map((variation) => (
<option key={variation.sport_variation_id} value={variation.sport_variation_id}>
{sport.name} - {variation.name}
</option>
))
)}
</select>
)}
{sportVariationError && (
<p className="mt-1 text-sm text-red-600">{sportVariationError}</p>
)}
{isEditing && (
<p className="mt-2 text-xs text-slate-500">
Sport type cannot be changed after creation
</p>
)}
</div>
{/* Actions */}

@ -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

@ -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<ApiResult<SportsResponse>> {
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',
},

@ -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 {

Loading…
Cancel
Save