diff --git a/src/app/[locale]/admin/clubs/[club_id]/competitions/CompetitionsPageComponent.tsx b/src/app/[locale]/admin/clubs/[club_id]/competitions/CompetitionsPageComponent.tsx new file mode 100644 index 0000000..cf22a64 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/competitions/CompetitionsPageComponent.tsx @@ -0,0 +1,126 @@ +'use client'; + +import React, { useState } from 'react'; +import { Plus, Filter, Search, Trophy, FileText } from 'lucide-react'; +import { useCompetitions } from '@/src/hooks/queries/useCompetitionQueries'; +import { CompetitionList } from '@/src/components/competitions'; +import CreateCompetitionModal from './CreateCompetitionModal'; +import type { CompetitionStatus, CompetitionType } from '@/src/types/competition'; + +interface CompetitionsPageComponentProps { + clubId: number; +} + +const statusOptions: { value: CompetitionStatus | 'all'; label: string }[] = [ + { value: 'all', label: 'All Statuses' }, + { value: 'draft', label: 'Draft' }, + { value: 'published', label: 'Published' }, + { value: 'running', label: 'Running' }, + { value: 'finished', label: 'Finished' }, + { value: 'cancelled', label: 'Cancelled' }, +]; + +const typeOptions: { value: CompetitionType | 'all'; label: string }[] = [ + { value: 'all', label: 'All Types' }, + { value: 'league', label: 'League' }, + { value: 'tournament', label: 'Tournament' }, + { value: 'challenge', label: 'Challenge' }, + { value: 'hybrid', label: 'Hybrid' }, +]; + +export default function CompetitionsPageComponent({ clubId }: CompetitionsPageComponentProps) { + const [statusFilter, setStatusFilter] = useState('all'); + const [typeFilter, setTypeFilter] = useState('all'); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const { data: competitions, isLoading, error } = useCompetitions(clubId, { + status: statusFilter === 'all' ? undefined : statusFilter, + type: typeFilter === 'all' ? undefined : typeFilter, + }); + + return ( +
+ {/* Page Header */} +
+
+

Competitions

+

+ Manage leagues, tournaments, and challenges +

+
+
+ + +
+
+ + {/* Filters */} +
+
+ + +
+
+ + +
+
+ + {/* Error State */} + {error && ( +
+

+ Failed to load competitions. Please try again. +

+
+ )} + + {/* Competition List */} + + + {/* Create Competition Modal */} + setIsCreateModalOpen(false)} + facilityId={clubId} + /> +
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/competitions/CreateCompetitionModal.tsx b/src/app/[locale]/admin/clubs/[club_id]/competitions/CreateCompetitionModal.tsx new file mode 100644 index 0000000..fb79af5 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/competitions/CreateCompetitionModal.tsx @@ -0,0 +1,418 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { X, Trophy, Target, Zap, Layers, Loader2, AlertCircle } from 'lucide-react'; +import Modal from '@/src/components/modals/Modal'; +import ModalHeader from '@/src/components/modals/ModalHeader'; +import ModalBody from '@/src/components/modals/ModalBody'; +import ModalFooter from '@/src/components/modals/ModalFooter'; +import { useCompetitionTemplates } from '@/src/hooks/queries/useCompetitionQueries'; +import { useCreateCompetition } from '@/src/hooks/mutations/useCompetitionMutations'; +import type { + CompetitionType, + CompetitionVisibility, + CreateCompetitionRequest, + CompetitionTemplate, +} from '@/src/types/competition'; + +interface CreateCompetitionModalProps { + isOpen: boolean; + onClose: () => void; + facilityId: number; +} + +const competitionTypes: { + type: CompetitionType; + label: string; + description: string; + Icon: React.ComponentType<{ className?: string }>; +}[] = [ + { + type: 'league', + label: 'League', + description: 'Round-robin format with standings', + Icon: Trophy, + }, + { + type: 'tournament', + label: 'Tournament', + description: 'Knockout bracket format', + Icon: Target, + }, + { + type: 'challenge', + label: 'Challenge', + description: 'Rating-based leaderboard', + Icon: Zap, + }, + { + type: 'hybrid', + label: 'Hybrid', + description: 'Multiple stages combined', + Icon: Layers, + }, +]; + +const visibilityOptions: { value: CompetitionVisibility; label: string; description: string }[] = [ + { value: 'public', label: 'Public', description: 'Anyone can see and register' }, + { value: 'unlisted', label: 'Unlisted', description: 'Only accessible via link' }, + { value: 'private', label: 'Private', description: 'Invite only' }, +]; + +export default function CreateCompetitionModal({ + isOpen, + onClose, + facilityId, +}: CreateCompetitionModalProps) { + const [step, setStep] = useState<'source' | 'details'>('source'); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [formData, setFormData] = useState<{ + type: CompetitionType; + title: string; + description: string; + visibility: CompetitionVisibility; + starts_at: string; + ends_at: string; + registration_open_at: string; + registration_close_at: string; + sport_id: number; + }>({ + type: 'tournament', + title: '', + description: '', + visibility: 'public', + starts_at: '', + ends_at: '', + registration_open_at: '', + registration_close_at: '', + sport_id: 1, // Default to padel + }); + const [formError, setFormError] = useState(null); + + const { data: templates, isLoading: templatesLoading } = useCompetitionTemplates(facilityId); + const createMutation = useCreateCompetition(facilityId); + + // Reset form when modal closes + useEffect(() => { + if (!isOpen) { + setStep('source'); + setSelectedTemplate(null); + setFormData({ + type: 'tournament', + title: '', + description: '', + visibility: 'public', + starts_at: '', + ends_at: '', + registration_open_at: '', + registration_close_at: '', + sport_id: 1, + }); + setFormError(null); + } + }, [isOpen]); + + function handleSelectTemplate(template: CompetitionTemplate) { + setSelectedTemplate(template); + setFormData((prev) => ({ + ...prev, + type: template.type, + sport_id: template.sport_id, + })); + setStep('details'); + } + + function handleSelectFromScratch(type: CompetitionType) { + setSelectedTemplate(null); + setFormData((prev) => ({ + ...prev, + type, + })); + setStep('details'); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setFormError(null); + + if (!formData.title.trim()) { + setFormError('Title is required'); + return; + } + if (!formData.starts_at) { + setFormError('Start date is required'); + return; + } + + const request: CreateCompetitionRequest = { + type: formData.type, + sport_id: formData.sport_id, + title: formData.title.trim(), + description: formData.description.trim() || undefined, + visibility: formData.visibility, + starts_at: new Date(formData.starts_at).toISOString(), + ends_at: formData.ends_at ? new Date(formData.ends_at).toISOString() : undefined, + registration_open_at: formData.registration_open_at + ? new Date(formData.registration_open_at).toISOString() + : undefined, + registration_close_at: formData.registration_close_at + ? new Date(formData.registration_close_at).toISOString() + : undefined, + template_id: selectedTemplate?.template_id, + config: selectedTemplate?.config, + }; + + try { + await createMutation.mutateAsync(request); + onClose(); + } catch (err) { + setFormError(err instanceof Error ? err.message : 'Failed to create competition'); + } + } + + return ( + + + + {step === 'source' ? ( +
+ {/* Templates Section */} + {templatesLoading ? ( +
+ +
+ ) : templates && templates.length > 0 ? ( +
+

+ From Template +

+
+ {templates.map((template) => ( + + ))} +
+
+ ) : null} + + {/* From Scratch Section */} +
+

+ {templates && templates.length > 0 ? 'Or Start From Scratch' : 'Choose Type'} +

+
+ {competitionTypes.map(({ type, label, description, Icon }) => ( + + ))} +
+
+
+ ) : ( +
+ {formError && ( +
+ +

{formError}

+
+ )} + + {/* Title */} +
+ + setFormData((prev) => ({ ...prev, title: e.target.value }))} + placeholder="e.g., Summer Tournament 2025" + className="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +
+ + {/* Description */} +
+ +