feat: add competition management UI for facility managers
continuous-integration/drone/push Build is passing Details

Add comprehensive competition management features:

Types & API:
- competition.ts: Type definitions for competitions, templates, stages, etc.
- competition-admin.ts: API client for all competition endpoints
- useCompetitionQueries.ts: React Query hooks for data fetching
- useCompetitionMutations.ts: Mutation hooks for write operations

Components:
- CompetitionStatusBadge, CompetitionTypeBadge for visual indicators
- CompetitionCard, CompetitionList for displaying competitions
- Skeleton components for loading states

Pages:
- /competitions: List competitions with status/type filtering
- /competitions/templates: Manage reusable competition templates
- /competitions/[id]: Detail view with tabs for:
  - Overview: Competition details and configuration
  - Registrations: Approve/reject registration requests
  - Participants: Manage seeds and withdrawals
  - Fixtures: Generate and manage match fixtures
  - Standings: View standings tables and leaderboards

Navigation:
- Added Competitions tab to ClubTabNavigation
master
Guillermo Pages 2 months ago
parent f39e3542ed
commit e6a10f7290

@ -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<CompetitionStatus | 'all'>('all');
const [typeFilter, setTypeFilter] = useState<CompetitionType | 'all'>('all');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { data: competitions, isLoading, error } = useCompetitions(clubId, {
status: statusFilter === 'all' ? undefined : statusFilter,
type: typeFilter === 'all' ? undefined : typeFilter,
});
return (
<div>
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h2 className="text-2xl font-bold text-slate-900">Competitions</h2>
<p className="text-slate-600 mt-1">
Manage leagues, tournaments, and challenges
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => window.location.href = `competitions/templates`}
className="inline-flex items-center px-4 py-2.5 bg-white border border-slate-200 text-slate-700 font-semibold rounded-xl hover:bg-slate-50 transition-colors"
>
<FileText className="w-5 h-5 mr-2" />
Templates
</button>
<button
onClick={() => setIsCreateModalOpen(true)}
className="inline-flex items-center px-4 py-2.5 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all"
>
<Plus className="w-5 h-5 mr-2" />
New Competition
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="relative">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as CompetitionStatus | 'all')}
className="pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent appearance-none cursor-pointer"
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="relative">
<Trophy className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as CompetitionType | 'all')}
className="pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent appearance-none cursor-pointer"
>
{typeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{/* Error State */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
<p className="text-red-700 font-medium">
Failed to load competitions. Please try again.
</p>
</div>
)}
{/* Competition List */}
<CompetitionList
competitions={competitions ?? []}
facilityId={clubId}
isLoading={isLoading}
emptyMessage="No competitions yet. Create your first competition to get started."
/>
{/* Create Competition Modal */}
<CreateCompetitionModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
facilityId={clubId}
/>
</div>
);
}

@ -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<CompetitionTemplate | null>(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<string | null>(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 (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalHeader
title={step === 'source' ? 'Create Competition' : 'Competition Details'}
subtitle={
step === 'source'
? 'Choose a template or start from scratch'
: selectedTemplate
? `Based on: ${selectedTemplate.name}`
: 'Configure your competition'
}
onClose={onClose}
/>
<ModalBody padding="md">
{step === 'source' ? (
<div className="space-y-6">
{/* Templates Section */}
{templatesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
</div>
) : templates && templates.length > 0 ? (
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-3">
From Template
</h3>
<div className="grid gap-3">
{templates.map((template) => (
<button
key={template.template_id}
onClick={() => handleSelectTemplate(template)}
className="flex items-center gap-4 p-4 bg-slate-50 hover:bg-purple-50 border border-slate-200 hover:border-purple-300 rounded-xl text-left transition-colors"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
{template.type === 'league' && <Trophy className="w-5 h-5 text-purple-600" />}
{template.type === 'tournament' && <Target className="w-5 h-5 text-purple-600" />}
{template.type === 'challenge' && <Zap className="w-5 h-5 text-purple-600" />}
{template.type === 'hybrid' && <Layers className="w-5 h-5 text-purple-600" />}
</div>
<div className="flex-1">
<h4 className="font-semibold text-slate-800">{template.name}</h4>
{template.description && (
<p className="text-sm text-slate-600 line-clamp-1">
{template.description}
</p>
)}
</div>
<span className="text-xs px-2 py-1 bg-white rounded-full text-slate-500 capitalize">
{template.type}
</span>
</button>
))}
</div>
</div>
) : null}
{/* From Scratch Section */}
<div>
<h3 className="text-sm font-semibold text-slate-700 mb-3">
{templates && templates.length > 0 ? 'Or Start From Scratch' : 'Choose Type'}
</h3>
<div className="grid grid-cols-2 gap-3">
{competitionTypes.map(({ type, label, description, Icon }) => (
<button
key={type}
onClick={() => handleSelectFromScratch(type)}
className="flex flex-col items-center gap-2 p-4 bg-white hover:bg-slate-50 border border-slate-200 hover:border-purple-300 rounded-xl text-center transition-colors"
>
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-purple-100 rounded-xl flex items-center justify-center">
<Icon className="w-6 h-6 text-purple-600" />
</div>
<div>
<h4 className="font-semibold text-slate-800">{label}</h4>
<p className="text-xs text-slate-500">{description}</p>
</div>
</button>
))}
</div>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{formError && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-xl">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
<p className="text-sm text-red-700">{formError}</p>
</div>
)}
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.title}
onChange={(e) => 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"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Description
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
placeholder="Brief description of the competition..."
rows={3}
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 resize-none"
/>
</div>
{/* Visibility */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Visibility
</label>
<div className="grid grid-cols-3 gap-2">
{visibilityOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() =>
setFormData((prev) => ({ ...prev, visibility: option.value }))
}
className={`p-3 border rounded-xl text-center transition-colors ${
formData.visibility === option.value
? 'border-purple-500 bg-purple-50 text-purple-700'
: 'border-slate-200 hover:border-slate-300 text-slate-700'
}`}
>
<div className="font-medium text-sm">{option.label}</div>
</button>
))}
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Start Date <span className="text-red-500">*</span>
</label>
<input
type="datetime-local"
value={formData.starts_at}
onChange={(e) =>
setFormData((prev) => ({ ...prev, starts_at: e.target.value }))
}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
End Date
</label>
<input
type="datetime-local"
value={formData.ends_at}
onChange={(e) =>
setFormData((prev) => ({ ...prev, ends_at: e.target.value }))
}
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"
/>
</div>
</div>
{/* Registration Period */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Registration Opens
</label>
<input
type="datetime-local"
value={formData.registration_open_at}
onChange={(e) =>
setFormData((prev) => ({ ...prev, registration_open_at: e.target.value }))
}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Registration Closes
</label>
<input
type="datetime-local"
value={formData.registration_close_at}
onChange={(e) =>
setFormData((prev) => ({ ...prev, registration_close_at: e.target.value }))
}
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"
/>
</div>
</div>
</form>
)}
</ModalBody>
<ModalFooter align="between">
{step === 'details' && (
<button
type="button"
onClick={() => setStep('source')}
className="px-4 py-2.5 text-slate-600 font-medium hover:text-slate-800 transition-colors"
>
Back
</button>
)}
<div className="flex gap-3 ml-auto">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 bg-slate-100 text-slate-700 font-semibold rounded-xl hover:bg-slate-200 transition-colors"
>
Cancel
</button>
{step === 'details' && (
<button
type="submit"
onClick={handleSubmit}
disabled={createMutation.isPending}
className="px-6 py-2.5 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{createMutation.isPending ? (
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Creating...
</span>
) : (
'Create Competition'
)}
</button>
)}
</div>
</ModalFooter>
</Modal>
);
}

@ -0,0 +1,323 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import {
ArrowLeft,
Loader2,
AlertCircle,
Calendar,
Users,
PlayCircle,
CheckCircle,
XCircle,
Clock,
Eye,
Settings,
Trophy,
ListOrdered,
} from 'lucide-react';
import useTranslation from '@/src/hooks/useTranslation';
import Card from '@/src/components/cards/Card';
import { CompetitionStatusBadge, CompetitionTypeBadge } from '@/src/components/competitions';
import { useCompetition } from '@/src/hooks/queries/useCompetitionQueries';
import {
usePublishCompetition,
useStartCompetition,
useFinishCompetition,
useCancelCompetition,
} from '@/src/hooks/mutations/useCompetitionMutations';
import OverviewTab from './tabs/OverviewTab';
import RegistrationsTab from './tabs/RegistrationsTab';
import ParticipantsTab from './tabs/ParticipantsTab';
import FixturesTab from './tabs/FixturesTab';
import StandingsTab from './tabs/StandingsTab';
import type { CompetitionStatus } from '@/src/types/competition';
interface CompetitionDetailComponentProps {
clubId: number;
competitionId: number;
}
type TabKey = 'overview' | 'registrations' | 'participants' | 'fixtures' | 'standings';
const tabs: { key: TabKey; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ key: 'overview', label: 'Overview', icon: Eye },
{ key: 'registrations', label: 'Registrations', icon: Users },
{ key: 'participants', label: 'Participants', icon: Trophy },
{ key: 'fixtures', label: 'Fixtures', icon: Calendar },
{ key: 'standings', label: 'Standings', icon: ListOrdered },
];
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function StatusTransitionButton({
status,
competitionId,
}: {
status: CompetitionStatus;
competitionId: number;
}) {
const publishMutation = usePublishCompetition(competitionId);
const startMutation = useStartCompetition(competitionId);
const finishMutation = useFinishCompetition(competitionId);
const cancelMutation = useCancelCompetition(competitionId);
const isPending =
publishMutation.isPending ||
startMutation.isPending ||
finishMutation.isPending ||
cancelMutation.isPending;
if (status === 'draft') {
return (
<div className="flex gap-2">
<button
onClick={() => publishMutation.mutate()}
disabled={isPending}
className="inline-flex items-center px-4 py-2 bg-indigo-600 text-white font-semibold rounded-xl hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
{publishMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<PlayCircle className="w-4 h-4 mr-2" />
)}
Publish
</button>
</div>
);
}
if (status === 'published') {
return (
<div className="flex gap-2">
<button
onClick={() => startMutation.mutate()}
disabled={isPending}
className="inline-flex items-center px-4 py-2 bg-emerald-600 text-white font-semibold rounded-xl hover:bg-emerald-700 transition-colors disabled:opacity-50"
>
{startMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<PlayCircle className="w-4 h-4 mr-2" />
)}
Start
</button>
<button
onClick={() => cancelMutation.mutate()}
disabled={isPending}
className="inline-flex items-center px-4 py-2 bg-red-100 text-red-700 font-semibold rounded-xl hover:bg-red-200 transition-colors disabled:opacity-50"
>
{cancelMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<XCircle className="w-4 h-4 mr-2" />
)}
Cancel
</button>
</div>
);
}
if (status === 'running') {
return (
<div className="flex gap-2">
<button
onClick={() => finishMutation.mutate()}
disabled={isPending}
className="inline-flex items-center px-4 py-2 bg-purple-600 text-white font-semibold rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50"
>
{finishMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle className="w-4 h-4 mr-2" />
)}
Finish
</button>
</div>
);
}
return null;
}
export default function CompetitionDetailComponent({
clubId,
competitionId,
}: CompetitionDetailComponentProps) {
const { locale } = useTranslation();
const [activeTab, setActiveTab] = useState<TabKey>('overview');
const { data: competition, isLoading, error } = useCompetition(competitionId);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="w-12 h-12 text-purple-600 animate-spin mb-4" />
<p className="text-slate-600">Loading competition...</p>
</div>
);
}
if (error || !competition) {
return (
<div className="py-8">
<Link
href={`/${locale}/admin/clubs/${clubId}/competitions`}
className="inline-flex items-center text-slate-600 hover:text-slate-900 font-medium transition-colors mb-6"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Competitions
</Link>
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start gap-3">
<AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0" />
<div>
<h3 className="font-semibold text-red-800">Error Loading Competition</h3>
<p className="text-red-700 mt-1">
{error instanceof Error ? error.message : 'Competition not found'}
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div>
{/* Back Link */}
<Link
href={`/${locale}/admin/clubs/${clubId}/competitions`}
className="inline-flex items-center text-slate-600 hover:text-slate-900 font-medium transition-colors mb-6"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Competitions
</Link>
{/* Header */}
<div className="mb-8">
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-4">
<div>
<div className="flex items-center gap-3 mb-2">
<CompetitionTypeBadge type={competition.type} size="sm" />
<CompetitionStatusBadge status={competition.status} size="sm" />
</div>
<h1 className="text-3xl font-bold text-slate-900">{competition.title}</h1>
{competition.description && (
<p className="text-slate-600 mt-2">{competition.description}</p>
)}
</div>
<StatusTransitionButton status={competition.status} competitionId={competitionId} />
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card variant="bordered" padding="sm">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center">
<Calendar className="w-5 h-5 text-indigo-600" />
</div>
<div>
<p className="text-xs text-slate-500">Starts</p>
<p className="text-sm font-medium text-slate-800">
{formatDate(competition.starts_at)}
</p>
</div>
</div>
</Card>
<Card variant="bordered" padding="sm">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="text-xs text-slate-500">Participants</p>
<p className="text-sm font-medium text-slate-800">
{competition.participant_count ?? 0}
</p>
</div>
</div>
</Card>
<Card variant="bordered" padding="sm">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-amber-600" />
</div>
<div>
<p className="text-xs text-slate-500">Registrations</p>
<p className="text-sm font-medium text-slate-800">
{competition.registration_count ?? 0} pending
</p>
</div>
</div>
</Card>
<Card variant="bordered" padding="sm">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-pink-100 rounded-lg flex items-center justify-center">
<Trophy className="w-5 h-5 text-pink-600" />
</div>
<div>
<p className="text-xs text-slate-500">Stages</p>
<p className="text-sm font-medium text-slate-800">
{competition.stages?.length ?? 0}
</p>
</div>
</div>
</Card>
</div>
</div>
{/* Tabs */}
<div className="border-b border-slate-200 mb-6">
<div className="flex overflow-x-auto scrollbar-hide -mb-px">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 px-4 py-3 font-medium border-b-2 whitespace-nowrap transition-colors ${
isActive
? 'border-purple-600 text-purple-600'
: 'border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<OverviewTab competition={competition} clubId={clubId} />
)}
{activeTab === 'registrations' && (
<RegistrationsTab competitionId={competitionId} />
)}
{activeTab === 'participants' && (
<ParticipantsTab competitionId={competitionId} />
)}
{activeTab === 'fixtures' && (
<FixturesTab competition={competition} />
)}
{activeTab === 'standings' && (
<StandingsTab competition={competition} />
)}
</div>
);
}

@ -0,0 +1,13 @@
import CompetitionDetailComponent from './CompetitionDetailComponent';
export default async function CompetitionDetailPage({
params,
}: {
params: Promise<{ club_id: string; competition_id: string }>;
}) {
const { club_id, competition_id } = await params;
const clubId = parseInt(club_id, 10);
const competitionId = parseInt(competition_id, 10);
return <CompetitionDetailComponent clubId={clubId} competitionId={competitionId} />;
}

@ -0,0 +1,274 @@
'use client';
import React, { useState } from 'react';
import { Calendar, Play, RefreshCw, AlertCircle, Loader2, CheckCircle, Clock } from 'lucide-react';
import Card from '@/src/components/cards/Card';
import { useCompetitionFixtures } from '@/src/hooks/queries/useCompetitionQueries';
import {
useGenerateFixtures,
useSetMatchWinner,
} from '@/src/hooks/mutations/useCompetitionMutations';
import type {
Competition,
CompetitionMatch,
CompetitionStage,
MatchStatus,
} from '@/src/types/competition';
interface FixturesTabProps {
competition: Competition;
}
const matchStatusConfig: Record<MatchStatus, {
label: string;
bgColor: string;
textColor: string;
}> = {
draft: { label: 'Draft', bgColor: 'bg-slate-100', textColor: 'text-slate-600' },
needs_scheduling: { label: 'Needs Scheduling', bgColor: 'bg-amber-100', textColor: 'text-amber-700' },
scheduled: { label: 'Scheduled', bgColor: 'bg-indigo-100', textColor: 'text-indigo-700' },
in_progress: { label: 'In Progress', bgColor: 'bg-emerald-100', textColor: 'text-emerald-700' },
completed: { label: 'Completed', bgColor: 'bg-purple-100', textColor: 'text-purple-700' },
voided: { label: 'Voided', bgColor: 'bg-red-100', textColor: 'text-red-700' },
};
function MatchCard({
match,
competitionId,
stageId,
}: {
match: CompetitionMatch;
competitionId: number;
stageId: number;
}) {
const setWinnerMutation = useSetMatchWinner(competitionId, stageId);
const config = matchStatusConfig[match.status];
const homeName = match.home_participant?.display_name ?? 'TBD';
const awayName = match.away_participant?.display_name ?? 'TBD';
const canSetWinner =
match.status !== 'completed' &&
match.status !== 'voided' &&
match.home_participant_id &&
match.away_participant_id;
function handleSetWinner(winnerId: number) {
setWinnerMutation.mutate({
matchId: match.competition_match_id,
request: { winner_participant_id: winnerId },
});
}
return (
<div className="p-4 bg-white border border-slate-200 rounded-xl">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-slate-500">
Round {match.round_index + 1} Match {match.match_index + 1}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${config.bgColor} ${config.textColor}`}>
{config.label}
</span>
</div>
<div className="space-y-2">
{/* Home */}
<div
className={`flex items-center justify-between p-3 rounded-lg transition-colors ${
match.winner_participant_id === match.home_participant_id
? 'bg-emerald-50 border border-emerald-200'
: 'bg-slate-50'
} ${canSetWinner && match.home_participant_id ? 'cursor-pointer hover:bg-purple-50' : ''}`}
onClick={() => canSetWinner && match.home_participant_id && handleSetWinner(match.home_participant_id)}
>
<span className={`font-medium ${homeName === 'TBD' ? 'text-slate-400' : 'text-slate-800'}`}>
{homeName}
</span>
{match.winner_participant_id === match.home_participant_id && (
<CheckCircle className="w-5 h-5 text-emerald-600" />
)}
</div>
<div className="text-center text-xs text-slate-400 font-medium">VS</div>
{/* Away */}
<div
className={`flex items-center justify-between p-3 rounded-lg transition-colors ${
match.winner_participant_id === match.away_participant_id
? 'bg-emerald-50 border border-emerald-200'
: 'bg-slate-50'
} ${canSetWinner && match.away_participant_id ? 'cursor-pointer hover:bg-purple-50' : ''}`}
onClick={() => canSetWinner && match.away_participant_id && handleSetWinner(match.away_participant_id)}
>
<span className={`font-medium ${awayName === 'TBD' ? 'text-slate-400' : 'text-slate-800'}`}>
{awayName}
</span>
{match.winner_participant_id === match.away_participant_id && (
<CheckCircle className="w-5 h-5 text-emerald-600" />
)}
</div>
</div>
{match.scheduled_at && (
<div className="flex items-center gap-1.5 mt-3 text-xs text-slate-500">
<Clock className="w-3.5 h-3.5" />
{new Date(match.scheduled_at).toLocaleString()}
</div>
)}
</div>
);
}
function StageFixtures({
stage,
competitionId,
}: {
stage: CompetitionStage;
competitionId: number;
}) {
const { data: fixtures, isLoading, error } = useCompetitionFixtures(competitionId, stage.stage_id);
const generateMutation = useGenerateFixtures(competitionId);
const hasFixtures = fixtures && fixtures.length > 0;
function handleGenerateFixtures() {
generateMutation.mutate({
stageId: stage.stage_id,
request: {
stage_type: stage.stage_type,
legs: stage.config?.legs,
third_place_match: stage.config?.third_place_match,
},
});
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl">
<AlertCircle className="w-5 h-5 text-red-600" />
<p className="text-red-700">Failed to load fixtures</p>
</div>
);
}
if (!hasFixtures) {
return (
<div className="text-center py-12">
<Calendar className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h4 className="font-semibold text-slate-800 mb-2">No fixtures generated</h4>
<p className="text-slate-500 mb-4">
Generate fixtures to create the match schedule for this stage.
</p>
<button
onClick={handleGenerateFixtures}
disabled={generateMutation.isPending}
className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all disabled:opacity-50"
>
{generateMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Play className="w-4 h-4 mr-2" />
)}
Generate Fixtures
</button>
</div>
);
}
// Group by round
const rounds = fixtures.reduce((acc, match) => {
const round = match.round_index;
if (!acc[round]) acc[round] = [];
acc[round].push(match);
return acc;
}, {} as Record<number, CompetitionMatch[]>);
return (
<div className="space-y-6">
{Object.entries(rounds)
.sort(([a], [b]) => parseInt(a) - parseInt(b))
.map(([roundIndex, matches]) => (
<div key={roundIndex}>
<h4 className="font-semibold text-slate-700 mb-3">
Round {parseInt(roundIndex) + 1}
</h4>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{matches
.sort((a, b) => a.match_index - b.match_index)
.map((match) => (
<MatchCard
key={match.competition_match_id}
match={match}
competitionId={competitionId}
stageId={stage.stage_id}
/>
))}
</div>
</div>
))}
</div>
);
}
export default function FixturesTab({ competition }: FixturesTabProps) {
const [selectedStageId, setSelectedStageId] = useState<number | null>(
competition.stages?.[0]?.stage_id ?? null
);
const stages = competition.stages ?? [];
const selectedStage = stages.find((s) => s.stage_id === selectedStageId);
if (stages.length === 0) {
return (
<Card variant="bordered" padding="lg">
<div className="text-center py-8">
<Calendar className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="font-semibold text-slate-800 mb-1">No stages configured</h3>
<p className="text-slate-500">
Configure stages in the competition settings to manage fixtures.
</p>
</div>
</Card>
);
}
return (
<div>
{/* Stage Selector */}
{stages.length > 1 && (
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
{stages
.sort((a, b) => a.stage_order - b.stage_order)
.map((stage) => (
<button
key={stage.stage_id}
onClick={() => setSelectedStageId(stage.stage_id)}
className={`px-4 py-2 rounded-xl font-medium whitespace-nowrap transition-colors ${
selectedStageId === stage.stage_id
? 'bg-purple-100 text-purple-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{stage.name}
</button>
))}
</div>
)}
{/* Fixtures */}
{selectedStage && (
<StageFixtures
stage={selectedStage}
competitionId={competition.competition_id}
/>
)}
</div>
);
}

@ -0,0 +1,207 @@
'use client';
import React from 'react';
import { Calendar, Users, Eye, Globe, Lock, Link as LinkIcon } from 'lucide-react';
import Card from '@/src/components/cards/Card';
import type { Competition, CompetitionStage } from '@/src/types/competition';
interface OverviewTabProps {
competition: Competition;
clubId: number;
}
function formatDate(dateString: string | null): string {
if (!dateString) return 'Not set';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function StageCard({ stage, index }: { stage: CompetitionStage; index: number }) {
const statusColors = {
pending: 'bg-slate-100 text-slate-700',
active: 'bg-emerald-100 text-emerald-700',
completed: 'bg-purple-100 text-purple-700',
};
const stageTypeLabels: Record<string, string> = {
round_robin: 'Round Robin',
group_stage: 'Group Stage',
knockout_single: 'Single Elimination',
knockout_double: 'Double Elimination',
rating_delta_challenge: 'Rating Challenge',
};
return (
<div className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl">
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center font-bold text-slate-600 border border-slate-200">
{index + 1}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-slate-800">{stage.name}</h4>
<span className={`text-xs px-2 py-0.5 rounded-full ${statusColors[stage.status]}`}>
{stage.status}
</span>
</div>
<p className="text-sm text-slate-600">
{stageTypeLabels[stage.stage_type] || stage.stage_type}
{stage.match_count !== undefined && `${stage.match_count} matches`}
{stage.groups && stage.groups.length > 0 && `${stage.groups.length} groups`}
</p>
</div>
</div>
);
}
export default function OverviewTab({ competition, clubId }: OverviewTabProps) {
const visibilityIcons = {
public: Globe,
unlisted: LinkIcon,
private: Lock,
};
const VisibilityIcon = visibilityIcons[competition.visibility];
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* Competition Details */}
<Card variant="bordered" padding="md">
<h3 className="text-lg font-semibold text-slate-800 mb-4">Competition Details</h3>
<div className="space-y-4">
<div className="flex items-start gap-3">
<Eye className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<p className="text-sm text-slate-500">Visibility</p>
<div className="flex items-center gap-2">
<VisibilityIcon className="w-4 h-4 text-slate-600" />
<p className="font-medium text-slate-800 capitalize">{competition.visibility}</p>
</div>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<p className="text-sm text-slate-500">Competition Period</p>
<p className="font-medium text-slate-800">{formatDate(competition.starts_at)}</p>
{competition.ends_at && (
<p className="text-sm text-slate-600">to {formatDate(competition.ends_at)}</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Users className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<p className="text-sm text-slate-500">Registration Period</p>
{competition.registration_open_at ? (
<>
<p className="font-medium text-slate-800">
Opens: {formatDate(competition.registration_open_at)}
</p>
{competition.registration_close_at && (
<p className="text-sm text-slate-600">
Closes: {formatDate(competition.registration_close_at)}
</p>
)}
</>
) : (
<p className="font-medium text-slate-800">Not configured</p>
)}
</div>
</div>
</div>
</Card>
{/* Stages */}
<Card variant="bordered" padding="md">
<h3 className="text-lg font-semibold text-slate-800 mb-4">Stages</h3>
{competition.stages && competition.stages.length > 0 ? (
<div className="space-y-3">
{competition.stages
.sort((a, b) => a.stage_order - b.stage_order)
.map((stage, index) => (
<StageCard key={stage.stage_id} stage={stage} index={index} />
))}
</div>
) : (
<div className="text-center py-8">
<p className="text-slate-500">No stages configured yet.</p>
<p className="text-sm text-slate-400 mt-1">
Stages will be created based on the competition type and configuration.
</p>
</div>
)}
</Card>
{/* Configuration Preview */}
{competition.config_snapshot && (
<Card variant="bordered" padding="md" className="lg:col-span-2">
<h3 className="text-lg font-semibold text-slate-800 mb-4">Configuration</h3>
<div className="grid gap-4 md:grid-cols-3">
{competition.config_snapshot.ops?.registration && (
<div className="p-4 bg-slate-50 rounded-xl">
<h4 className="font-medium text-slate-800 mb-2">Registration</h4>
<div className="space-y-1 text-sm text-slate-600">
<p>
Approval:{' '}
{competition.config_snapshot.ops.registration.requires_approval
? 'Required'
: 'Auto-approve'}
</p>
{competition.config_snapshot.ops.registration.max_participants && (
<p>
Max participants: {competition.config_snapshot.ops.registration.max_participants}
</p>
)}
<p>
Waitlist:{' '}
{competition.config_snapshot.ops.registration.waitlist_enabled
? 'Enabled'
: 'Disabled'}
</p>
</div>
</div>
)}
{competition.config_snapshot.rating && (
<div className="p-4 bg-slate-50 rounded-xl">
<h4 className="font-medium text-slate-800 mb-2">Rating</h4>
<div className="space-y-1 text-sm text-slate-600">
<p>
Affects rating:{' '}
{competition.config_snapshot.rating.affects_rating ? 'Yes' : 'No'}
</p>
{competition.config_snapshot.rating.rating_weight && (
<p>Weight: {competition.config_snapshot.rating.rating_weight}x</p>
)}
</div>
</div>
)}
{competition.config_snapshot.format && (
<div className="p-4 bg-slate-50 rounded-xl">
<h4 className="font-medium text-slate-800 mb-2">Format</h4>
<div className="space-y-1 text-sm text-slate-600">
{competition.config_snapshot.format.participant_type && (
<p className="capitalize">
Type: {competition.config_snapshot.format.participant_type}
</p>
)}
{competition.config_snapshot.format.team_size && (
<p>
Team size: {competition.config_snapshot.format.team_size.min}-
{competition.config_snapshot.format.team_size.max}
</p>
)}
</div>
</div>
)}
</div>
</Card>
)}
</div>
);
}

@ -0,0 +1,203 @@
'use client';
import React, { useState } from 'react';
import { Plus, Users, UserMinus, Hash, AlertCircle, Loader2 } from 'lucide-react';
import Card from '@/src/components/cards/Card';
import { useCompetitionParticipants } from '@/src/hooks/queries/useCompetitionQueries';
import {
useSetParticipantSeed,
useWithdrawParticipant,
} from '@/src/hooks/mutations/useCompetitionMutations';
import type { CompetitionParticipant, ParticipantStatus } from '@/src/types/competition';
interface ParticipantsTabProps {
competitionId: number;
}
const statusColors: Record<ParticipantStatus, string> = {
active: 'bg-emerald-100 text-emerald-700',
withdrawn: 'bg-slate-100 text-slate-500',
eliminated: 'bg-red-100 text-red-700',
};
function ParticipantRow({
participant,
competitionId,
}: {
participant: CompetitionParticipant;
competitionId: number;
}) {
const [isEditingSeed, setIsEditingSeed] = useState(false);
const [seedValue, setSeedValue] = useState(participant.seed?.toString() ?? '');
const setSeedMutation = useSetParticipantSeed(competitionId);
const withdrawMutation = useWithdrawParticipant(competitionId);
function handleSeedSave() {
const seed = parseInt(seedValue, 10);
if (!isNaN(seed) && seed > 0) {
setSeedMutation.mutate(
{ participantId: participant.participant_id, seed },
{ onSuccess: () => setIsEditingSeed(false) }
);
}
}
return (
<div className="flex items-center gap-4 p-4 bg-white border border-slate-200 rounded-xl">
{/* Seed */}
<div className="w-12 flex-shrink-0">
{isEditingSeed ? (
<div className="flex items-center gap-1">
<input
type="number"
value={seedValue}
onChange={(e) => setSeedValue(e.target.value)}
className="w-12 px-2 py-1 text-center text-sm border border-slate-200 rounded focus:outline-none focus:ring-1 focus:ring-purple-500"
min="1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSeedSave();
if (e.key === 'Escape') setIsEditingSeed(false);
}}
onBlur={handleSeedSave}
/>
</div>
) : (
<button
onClick={() => setIsEditingSeed(true)}
className="w-10 h-10 flex items-center justify-center bg-slate-100 rounded-lg text-slate-600 font-medium hover:bg-slate-200 transition-colors"
title="Click to set seed"
>
{participant.seed ?? <Hash className="w-4 h-4" />}
</button>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-slate-800 truncate">
{participant.display_name}
</h4>
<span
className={`text-xs px-2 py-0.5 rounded-full ${statusColors[participant.status]}`}
>
{participant.status}
</span>
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-slate-600">
{participant.members && participant.members.length > 0 && (
<span>
{participant.members.map((m) => m.display_name).join(', ')}
</span>
)}
{participant.group_name && (
<>
<span className="text-slate-400"></span>
<span>Group {participant.group_name}</span>
</>
)}
</div>
</div>
{/* Actions */}
{participant.status === 'active' && (
<button
onClick={() => withdrawMutation.mutate(participant.participant_id)}
disabled={withdrawMutation.isPending}
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
title="Withdraw participant"
>
{withdrawMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<UserMinus className="w-5 h-5" />
)}
</button>
)}
</div>
);
}
function ParticipantSkeleton() {
return (
<div className="flex items-center gap-4 p-4 bg-white border border-slate-200 rounded-xl">
<div className="w-10 h-10 bg-slate-200 rounded-lg animate-pulse" />
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<div className="h-5 w-32 bg-slate-200 rounded animate-pulse" />
<div className="h-5 w-16 bg-slate-200 rounded-full animate-pulse" />
</div>
<div className="h-4 w-48 bg-slate-200 rounded animate-pulse" />
</div>
</div>
);
}
export default function ParticipantsTab({ competitionId }: ParticipantsTabProps) {
const [statusFilter, setStatusFilter] = useState<ParticipantStatus | 'all'>('all');
const { data: participants, isLoading, error } = useCompetitionParticipants(competitionId, {
status: statusFilter === 'all' ? undefined : statusFilter,
});
const activeCount = participants?.filter((p) => p.status === 'active').length ?? 0;
return (
<div>
{/* Header */}
<div className="flex items-center justify-between gap-4 mb-6">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as ParticipantStatus | 'all')}
className="px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="all">All ({participants?.length ?? 0})</option>
<option value="active">Active ({activeCount})</option>
<option value="withdrawn">Withdrawn</option>
<option value="eliminated">Eliminated</option>
</select>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl mb-6">
<AlertCircle className="w-5 h-5 text-red-600" />
<p className="text-red-700">Failed to load participants</p>
</div>
)}
{/* List */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<ParticipantSkeleton key={i} />
))}
</div>
) : participants && participants.length > 0 ? (
<div className="space-y-3">
{participants
.sort((a, b) => (a.seed ?? 999) - (b.seed ?? 999))
.map((participant) => (
<ParticipantRow
key={participant.participant_id}
participant={participant}
competitionId={competitionId}
/>
))}
</div>
) : (
<Card variant="bordered" padding="lg">
<div className="text-center py-8">
<Users className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="font-semibold text-slate-800 mb-1">No participants yet</h3>
<p className="text-slate-500">
Approve registrations to add participants to this competition.
</p>
</div>
</Card>
)}
</div>
);
}

@ -0,0 +1,224 @@
'use client';
import React, { useState } from 'react';
import { Check, X, Clock, UserCheck, UserX, Loader2, AlertCircle } from 'lucide-react';
import Card from '@/src/components/cards/Card';
import { useCompetitionRegistrations } from '@/src/hooks/queries/useCompetitionQueries';
import {
useApproveRegistration,
useRejectRegistration,
} from '@/src/hooks/mutations/useCompetitionMutations';
import type { CompetitionRegistration, RegistrationStatus } from '@/src/types/competition';
interface RegistrationsTabProps {
competitionId: number;
}
const statusConfig: Record<RegistrationStatus, {
label: string;
bgColor: string;
textColor: string;
Icon: React.ComponentType<{ className?: string }>;
}> = {
pending: {
label: 'Pending',
bgColor: 'bg-amber-100',
textColor: 'text-amber-700',
Icon: Clock,
},
approved: {
label: 'Approved',
bgColor: 'bg-emerald-100',
textColor: 'text-emerald-700',
Icon: UserCheck,
},
rejected: {
label: 'Rejected',
bgColor: 'bg-red-100',
textColor: 'text-red-700',
Icon: UserX,
},
waitlisted: {
label: 'Waitlisted',
bgColor: 'bg-slate-100',
textColor: 'text-slate-700',
Icon: Clock,
},
withdrawn: {
label: 'Withdrawn',
bgColor: 'bg-gray-100',
textColor: 'text-gray-700',
Icon: X,
},
};
function RegistrationRow({
registration,
competitionId,
}: {
registration: CompetitionRegistration;
competitionId: number;
}) {
const approveMutation = useApproveRegistration(competitionId);
const rejectMutation = useRejectRegistration(competitionId);
const isPending = approveMutation.isPending || rejectMutation.isPending;
const config = statusConfig[registration.status];
const StatusIcon = config.Icon;
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
return (
<div className="flex items-center gap-4 p-4 bg-white border border-slate-200 rounded-xl">
{/* User Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-slate-800 truncate">
{registration.team_name || registration.display_name}
</h4>
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${config.bgColor} ${config.textColor}`}>
<StatusIcon className="w-3 h-3" />
{config.label}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-slate-600">
{registration.members && registration.members.length > 0 && (
<span>
{registration.members.map((m) => m.display_name).join(', ')}
</span>
)}
<span className="text-slate-400"></span>
<span>{formatDate(registration.created_at)}</span>
</div>
{registration.notes && (
<p className="text-sm text-slate-500 mt-1 truncate">
{registration.notes}
</p>
)}
</div>
{/* Actions */}
{registration.status === 'pending' && (
<div className="flex items-center gap-2">
<button
onClick={() => approveMutation.mutate(registration.registration_id)}
disabled={isPending}
className="p-2 bg-emerald-100 text-emerald-700 rounded-lg hover:bg-emerald-200 transition-colors disabled:opacity-50"
title="Approve"
>
{approveMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Check className="w-5 h-5" />
)}
</button>
<button
onClick={() => rejectMutation.mutate(registration.registration_id)}
disabled={isPending}
className="p-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors disabled:opacity-50"
title="Reject"
>
{rejectMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<X className="w-5 h-5" />
)}
</button>
</div>
)}
</div>
);
}
function RegistrationSkeleton() {
return (
<div className="flex items-center gap-4 p-4 bg-white border border-slate-200 rounded-xl">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<div className="h-5 w-32 bg-slate-200 rounded animate-pulse" />
<div className="h-5 w-16 bg-slate-200 rounded-full animate-pulse" />
</div>
<div className="h-4 w-48 bg-slate-200 rounded animate-pulse" />
</div>
<div className="flex gap-2">
<div className="w-9 h-9 bg-slate-200 rounded-lg animate-pulse" />
<div className="w-9 h-9 bg-slate-200 rounded-lg animate-pulse" />
</div>
</div>
);
}
export default function RegistrationsTab({ competitionId }: RegistrationsTabProps) {
const [statusFilter, setStatusFilter] = useState<RegistrationStatus | 'all'>('all');
const { data: registrations, isLoading, error } = useCompetitionRegistrations(competitionId, {
status: statusFilter === 'all' ? undefined : statusFilter,
});
const pendingCount = registrations?.filter((r) => r.status === 'pending').length ?? 0;
return (
<div>
{/* Filter */}
<div className="flex items-center gap-4 mb-6">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as RegistrationStatus | 'all')}
className="px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="all">All Statuses</option>
<option value="pending">Pending ({pendingCount})</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="waitlisted">Waitlisted</option>
<option value="withdrawn">Withdrawn</option>
</select>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl mb-6">
<AlertCircle className="w-5 h-5 text-red-600" />
<p className="text-red-700">Failed to load registrations</p>
</div>
)}
{/* List */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<RegistrationSkeleton key={i} />
))}
</div>
) : registrations && registrations.length > 0 ? (
<div className="space-y-3">
{registrations.map((registration) => (
<RegistrationRow
key={registration.registration_id}
registration={registration}
competitionId={competitionId}
/>
))}
</div>
) : (
<Card variant="bordered" padding="lg">
<div className="text-center py-8">
<Clock className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="font-semibold text-slate-800 mb-1">No registrations yet</h3>
<p className="text-slate-500">
Registrations will appear here when players sign up.
</p>
</div>
</Card>
)}
</div>
);
}

@ -0,0 +1,317 @@
'use client';
import React, { useState } from 'react';
import { RefreshCw, ListOrdered, AlertCircle, Loader2, Trophy } from 'lucide-react';
import Card from '@/src/components/cards/Card';
import {
useCompetitionStandings,
useCompetitionLeaderboard,
} from '@/src/hooks/queries/useCompetitionQueries';
import {
useRecalculateStandings,
useRecalculateLeaderboard,
} from '@/src/hooks/mutations/useCompetitionMutations';
import type { Competition, CompetitionStage, CompetitionStanding, LeaderboardEntry, LeaderboardStats } from '@/src/types/competition';
interface StandingsTabProps {
competition: Competition;
}
function StandingsTable({
standings,
competitionId,
stageId,
}: {
standings: CompetitionStanding[];
competitionId: number;
stageId: number;
}) {
const recalculateMutation = useRecalculateStandings(competitionId, stageId);
return (
<div>
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-slate-700">Standings</h4>
<button
onClick={() => recalculateMutation.mutate(undefined)}
disabled={recalculateMutation.isPending}
className="inline-flex items-center px-3 py-1.5 text-sm bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition-colors disabled:opacity-50"
>
{recalculateMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-1.5" />
)}
Recalculate
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left text-xs font-semibold text-slate-500 uppercase py-3 px-4">#</th>
<th className="text-left text-xs font-semibold text-slate-500 uppercase py-3 px-4">Team</th>
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4">P</th>
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4">W</th>
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4">D</th>
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4">L</th>
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4">GD</th>
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4 bg-purple-50">Pts</th>
</tr>
</thead>
<tbody>
{standings
.sort((a, b) => a.rank - b.rank)
.map((standing, index) => (
<tr
key={standing.standing_id}
className={`border-b border-slate-100 ${
index < 2 ? 'bg-emerald-50/50' : ''
}`}
>
<td className="py-3 px-4">
<span className={`font-semibold ${index < 2 ? 'text-emerald-600' : 'text-slate-600'}`}>
{standing.rank}
</span>
</td>
<td className="py-3 px-4 font-medium text-slate-800">
{standing.display_name}
</td>
<td className="py-3 px-4 text-center text-slate-600">{standing.played}</td>
<td className="py-3 px-4 text-center text-emerald-600">{standing.won}</td>
<td className="py-3 px-4 text-center text-amber-600">{standing.drawn}</td>
<td className="py-3 px-4 text-center text-red-600">{standing.lost}</td>
<td className="py-3 px-4 text-center text-slate-600">
{standing.goal_difference > 0 ? '+' : ''}
{standing.goal_difference}
</td>
<td className="py-3 px-4 text-center font-bold text-purple-700 bg-purple-50">
{standing.points}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function LeaderboardTable({
leaderboardData,
competitionId,
stageId,
}: {
leaderboardData: { leaderboard: LeaderboardEntry[]; stats: LeaderboardStats };
competitionId: number;
stageId: number;
}) {
const recalculateMutation = useRecalculateLeaderboard(competitionId, stageId);
const entries = leaderboardData.leaderboard;
return (
<div>
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-slate-700">Leaderboard</h4>
<button
onClick={() => recalculateMutation.mutate(undefined)}
disabled={recalculateMutation.isPending}
className="inline-flex items-center px-3 py-1.5 text-sm bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition-colors disabled:opacity-50"
>
{recalculateMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-1.5" />
)}
Recalculate
</button>
</div>
<div className="space-y-2">
{entries
.sort((a, b) => a.rank - b.rank)
.map((entry, index) => (
<div
key={entry.entry_id}
className={`flex items-center gap-4 p-4 rounded-xl ${
index === 0
? 'bg-gradient-to-r from-amber-100 to-amber-50 border border-amber-200'
: index === 1
? 'bg-gradient-to-r from-slate-200 to-slate-100 border border-slate-300'
: index === 2
? 'bg-gradient-to-r from-orange-100 to-orange-50 border border-orange-200'
: 'bg-white border border-slate-200'
}`}
>
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-bold ${
index === 0
? 'bg-amber-500 text-white'
: index === 1
? 'bg-slate-400 text-white'
: index === 2
? 'bg-orange-400 text-white'
: 'bg-slate-100 text-slate-600'
}`}
>
{entry.rank}
</div>
<div className="flex-1">
<h5 className="font-semibold text-slate-800">{entry.display_name}</h5>
<p className="text-sm text-slate-500">
{entry.matches_counted} matches counted
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-purple-600">
{entry.metric_value > 0 ? '+' : ''}
{entry.metric_value.toFixed(1)}
</div>
<p className="text-xs text-slate-500">Rating Delta</p>
</div>
</div>
))}
</div>
</div>
);
}
function StageStandings({
stage,
competitionId,
}: {
stage: CompetitionStage;
competitionId: number;
}) {
const isChallenge = stage.stage_type === 'rating_delta_challenge';
const {
data: standings,
isLoading: standingsLoading,
error: standingsError,
} = useCompetitionStandings(competitionId, stage.stage_id, undefined);
const {
data: leaderboard,
isLoading: leaderboardLoading,
error: leaderboardError,
} = useCompetitionLeaderboard(competitionId, stage.stage_id);
const isLoading = isChallenge ? leaderboardLoading : standingsLoading;
const error = isChallenge ? leaderboardError : standingsError;
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl">
<AlertCircle className="w-5 h-5 text-red-600" />
<p className="text-red-700">Failed to load standings</p>
</div>
);
}
if (isChallenge) {
if (!leaderboard || leaderboard.leaderboard.length === 0) {
return (
<div className="text-center py-12">
<Trophy className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h4 className="font-semibold text-slate-800 mb-2">No leaderboard data</h4>
<p className="text-slate-500">
Leaderboard will populate as matches are completed.
</p>
</div>
);
}
return (
<LeaderboardTable
leaderboardData={leaderboard}
competitionId={competitionId}
stageId={stage.stage_id}
/>
);
}
if (!standings || standings.length === 0) {
return (
<div className="text-center py-12">
<ListOrdered className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h4 className="font-semibold text-slate-800 mb-2">No standings yet</h4>
<p className="text-slate-500">
Standings will populate as matches are completed.
</p>
</div>
);
}
return (
<StandingsTable
standings={standings}
competitionId={competitionId}
stageId={stage.stage_id}
/>
);
}
export default function StandingsTab({ competition }: StandingsTabProps) {
const stages = competition.stages ?? [];
const [selectedStageId, setSelectedStageId] = useState<number | null>(
stages[0]?.stage_id ?? null
);
const selectedStage = stages.find((s) => s.stage_id === selectedStageId);
if (stages.length === 0) {
return (
<Card variant="bordered" padding="lg">
<div className="text-center py-8">
<ListOrdered className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="font-semibold text-slate-800 mb-1">No stages configured</h3>
<p className="text-slate-500">
Configure stages in the competition settings to view standings.
</p>
</div>
</Card>
);
}
return (
<div>
{/* Stage Selector */}
{stages.length > 1 && (
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
{stages
.sort((a, b) => a.stage_order - b.stage_order)
.map((stage) => (
<button
key={stage.stage_id}
onClick={() => setSelectedStageId(stage.stage_id)}
className={`px-4 py-2 rounded-xl font-medium whitespace-nowrap transition-colors ${
selectedStageId === stage.stage_id
? 'bg-purple-100 text-purple-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{stage.name}
</button>
))}
</div>
)}
{/* Standings */}
{selectedStage && (
<StageStandings
stage={selectedStage}
competitionId={competition.competition_id}
/>
)}
</div>
);
}

@ -0,0 +1,12 @@
import CompetitionsPageComponent from './CompetitionsPageComponent';
export default async function CompetitionsPage({
params,
}: {
params: Promise<{ club_id: string }>;
}) {
const { club_id } = await params;
const clubId = parseInt(club_id, 10);
return <CompetitionsPageComponent clubId={clubId} />;
}

@ -0,0 +1,265 @@
'use client';
import React, { useState, useEffect } from 'react';
import { 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 { useCreateTemplate } from '@/src/hooks/mutations/useCompetitionMutations';
import type { CompetitionType, CreateTemplateRequest, StageType } from '@/src/types/competition';
interface CreateTemplateModalProps {
isOpen: boolean;
onClose: () => void;
facilityId: number;
}
const competitionTypes: {
type: CompetitionType;
label: string;
description: string;
Icon: React.ComponentType<{ className?: string }>;
defaultStageType: StageType;
}[] = [
{
type: 'league',
label: 'League',
description: 'Round-robin format with standings',
Icon: Trophy,
defaultStageType: 'round_robin',
},
{
type: 'tournament',
label: 'Tournament',
description: 'Knockout bracket format',
Icon: Target,
defaultStageType: 'knockout_single',
},
{
type: 'challenge',
label: 'Challenge',
description: 'Rating-based leaderboard',
Icon: Zap,
defaultStageType: 'rating_delta_challenge',
},
{
type: 'hybrid',
label: 'Hybrid',
description: 'Multiple stages combined',
Icon: Layers,
defaultStageType: 'group_stage',
},
];
export default function CreateTemplateModal({
isOpen,
onClose,
facilityId,
}: CreateTemplateModalProps) {
const [formData, setFormData] = useState({
type: 'tournament' as CompetitionType,
name: '',
description: '',
sport_id: 1, // Default to padel
});
const [formError, setFormError] = useState<string | null>(null);
const createMutation = useCreateTemplate(facilityId);
// Reset form when modal closes
useEffect(() => {
if (!isOpen) {
setFormData({
type: 'tournament',
name: '',
description: '',
sport_id: 1,
});
setFormError(null);
}
}, [isOpen]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setFormError(null);
if (!formData.name.trim()) {
setFormError('Name is required');
return;
}
const selectedType = competitionTypes.find((t) => t.type === formData.type);
if (!selectedType) {
setFormError('Invalid competition type');
return;
}
const request: CreateTemplateRequest = {
type: formData.type,
scope: 'facility',
sport_id: formData.sport_id,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
config: {
format: {
stages: [
{
legs: formData.type === 'league' ? 2 : undefined,
points_win: formData.type === 'league' ? 3 : undefined,
points_draw: formData.type === 'league' ? 1 : undefined,
points_loss: formData.type === 'league' ? 0 : undefined,
third_place_match: formData.type === 'tournament' ? false : undefined,
leaderboard_metric:
formData.type === 'challenge' ? 'rating_delta_sum' : undefined,
min_matches: formData.type === 'challenge' ? 3 : undefined,
},
],
},
ops: {
registration: {
requires_approval: true,
waitlist_enabled: true,
},
},
rating: {
affects_rating: true,
rating_weight: 1.0,
},
},
};
try {
await createMutation.mutateAsync(request);
onClose();
} catch (err) {
setFormError(err instanceof Error ? err.message : 'Failed to create template');
}
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalHeader
title="Create Template"
subtitle="Create a reusable competition configuration"
onClose={onClose}
/>
<form onSubmit={handleSubmit}>
<ModalBody padding="md">
<div className="space-y-5">
{formError && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-xl">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
<p className="text-sm text-red-700">{formError}</p>
</div>
)}
{/* Competition Type */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Type <span className="text-red-500">*</span>
</label>
<div className="grid grid-cols-2 gap-3">
{competitionTypes.map(({ type, label, description, Icon }) => (
<button
key={type}
type="button"
onClick={() => setFormData((prev) => ({ ...prev, type }))}
className={`flex items-center gap-3 p-4 border rounded-xl text-left transition-colors ${
formData.type === type
? 'border-purple-500 bg-purple-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
formData.type === type
? 'bg-purple-100'
: 'bg-slate-100'
}`}
>
<Icon
className={`w-5 h-5 ${
formData.type === type
? 'text-purple-600'
: 'text-slate-500'
}`}
/>
</div>
<div>
<div
className={`font-semibold ${
formData.type === type
? 'text-purple-700'
: 'text-slate-800'
}`}
>
{label}
</div>
<div className="text-xs text-slate-500">{description}</div>
</div>
</button>
))}
</div>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="e.g., Standard Tournament (8 players)"
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"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Description
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({ ...prev, description: e.target.value }))
}
placeholder="Describe the template configuration..."
rows={3}
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 resize-none"
/>
</div>
</div>
</ModalBody>
<ModalFooter align="right">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 bg-slate-100 text-slate-700 font-semibold rounded-xl hover:bg-slate-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={createMutation.isPending}
className="px-6 py-2.5 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{createMutation.isPending ? (
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Creating...
</span>
) : (
'Create Template'
)}
</button>
</ModalFooter>
</form>
</Modal>
);
}

@ -0,0 +1,236 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { ArrowLeft, Plus, Trophy, Target, Zap, Layers, MoreVertical, Pencil, Trash2, Eye, EyeOff } from 'lucide-react';
import useTranslation from '@/src/hooks/useTranslation';
import Card from '@/src/components/cards/Card';
import { useCompetitionTemplates } from '@/src/hooks/queries/useCompetitionQueries';
import { useUpdateTemplate } from '@/src/hooks/mutations/useCompetitionMutations';
import CreateTemplateModal from './CreateTemplateModal';
import type { CompetitionTemplate, CompetitionType } from '@/src/types/competition';
interface TemplatesPageComponentProps {
clubId: number;
}
const typeIcons: Record<CompetitionType, React.ComponentType<{ className?: string }>> = {
league: Trophy,
tournament: Target,
challenge: Zap,
hybrid: Layers,
};
const typeColors: Record<CompetitionType, string> = {
league: 'bg-amber-100 text-amber-700',
tournament: 'bg-purple-100 text-purple-700',
challenge: 'bg-pink-100 text-pink-700',
hybrid: 'bg-indigo-100 text-indigo-700',
};
function TemplateCard({
template,
onToggleActive,
}: {
template: CompetitionTemplate;
onToggleActive: () => void;
}) {
const [showMenu, setShowMenu] = useState(false);
const Icon = typeIcons[template.type];
return (
<Card variant="bordered" padding="none" className="overflow-visible">
<div className="p-5">
<div className="flex items-start justify-between gap-3 mb-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${typeColors[template.type]}`}>
<Icon className="w-5 h-5" />
</div>
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<MoreVertical className="w-4 h-4 text-slate-500" />
</button>
{showMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowMenu(false)}
/>
<div className="absolute right-0 top-full mt-1 w-48 bg-white border border-slate-200 rounded-xl shadow-lg z-20 py-1">
<button
onClick={() => {
setShowMenu(false);
// TODO: Open edit modal
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50"
>
<Pencil className="w-4 h-4" />
Edit Template
</button>
<button
onClick={() => {
setShowMenu(false);
onToggleActive();
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50"
>
{template.is_active ? (
<>
<EyeOff className="w-4 h-4" />
Deactivate
</>
) : (
<>
<Eye className="w-4 h-4" />
Activate
</>
)}
</button>
</div>
</>
)}
</div>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-1">
{template.name}
</h3>
{template.description && (
<p className="text-sm text-slate-600 mb-3 line-clamp-2">
{template.description}
</p>
)}
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs px-2 py-1 rounded-full capitalize ${typeColors[template.type]}`}>
{template.type}
</span>
{!template.is_active && (
<span className="text-xs px-2 py-1 rounded-full bg-slate-100 text-slate-500">
Inactive
</span>
)}
{template.scope === 'platform' && (
<span className="text-xs px-2 py-1 rounded-full bg-indigo-100 text-indigo-700">
Platform
</span>
)}
</div>
</div>
</Card>
);
}
function TemplateCardSkeleton() {
return (
<Card variant="bordered" padding="none">
<div className="p-5">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="w-10 h-10 bg-slate-200 rounded-lg animate-pulse" />
<div className="w-8 h-8 bg-slate-200 rounded-lg animate-pulse" />
</div>
<div className="h-6 w-3/4 bg-slate-200 rounded mb-2 animate-pulse" />
<div className="h-4 w-full bg-slate-200 rounded mb-3 animate-pulse" />
<div className="flex gap-2">
<div className="h-5 w-16 bg-slate-200 rounded-full animate-pulse" />
</div>
</div>
</Card>
);
}
export default function TemplatesPageComponent({ clubId }: TemplatesPageComponentProps) {
const { locale } = useTranslation();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { data: templates, isLoading, error } = useCompetitionTemplates(clubId);
const updateMutation = useUpdateTemplate();
function handleToggleActive(template: CompetitionTemplate) {
updateMutation.mutate({
templateId: template.template_id,
data: { is_active: !template.is_active },
});
}
return (
<div>
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<Link
href={`/${locale}/admin/clubs/${clubId}/competitions`}
className="inline-flex items-center text-slate-600 hover:text-slate-900 font-medium transition-colors mb-2"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Competitions
</Link>
<h2 className="text-2xl font-bold text-slate-900">Competition Templates</h2>
<p className="text-slate-600 mt-1">
Reusable configurations for creating competitions
</p>
</div>
<button
onClick={() => setIsCreateModalOpen(true)}
className="inline-flex items-center px-4 py-2.5 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all"
>
<Plus className="w-5 h-5 mr-2" />
New Template
</button>
</div>
{/* Error State */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
<p className="text-red-700 font-medium">
Failed to load templates. Please try again.
</p>
</div>
)}
{/* Templates Grid */}
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<TemplateCardSkeleton key={i} />
))}
</div>
) : templates && templates.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{templates.map((template) => (
<TemplateCard
key={template.template_id}
template={template}
onToggleActive={() => handleToggleActive(template)}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
<Trophy className="w-8 h-8 text-slate-400" />
</div>
<p className="text-slate-600 text-center mb-4">
No templates yet. Create your first template to get started.
</p>
<button
onClick={() => setIsCreateModalOpen(true)}
className="inline-flex items-center px-4 py-2.5 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all"
>
<Plus className="w-5 h-5 mr-2" />
Create Template
</button>
</div>
)}
{/* Create Template Modal */}
<CreateTemplateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
facilityId={clubId}
/>
</div>
);
}

@ -0,0 +1,12 @@
import TemplatesPageComponent from './TemplatesPageComponent';
export default async function TemplatesPage({
params,
}: {
params: Promise<{ club_id: string }>;
}) {
const { club_id } = await params;
const clubId = parseInt(club_id, 10);
return <TemplatesPageComponent clubId={clubId} />;
}

@ -27,6 +27,7 @@ export default function ClubTabNavigation({ clubId }: ClubTabNavigationProps) {
{ key: 'slot-instances', label: 'Slot Instances', href: `${basePath}/slot-instances` },
{ key: 'plans', label: 'Plans', href: `${basePath}/plans` },
{ key: 'members', label: 'Members', href: `${basePath}/members` },
{ key: 'competitions', label: 'Competitions', href: `${basePath}/competitions` },
{ key: 'settings', label: 'Settings', href: `${basePath}/settings` },
{ key: 'credits', label: 'Credits', href: `${basePath}/credits` },
{ key: 'transfers', label: 'Transfers', href: `${basePath}/transfers` },

@ -0,0 +1,105 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Calendar, Users, MapPin, ChevronRight } from 'lucide-react';
import Card from '@/src/components/cards/Card';
import CompetitionStatusBadge from './CompetitionStatusBadge';
import CompetitionTypeBadge from './CompetitionTypeBadge';
import type { Competition } from '@/src/types/competition';
interface CompetitionCardProps {
competition: Competition;
facilityId: number;
className?: string;
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function formatDateRange(startDate: string, endDate: string | null): string {
const start = formatDate(startDate);
if (!endDate) return start;
const end = formatDate(endDate);
return `${start} - ${end}`;
}
export default function CompetitionCard({
competition,
facilityId,
className = '',
}: CompetitionCardProps) {
const href = `/facility/${facilityId}/competitions/${competition.competition_id}`;
return (
<Link href={href} className={`block group ${className}`}>
<Card
variant="bordered"
padding="none"
hover
className="overflow-hidden"
>
<div className="p-5">
{/* Header with badges */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2 flex-wrap">
<CompetitionTypeBadge type={competition.type} size="xs" />
<CompetitionStatusBadge status={competition.status} size="xs" />
</div>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-purple-600 transition-colors flex-shrink-0" />
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-slate-800 mb-2 group-hover:text-purple-700 transition-colors line-clamp-2">
{competition.title}
</h3>
{/* Description */}
{competition.description && (
<p className="text-sm text-slate-600 mb-4 line-clamp-2">
{competition.description}
</p>
)}
{/* Meta info */}
<div className="flex flex-wrap gap-4 text-sm text-slate-500">
<div className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
<span>{formatDateRange(competition.starts_at, competition.ends_at)}</span>
</div>
{competition.participant_count !== undefined && (
<div className="flex items-center gap-1.5">
<Users className="w-4 h-4" />
<span>{competition.participant_count} participants</span>
</div>
)}
{competition.scope !== 'facility' && (
<div className="flex items-center gap-1.5">
<MapPin className="w-4 h-4" />
<span className="capitalize">{competition.scope.replace('_', ' ')}</span>
</div>
)}
</div>
</div>
{/* Registration status bar for published competitions */}
{competition.status === 'published' && competition.registration_count !== undefined && (
<div className="px-5 py-3 bg-slate-50 border-t border-slate-100">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">Registrations</span>
<span className="font-medium text-slate-800">
{competition.registration_count} pending
</span>
</div>
</div>
)}
</Card>
</Link>
);
}

@ -0,0 +1,56 @@
import React from 'react';
import Card from '@/src/components/cards/Card';
interface CompetitionCardSkeletonProps {
showRegistrationBar?: boolean;
}
export default function CompetitionCardSkeleton({
showRegistrationBar = false,
}: CompetitionCardSkeletonProps) {
return (
<Card variant="bordered" padding="none" className="overflow-hidden">
<div className="p-5">
{/* Header with badges */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2">
<div className="h-6 w-20 bg-slate-200 rounded-full animate-pulse" />
<div className="h-6 w-16 bg-slate-200 rounded-full animate-pulse" />
</div>
<div className="w-5 h-5 bg-slate-200 rounded animate-pulse" />
</div>
{/* Title */}
<div className="h-6 w-3/4 bg-slate-200 rounded-lg mb-2 animate-pulse" />
{/* Description */}
<div className="space-y-1 mb-4">
<div className="h-4 w-full bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-2/3 bg-slate-200 rounded animate-pulse" />
</div>
{/* Meta info */}
<div className="flex gap-4">
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-24 bg-slate-200 rounded animate-pulse" />
</div>
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-20 bg-slate-200 rounded animate-pulse" />
</div>
</div>
</div>
{/* Registration status bar */}
{showRegistrationBar && (
<div className="px-5 py-3 bg-slate-50 border-t border-slate-100">
<div className="flex items-center justify-between">
<div className="h-4 w-20 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-16 bg-slate-200 rounded animate-pulse" />
</div>
</div>
)}
</Card>
);
}

@ -0,0 +1,56 @@
'use client';
import React from 'react';
import { Trophy } from 'lucide-react';
import CompetitionCard from './CompetitionCard';
import CompetitionCardSkeleton from './CompetitionCardSkeleton';
import type { Competition } from '@/src/types/competition';
interface CompetitionListProps {
competitions: Competition[];
facilityId: number;
isLoading?: boolean;
emptyMessage?: string;
className?: string;
}
export default function CompetitionList({
competitions,
facilityId,
isLoading = false,
emptyMessage = 'No competitions found',
className = '',
}: CompetitionListProps) {
if (isLoading) {
return (
<div className={`grid gap-4 md:grid-cols-2 lg:grid-cols-3 ${className}`}>
{[1, 2, 3, 4, 5, 6].map((i) => (
<CompetitionCardSkeleton key={i} showRegistrationBar={i % 2 === 0} />
))}
</div>
);
}
if (competitions.length === 0) {
return (
<div className={`flex flex-col items-center justify-center py-16 ${className}`}>
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
<Trophy className="w-8 h-8 text-slate-400" />
</div>
<p className="text-slate-600 text-center">{emptyMessage}</p>
</div>
);
}
return (
<div className={`grid gap-4 md:grid-cols-2 lg:grid-cols-3 ${className}`}>
{competitions.map((competition) => (
<CompetitionCard
key={competition.competition_id}
competition={competition}
facilityId={facilityId}
/>
))}
</div>
);
}

@ -0,0 +1,97 @@
import React from 'react';
import type { CompetitionStatus } from '@/src/types/competition';
interface CompetitionStatusBadgeProps {
status: CompetitionStatus;
size?: 'xs' | 'sm' | 'md';
showDot?: boolean;
className?: string;
}
const statusConfig: Record<CompetitionStatus, {
label: string;
bgColor: string;
textColor: string;
dotColor: string;
}> = {
draft: {
label: 'Draft',
bgColor: 'bg-slate-100',
textColor: 'text-slate-700',
dotColor: 'bg-slate-400',
},
published: {
label: 'Published',
bgColor: 'bg-indigo-50',
textColor: 'text-indigo-700',
dotColor: 'bg-indigo-500',
},
running: {
label: 'Running',
bgColor: 'bg-emerald-50',
textColor: 'text-emerald-700',
dotColor: 'bg-emerald-500',
},
finished: {
label: 'Finished',
bgColor: 'bg-purple-50',
textColor: 'text-purple-700',
dotColor: 'bg-purple-500',
},
cancelled: {
label: 'Cancelled',
bgColor: 'bg-red-50',
textColor: 'text-red-700',
dotColor: 'bg-red-500',
},
};
const sizeClasses = {
xs: {
padding: 'px-2 py-0.5',
text: 'text-xs',
dot: 'w-1.5 h-1.5',
},
sm: {
padding: 'px-2.5 py-1',
text: 'text-sm',
dot: 'w-2 h-2',
},
md: {
padding: 'px-3 py-1.5',
text: 'text-sm',
dot: 'w-2 h-2',
},
};
export default function CompetitionStatusBadge({
status,
size = 'sm',
showDot = true,
className = '',
}: CompetitionStatusBadgeProps) {
const config = statusConfig[status];
const sizes = sizeClasses[size];
return (
<span
className={`
inline-flex items-center gap-1.5
${sizes.padding}
${config.bgColor}
${config.textColor}
rounded-full
${sizes.text}
font-medium
${className}
`}
>
{showDot && (
<span
className={`${sizes.dot} ${config.dotColor} rounded-full ${status === 'running' ? 'animate-pulse' : ''}`}
/>
)}
<span>{config.label}</span>
</span>
);
}

@ -0,0 +1,89 @@
import React from 'react';
import { Trophy, Target, Zap, Layers } from 'lucide-react';
import type { CompetitionType } from '@/src/types/competition';
interface CompetitionTypeBadgeProps {
type: CompetitionType;
size?: 'xs' | 'sm' | 'md';
showIcon?: boolean;
className?: string;
}
const typeConfig: Record<CompetitionType, {
label: string;
bgColor: string;
textColor: string;
Icon: React.ComponentType<{ className?: string }>;
}> = {
league: {
label: 'League',
bgColor: 'bg-amber-50',
textColor: 'text-amber-700',
Icon: Trophy,
},
tournament: {
label: 'Tournament',
bgColor: 'bg-purple-50',
textColor: 'text-purple-700',
Icon: Target,
},
challenge: {
label: 'Challenge',
bgColor: 'bg-pink-50',
textColor: 'text-pink-700',
Icon: Zap,
},
hybrid: {
label: 'Hybrid',
bgColor: 'bg-indigo-50',
textColor: 'text-indigo-700',
Icon: Layers,
},
};
const sizeClasses = {
xs: {
padding: 'px-2 py-0.5',
text: 'text-xs',
icon: 'w-3 h-3',
},
sm: {
padding: 'px-2.5 py-1',
text: 'text-sm',
icon: 'w-3.5 h-3.5',
},
md: {
padding: 'px-3 py-1.5',
text: 'text-sm',
icon: 'w-4 h-4',
},
};
export default function CompetitionTypeBadge({
type,
size = 'sm',
showIcon = true,
className = '',
}: CompetitionTypeBadgeProps) {
const config = typeConfig[type];
const sizes = sizeClasses[size];
const { Icon } = config;
return (
<span
className={`
inline-flex items-center gap-1.5
${sizes.padding}
${config.bgColor}
${config.textColor}
rounded-full
${sizes.text}
font-medium
${className}
`}
>
{showIcon && <Icon className={sizes.icon} />}
<span>{config.label}</span>
</span>
);
}

@ -0,0 +1,5 @@
export { default as CompetitionStatusBadge } from './CompetitionStatusBadge';
export { default as CompetitionTypeBadge } from './CompetitionTypeBadge';
export { default as CompetitionCard } from './CompetitionCard';
export { default as CompetitionCardSkeleton } from './CompetitionCardSkeleton';
export { default as CompetitionList } from './CompetitionList';

@ -0,0 +1,592 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
createTemplate,
updateTemplate,
createCompetition,
updateCompetition,
publishCompetition,
startCompetition,
finishCompetition,
cancelCompetition,
approveRegistration,
rejectRegistration,
createParticipant,
assignParticipantToGroup,
setParticipantSeed,
withdrawParticipant,
generateFixtures,
linkMatchToSlot,
unlinkMatchFromSlot,
setMatchWinner,
voidMatch,
recalculateStandings,
recalculateLeaderboard,
} from '@/src/lib/api/competition-admin';
import { competitionQueryKeys } from '@/src/hooks/queries/useCompetitionQueries';
import type {
CreateTemplateRequest,
UpdateTemplateRequest,
CreateCompetitionRequest,
UpdateCompetitionRequest,
CreateParticipantRequest,
GenerateFixturesRequest,
LinkSlotRequest,
SetWinnerRequest,
} from '@/src/types/competition';
// ============================================================================
// Template Mutations
// ============================================================================
/**
* Create a new competition template
*/
export function useCreateTemplate(facilityId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateTemplateRequest) => {
const result = await createTemplate(facilityId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.templates() });
},
});
}
/**
* Update an existing template
*/
export function useUpdateTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
templateId,
data,
}: {
templateId: number;
data: UpdateTemplateRequest;
}) => {
const result = await updateTemplate(templateId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.templates() });
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.templateDetail(variables.templateId),
});
},
});
}
// ============================================================================
// Competition Mutations
// ============================================================================
/**
* Create a new competition
*/
export function useCreateCompetition(facilityId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateCompetitionRequest) => {
const result = await createCompetition(facilityId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
},
});
}
/**
* Update an existing competition
*/
export function useUpdateCompetition(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: UpdateCompetitionRequest) => {
const result = await updateCompetition(competitionId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.detail(competitionId),
});
},
});
}
/**
* Publish a competition (draft -> published)
*/
export function usePublishCompetition(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const result = await publishCompetition(competitionId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.detail(competitionId),
});
},
});
}
/**
* Start a competition (published -> running)
*/
export function useStartCompetition(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const result = await startCompetition(competitionId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.detail(competitionId),
});
},
});
}
/**
* Finish a competition (running -> finished)
*/
export function useFinishCompetition(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const result = await finishCompetition(competitionId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.detail(competitionId),
});
},
});
}
/**
* Cancel a competition
*/
export function useCancelCompetition(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const result = await cancelCompetition(competitionId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.detail(competitionId),
});
},
});
}
// ============================================================================
// Registration Mutations
// ============================================================================
/**
* Approve a registration
*/
export function useApproveRegistration(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (registrationId: number) => {
const result = await approveRegistration(competitionId, registrationId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.registrations(competitionId),
});
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.participants(competitionId),
});
},
});
}
/**
* Reject a registration
*/
export function useRejectRegistration(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (registrationId: number) => {
const result = await rejectRegistration(competitionId, registrationId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.registrations(competitionId),
});
},
});
}
// ============================================================================
// Participant Mutations
// ============================================================================
/**
* Create a participant manually
*/
export function useCreateParticipant(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateParticipantRequest) => {
const result = await createParticipant(competitionId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.participants(competitionId),
});
},
});
}
/**
* Assign a participant to a group
*/
export function useAssignParticipantToGroup(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
participantId,
groupId,
}: {
participantId: number;
groupId: number;
}) => {
const result = await assignParticipantToGroup(participantId, groupId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.participants(competitionId),
});
},
});
}
/**
* Set a participant's seed
*/
export function useSetParticipantSeed(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
participantId,
seed,
}: {
participantId: number;
seed: number;
}) => {
const result = await setParticipantSeed(participantId, seed);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.participants(competitionId),
});
},
});
}
/**
* Withdraw a participant
*/
export function useWithdrawParticipant(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (participantId: number) => {
const result = await withdrawParticipant(participantId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.participants(competitionId),
});
},
});
}
// ============================================================================
// Fixture Mutations
// ============================================================================
/**
* Generate fixtures for a stage
*/
export function useGenerateFixtures(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
stageId,
request,
}: {
stageId: number;
request: GenerateFixturesRequest;
}) => {
const result = await generateFixtures(competitionId, stageId, request);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.fixtures(competitionId, variables.stageId),
});
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.detail(competitionId),
});
},
});
}
/**
* Link a match to a slot
*/
export function useLinkMatchToSlot(competitionId: number, stageId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
matchId,
request,
}: {
matchId: number;
request: LinkSlotRequest;
}) => {
const result = await linkMatchToSlot(matchId, request);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.fixtures(competitionId, stageId),
});
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.match(variables.matchId),
});
},
});
}
/**
* Unlink a match from its slot
*/
export function useUnlinkMatchFromSlot(competitionId: number, stageId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (matchId: number) => {
const result = await unlinkMatchFromSlot(matchId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: (_, matchId) => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.fixtures(competitionId, stageId),
});
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.match(matchId),
});
},
});
}
/**
* Set a match winner manually
*/
export function useSetMatchWinner(competitionId: number, stageId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
matchId,
request,
}: {
matchId: number;
request: SetWinnerRequest;
}) => {
const result = await setMatchWinner(matchId, request);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.fixtures(competitionId, stageId),
});
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.match(variables.matchId),
});
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.standings(competitionId, stageId),
});
},
});
}
/**
* Void a match
*/
export function useVoidMatch(competitionId: number, stageId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (matchId: number) => {
const result = await voidMatch(matchId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: (_, matchId) => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.fixtures(competitionId, stageId),
});
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.match(matchId),
});
},
});
}
// ============================================================================
// Standings/Leaderboard Mutations
// ============================================================================
/**
* Recalculate standings for a stage
*/
export function useRecalculateStandings(competitionId: number, stageId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (groupId?: number) => {
const result = await recalculateStandings(competitionId, stageId, groupId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.standings(competitionId, stageId),
});
},
});
}
/**
* Recalculate leaderboard for a challenge stage
*/
export function useRecalculateLeaderboard(competitionId: number, stageId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const result = await recalculateLeaderboard(competitionId, stageId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.leaderboard(competitionId, stageId),
});
},
});
}
// ============================================================================
// Type exports for mutation results
// ============================================================================
export type CreateTemplateMutation = ReturnType<typeof useCreateTemplate>;
export type UpdateTemplateMutation = ReturnType<typeof useUpdateTemplate>;
export type CreateCompetitionMutation = ReturnType<typeof useCreateCompetition>;
export type UpdateCompetitionMutation = ReturnType<typeof useUpdateCompetition>;
export type PublishCompetitionMutation = ReturnType<typeof usePublishCompetition>;
export type ApproveRegistrationMutation = ReturnType<typeof useApproveRegistration>;
export type GenerateFixturesMutation = ReturnType<typeof useGenerateFixtures>;

@ -0,0 +1,293 @@
import { useQuery } from '@tanstack/react-query';
import {
listTemplates,
getTemplate,
listCompetitions,
getCompetition,
listRegistrations,
listParticipants,
listFixtures,
getMatch,
getStandings,
getLeaderboard,
} from '@/src/lib/api/competition-admin';
import type {
CompetitionListFilters,
RegistrationListFilters,
ParticipantListFilters,
FixtureListFilters,
StandingsFilters,
} from '@/src/types/competition';
// ============================================================================
// Query Keys
// ============================================================================
export const competitionQueryKeys = {
all: ['competitions'] as const,
// Templates
templates: () => [...competitionQueryKeys.all, 'templates'] as const,
templateList: (facilityId: number, filters?: { sport_id?: number; include_inactive?: boolean }) =>
[...competitionQueryKeys.templates(), 'list', facilityId, filters] as const,
templateDetail: (templateId: number) =>
[...competitionQueryKeys.templates(), 'detail', templateId] as const,
// Competitions
lists: () => [...competitionQueryKeys.all, 'list'] as const,
list: (facilityId: number, filters?: CompetitionListFilters) =>
[...competitionQueryKeys.lists(), facilityId, filters] as const,
details: () => [...competitionQueryKeys.all, 'detail'] as const,
detail: (competitionId: number) =>
[...competitionQueryKeys.details(), competitionId] as const,
// Registrations
registrations: (competitionId: number) =>
[...competitionQueryKeys.detail(competitionId), 'registrations'] as const,
registrationList: (competitionId: number, filters?: RegistrationListFilters) =>
[...competitionQueryKeys.registrations(competitionId), filters] as const,
// Participants
participants: (competitionId: number) =>
[...competitionQueryKeys.detail(competitionId), 'participants'] as const,
participantList: (competitionId: number, filters?: ParticipantListFilters) =>
[...competitionQueryKeys.participants(competitionId), filters] as const,
// Fixtures
fixtures: (competitionId: number, stageId: number) =>
[...competitionQueryKeys.detail(competitionId), 'fixtures', stageId] as const,
fixtureList: (competitionId: number, stageId: number, filters?: FixtureListFilters) =>
[...competitionQueryKeys.fixtures(competitionId, stageId), filters] as const,
match: (matchId: number) =>
[...competitionQueryKeys.all, 'match', matchId] as const,
// Standings
standings: (competitionId: number, stageId: number) =>
[...competitionQueryKeys.detail(competitionId), 'standings', stageId] as const,
standingsList: (competitionId: number, stageId: number, filters?: StandingsFilters) =>
[...competitionQueryKeys.standings(competitionId, stageId), filters] as const,
// Leaderboard
leaderboard: (competitionId: number, stageId: number) =>
[...competitionQueryKeys.detail(competitionId), 'leaderboard', stageId] as const,
};
// ============================================================================
// Template Query Hooks
// ============================================================================
/**
* Fetch competition templates for a facility
*/
export function useCompetitionTemplates(
facilityId: number,
filters?: { sport_id?: number; include_inactive?: boolean }
) {
return useQuery({
queryKey: competitionQueryKeys.templateList(facilityId, filters),
queryFn: async () => {
const result = await listTemplates(facilityId, filters);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: facilityId > 0,
});
}
/**
* Fetch a single template
*/
export function useCompetitionTemplate(templateId: number) {
return useQuery({
queryKey: competitionQueryKeys.templateDetail(templateId),
queryFn: async () => {
const result = await getTemplate(templateId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: templateId > 0,
});
}
// ============================================================================
// Competition Query Hooks
// ============================================================================
/**
* Fetch competitions for a facility
*/
export function useCompetitions(facilityId: number, filters?: CompetitionListFilters) {
return useQuery({
queryKey: competitionQueryKeys.list(facilityId, filters),
queryFn: async () => {
const result = await listCompetitions(facilityId, filters);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: facilityId > 0,
});
}
/**
* Fetch a single competition with stages
*/
export function useCompetition(competitionId: number) {
return useQuery({
queryKey: competitionQueryKeys.detail(competitionId),
queryFn: async () => {
const result = await getCompetition(competitionId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: competitionId > 0,
});
}
// ============================================================================
// Registration Query Hooks
// ============================================================================
/**
* Fetch registrations for a competition
*/
export function useCompetitionRegistrations(
competitionId: number,
filters?: RegistrationListFilters
) {
return useQuery({
queryKey: competitionQueryKeys.registrationList(competitionId, filters),
queryFn: async () => {
const result = await listRegistrations(competitionId, filters);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: competitionId > 0,
});
}
// ============================================================================
// Participant Query Hooks
// ============================================================================
/**
* Fetch participants for a competition
*/
export function useCompetitionParticipants(
competitionId: number,
filters?: ParticipantListFilters
) {
return useQuery({
queryKey: competitionQueryKeys.participantList(competitionId, filters),
queryFn: async () => {
const result = await listParticipants(competitionId, filters);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: competitionId > 0,
});
}
// ============================================================================
// Fixture Query Hooks
// ============================================================================
/**
* Fetch fixtures for a stage
*/
export function useCompetitionFixtures(
competitionId: number,
stageId: number,
filters?: FixtureListFilters
) {
return useQuery({
queryKey: competitionQueryKeys.fixtureList(competitionId, stageId, filters),
queryFn: async () => {
const result = await listFixtures(competitionId, stageId, filters);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: competitionId > 0 && stageId > 0,
});
}
/**
* Fetch a single match
*/
export function useCompetitionMatch(matchId: number) {
return useQuery({
queryKey: competitionQueryKeys.match(matchId),
queryFn: async () => {
const result = await getMatch(matchId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: matchId > 0,
});
}
// ============================================================================
// Standings Query Hooks
// ============================================================================
/**
* Fetch standings for a stage
*/
export function useCompetitionStandings(
competitionId: number,
stageId: number,
filters?: StandingsFilters
) {
return useQuery({
queryKey: competitionQueryKeys.standingsList(competitionId, stageId, filters),
queryFn: async () => {
const result = await getStandings(competitionId, stageId, filters);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: competitionId > 0 && stageId > 0,
});
}
// ============================================================================
// Leaderboard Query Hooks
// ============================================================================
/**
* Fetch leaderboard for a challenge stage
*/
export function useCompetitionLeaderboard(
competitionId: number,
stageId: number,
limit: number = 100,
offset: number = 0
) {
return useQuery({
queryKey: [...competitionQueryKeys.leaderboard(competitionId, stageId), limit, offset],
queryFn: async () => {
const result = await getLeaderboard(competitionId, stageId, limit, offset);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: competitionId > 0 && stageId > 0,
});
}

@ -0,0 +1,870 @@
/**
* Competition Admin API Client
*
* Handles all competition management operations: templates, competitions,
* registrations, participants, fixtures, standings, and leaderboards.
*/
import type {
CompetitionTemplate,
CreateTemplateRequest,
UpdateTemplateRequest,
Competition,
CreateCompetitionRequest,
UpdateCompetitionRequest,
CompetitionListFilters,
CompetitionStage,
CompetitionRegistration,
CreateRegistrationRequest,
RegistrationListFilters,
CompetitionParticipant,
CreateParticipantRequest,
ParticipantListFilters,
CompetitionMatch,
GenerateFixturesRequest,
FixtureListFilters,
LinkSlotRequest,
SetWinnerRequest,
CompetitionStanding,
StandingsFilters,
LeaderboardEntry,
LeaderboardStats,
CompetitionAdminError,
CompetitionApiResult,
} from '@/src/types/competition';
import apiFetch from '@/src/utils/apiFetch';
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Handle API response
* Flask backend wraps responses in: { status: 'success'|'fail', data: {...} }
*/
async function handleApiResponse<T>(response: Response): Promise<CompetitionApiResult<T>> {
if (response.ok) {
if (response.status === 204) {
return { success: true, data: undefined as T };
}
const json = await response.json();
const data = json.data !== undefined ? json.data : json;
return { success: true, data };
}
try {
const error: CompetitionAdminError = await response.json();
return { success: false, error };
} catch {
return {
success: false,
error: {
type: 'about:blank',
title: 'API Error',
status: response.status,
detail: response.statusText || 'An unexpected error occurred',
code: 'internal_error',
},
};
}
}
function buildNetworkError(message: string): CompetitionApiResult<never> {
return {
success: false,
error: {
type: 'about:blank',
title: 'Network Error',
status: 0,
detail: message,
code: 'internal_error',
},
};
}
// ============================================================================
// Competition Templates
// ============================================================================
/**
* GET /competition/templates
* List competition templates
*/
export async function listTemplates(
facilityId: number,
filters?: { sport_id?: number; include_inactive?: boolean }
): Promise<CompetitionApiResult<CompetitionTemplate[]>> {
try {
const params = new URLSearchParams();
params.set('facility_id', String(facilityId));
if (filters?.sport_id !== undefined) params.set('sport_id', String(filters.sport_id));
if (filters?.include_inactive) params.set('include_inactive', 'true');
const response = await apiFetch(`/competition/templates?${params}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ templates: CompetitionTemplate[] }>(response);
if (result.success && result.data.templates) {
return { success: true, data: result.data.templates };
}
return result as CompetitionApiResult<CompetitionTemplate[]>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list templates');
}
}
/**
* GET /competition/templates/{template_id}
* Get a single template
*/
export async function getTemplate(
templateId: number
): Promise<CompetitionApiResult<CompetitionTemplate>> {
try {
const response = await apiFetch(`/competition/templates/${templateId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ template: CompetitionTemplate }>(response);
if (result.success && result.data.template) {
return { success: true, data: result.data.template };
}
return result as CompetitionApiResult<CompetitionTemplate>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to get template');
}
}
/**
* POST /competition/templates
* Create a new template
*/
export async function createTemplate(
facilityId: number,
request: CreateTemplateRequest
): Promise<CompetitionApiResult<CompetitionTemplate>> {
try {
const response = await apiFetch('/competition/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...request, facility_id: facilityId }),
});
const result = await handleApiResponse<{ template: CompetitionTemplate }>(response);
if (result.success && result.data.template) {
return { success: true, data: result.data.template };
}
return result as CompetitionApiResult<CompetitionTemplate>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to create template');
}
}
/**
* PATCH /competition/templates/{template_id}
* Update a template
*/
export async function updateTemplate(
templateId: number,
request: UpdateTemplateRequest
): Promise<CompetitionApiResult<CompetitionTemplate>> {
try {
const response = await apiFetch(`/competition/templates/${templateId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
const result = await handleApiResponse<{ template: CompetitionTemplate }>(response);
if (result.success && result.data.template) {
return { success: true, data: result.data.template };
}
return result as CompetitionApiResult<CompetitionTemplate>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to update template');
}
}
// ============================================================================
// Competitions
// ============================================================================
/**
* GET /competitions
* List competitions for a facility
*/
export async function listCompetitions(
facilityId: number,
filters?: CompetitionListFilters
): Promise<CompetitionApiResult<Competition[]>> {
try {
const params = new URLSearchParams();
params.set('facility_id', String(facilityId));
if (filters?.status) params.set('status', filters.status);
if (filters?.sport_id !== undefined) params.set('sport_id', String(filters.sport_id));
if (filters?.type) params.set('type', filters.type);
const response = await apiFetch(`/competitions?${params}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ competitions: Competition[] }>(response);
if (result.success && result.data.competitions) {
return { success: true, data: result.data.competitions };
}
return result as CompetitionApiResult<Competition[]>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list competitions');
}
}
/**
* GET /competition/{competition_id}
* Get competition with stages
*/
export async function getCompetition(
competitionId: number
): Promise<CompetitionApiResult<Competition>> {
try {
const response = await apiFetch(`/competition/${competitionId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ competition: Competition }>(response);
if (result.success && result.data.competition) {
return { success: true, data: result.data.competition };
}
return result as CompetitionApiResult<Competition>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to get competition');
}
}
/**
* POST /competition
* Create a new competition
*/
export async function createCompetition(
facilityId: number,
request: CreateCompetitionRequest
): Promise<CompetitionApiResult<Competition>> {
try {
const response = await apiFetch('/competition', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...request, facility_id: facilityId }),
});
const result = await handleApiResponse<{ competition: Competition }>(response);
if (result.success && result.data.competition) {
return { success: true, data: result.data.competition };
}
return result as CompetitionApiResult<Competition>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to create competition');
}
}
/**
* PATCH /competition/{competition_id}
* Update a competition
*/
export async function updateCompetition(
competitionId: number,
request: UpdateCompetitionRequest
): Promise<CompetitionApiResult<Competition>> {
try {
const response = await apiFetch(`/competition/${competitionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
const result = await handleApiResponse<{ competition: Competition }>(response);
if (result.success && result.data.competition) {
return { success: true, data: result.data.competition };
}
return result as CompetitionApiResult<Competition>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to update competition');
}
}
/**
* POST /competition/{competition_id}/publish
* Publish a competition (draft -> published)
*/
export async function publishCompetition(
competitionId: number
): Promise<CompetitionApiResult<Competition>> {
try {
const response = await apiFetch(`/competition/${competitionId}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ competition: Competition }>(response);
if (result.success && result.data.competition) {
return { success: true, data: result.data.competition };
}
return result as CompetitionApiResult<Competition>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to publish competition');
}
}
/**
* POST /competition/{competition_id}/start
* Start a competition (published -> running)
*/
export async function startCompetition(
competitionId: number
): Promise<CompetitionApiResult<Competition>> {
try {
const response = await apiFetch(`/competition/${competitionId}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ competition: Competition }>(response);
if (result.success && result.data.competition) {
return { success: true, data: result.data.competition };
}
return result as CompetitionApiResult<Competition>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to start competition');
}
}
/**
* POST /competition/{competition_id}/finish
* Finish a competition (running -> finished)
*/
export async function finishCompetition(
competitionId: number
): Promise<CompetitionApiResult<Competition>> {
try {
const response = await apiFetch(`/competition/${competitionId}/finish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ competition: Competition }>(response);
if (result.success && result.data.competition) {
return { success: true, data: result.data.competition };
}
return result as CompetitionApiResult<Competition>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to finish competition');
}
}
/**
* POST /competition/{competition_id}/cancel
* Cancel a competition
*/
export async function cancelCompetition(
competitionId: number
): Promise<CompetitionApiResult<Competition>> {
try {
const response = await apiFetch(`/competition/${competitionId}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ competition: Competition }>(response);
if (result.success && result.data.competition) {
return { success: true, data: result.data.competition };
}
return result as CompetitionApiResult<Competition>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to cancel competition');
}
}
// ============================================================================
// Registrations
// ============================================================================
/**
* GET /competition/{competition_id}/registrations
* List registrations for a competition
*/
export async function listRegistrations(
competitionId: number,
filters?: RegistrationListFilters
): Promise<CompetitionApiResult<CompetitionRegistration[]>> {
try {
const params = new URLSearchParams();
if (filters?.status) params.set('status', filters.status);
const queryString = params.toString();
const endpoint = `/competition/${competitionId}/registrations${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ registrations: CompetitionRegistration[] }>(response);
if (result.success && result.data.registrations) {
return { success: true, data: result.data.registrations };
}
return result as CompetitionApiResult<CompetitionRegistration[]>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list registrations');
}
}
/**
* POST /competition/{competition_id}/registrations/{registration_id}/approve
* Approve a registration
*/
export async function approveRegistration(
competitionId: number,
registrationId: number
): Promise<CompetitionApiResult<{ participant_id: number }>> {
try {
const response = await apiFetch(
`/competition/${competitionId}/registrations/${registrationId}/approve`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
);
return handleApiResponse<{ participant_id: number }>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to approve registration');
}
}
/**
* POST /competition/{competition_id}/registrations/{registration_id}/reject
* Reject a registration
*/
export async function rejectRegistration(
competitionId: number,
registrationId: number
): Promise<CompetitionApiResult<void>> {
try {
const response = await apiFetch(
`/competition/${competitionId}/registrations/${registrationId}/reject`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
);
return handleApiResponse<void>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to reject registration');
}
}
// ============================================================================
// Participants
// ============================================================================
/**
* GET /competition/{competition_id}/participants
* List participants for a competition
*/
export async function listParticipants(
competitionId: number,
filters?: ParticipantListFilters
): Promise<CompetitionApiResult<CompetitionParticipant[]>> {
try {
const params = new URLSearchParams();
if (filters?.status) params.set('status', filters.status);
if (filters?.stage_group_id !== undefined) params.set('stage_group_id', String(filters.stage_group_id));
const queryString = params.toString();
const endpoint = `/competition/${competitionId}/participants${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ participants: CompetitionParticipant[] }>(response);
if (result.success && result.data.participants) {
return { success: true, data: result.data.participants };
}
return result as CompetitionApiResult<CompetitionParticipant[]>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list participants');
}
}
/**
* POST /competition/{competition_id}/participants
* Create a participant manually (without registration)
*/
export async function createParticipant(
competitionId: number,
request: CreateParticipantRequest
): Promise<CompetitionApiResult<CompetitionParticipant>> {
try {
const response = await apiFetch(`/competition/${competitionId}/participants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
const result = await handleApiResponse<{ participant: CompetitionParticipant }>(response);
if (result.success && result.data.participant) {
return { success: true, data: result.data.participant };
}
return result as CompetitionApiResult<CompetitionParticipant>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to create participant');
}
}
/**
* POST /competition/participants/{participant_id}/assign-group
* Assign a participant to a group
*/
export async function assignParticipantToGroup(
participantId: number,
groupId: number
): Promise<CompetitionApiResult<void>> {
try {
const response = await apiFetch(`/competition/participants/${participantId}/assign-group`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_id: groupId }),
});
return handleApiResponse<void>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to assign to group');
}
}
/**
* POST /competition/participants/{participant_id}/seed
* Set participant seed
*/
export async function setParticipantSeed(
participantId: number,
seed: number
): Promise<CompetitionApiResult<void>> {
try {
const response = await apiFetch(`/competition/participants/${participantId}/seed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seed }),
});
return handleApiResponse<void>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to set seed');
}
}
/**
* POST /competition/participants/{participant_id}/withdraw
* Withdraw a participant
*/
export async function withdrawParticipant(
participantId: number
): Promise<CompetitionApiResult<void>> {
try {
const response = await apiFetch(`/competition/participants/${participantId}/withdraw`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<void>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to withdraw participant');
}
}
// ============================================================================
// Fixtures
// ============================================================================
/**
* POST /competition/{competition_id}/stages/{stage_id}/generate-fixtures
* Generate fixtures for a stage
*/
export async function generateFixtures(
competitionId: number,
stageId: number,
request: GenerateFixturesRequest
): Promise<CompetitionApiResult<{ match_count: number; match_ids: number[] }>> {
try {
const response = await apiFetch(
`/competition/${competitionId}/stages/${stageId}/generate-fixtures`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
}
);
return handleApiResponse<{ match_count: number; match_ids: number[] }>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to generate fixtures');
}
}
/**
* GET /competition/{competition_id}/stages/{stage_id}/fixtures
* List fixtures for a stage
*/
export async function listFixtures(
competitionId: number,
stageId: number,
filters?: FixtureListFilters
): Promise<CompetitionApiResult<CompetitionMatch[]>> {
try {
const params = new URLSearchParams();
if (filters?.status) params.set('status', filters.status);
if (filters?.round_index !== undefined) params.set('round_index', String(filters.round_index));
const queryString = params.toString();
const endpoint = `/competition/${competitionId}/stages/${stageId}/fixtures${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ fixtures: CompetitionMatch[] }>(response);
if (result.success && result.data.fixtures) {
return { success: true, data: result.data.fixtures };
}
return result as CompetitionApiResult<CompetitionMatch[]>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list fixtures');
}
}
/**
* GET /competition/matches/{match_id}
* Get a single match
*/
export async function getMatch(
matchId: number
): Promise<CompetitionApiResult<CompetitionMatch>> {
try {
const response = await apiFetch(`/competition/matches/${matchId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ match: CompetitionMatch }>(response);
if (result.success && result.data.match) {
return { success: true, data: result.data.match };
}
return result as CompetitionApiResult<CompetitionMatch>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to get match');
}
}
/**
* POST /competition/matches/{match_id}/link-slot
* Link a match to a slot instance
*/
export async function linkMatchToSlot(
matchId: number,
request: LinkSlotRequest
): Promise<CompetitionApiResult<void>> {
try {
const response = await apiFetch(`/competition/matches/${matchId}/link-slot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
return handleApiResponse<void>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to link slot');
}
}
/**
* POST /competition/matches/{match_id}/unlink-slot
* Unlink a match from its slot instance
*/
export async function unlinkMatchFromSlot(
matchId: number
): Promise<CompetitionApiResult<void>> {
try {
const response = await apiFetch(`/competition/matches/${matchId}/unlink-slot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<void>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to unlink slot');
}
}
/**
* POST /competition/matches/{match_id}/set-winner
* Manually set match winner
*/
export async function setMatchWinner(
matchId: number,
request: SetWinnerRequest
): Promise<CompetitionApiResult<void>> {
try {
const response = await apiFetch(`/competition/matches/${matchId}/set-winner`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
return handleApiResponse<void>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to set winner');
}
}
/**
* POST /competition/matches/{match_id}/void
* Void a match
*/
export async function voidMatch(
matchId: number
): Promise<CompetitionApiResult<void>> {
try {
const response = await apiFetch(`/competition/matches/${matchId}/void`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<void>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to void match');
}
}
// ============================================================================
// Standings
// ============================================================================
/**
* GET /competition/{competition_id}/stages/{stage_id}/standings
* Get standings for a stage
*/
export async function getStandings(
competitionId: number,
stageId: number,
filters?: StandingsFilters
): Promise<CompetitionApiResult<CompetitionStanding[]>> {
try {
const params = new URLSearchParams();
if (filters?.group_id !== undefined) params.set('group_id', String(filters.group_id));
const queryString = params.toString();
const endpoint = `/competition/${competitionId}/stages/${stageId}/standings${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ standings: CompetitionStanding[] }>(response);
if (result.success && result.data.standings) {
return { success: true, data: result.data.standings };
}
return result as CompetitionApiResult<CompetitionStanding[]>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to get standings');
}
}
/**
* POST /competition/{competition_id}/stages/{stage_id}/standings/recalculate
* Recalculate standings
*/
export async function recalculateStandings(
competitionId: number,
stageId: number,
groupId?: number
): Promise<CompetitionApiResult<{ participant_count: number }>> {
try {
const response = await apiFetch(
`/competition/${competitionId}/stages/${stageId}/standings/recalculate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(groupId !== undefined ? { group_id: groupId } : {}),
}
);
return handleApiResponse<{ participant_count: number }>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to recalculate standings');
}
}
// ============================================================================
// Leaderboard
// ============================================================================
/**
* GET /competition/{competition_id}/stages/{stage_id}/leaderboard
* Get leaderboard for a challenge stage
*/
export async function getLeaderboard(
competitionId: number,
stageId: number,
limit: number = 100,
offset: number = 0
): Promise<CompetitionApiResult<{ leaderboard: LeaderboardEntry[]; stats: LeaderboardStats }>> {
try {
const params = new URLSearchParams();
if (limit !== 100) params.set('limit', String(limit));
if (offset !== 0) params.set('offset', String(offset));
const queryString = params.toString();
const endpoint = `/competition/${competitionId}/stages/${stageId}/leaderboard${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<{ leaderboard: LeaderboardEntry[]; stats: LeaderboardStats }>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to get leaderboard');
}
}
/**
* POST /competition/{competition_id}/stages/{stage_id}/leaderboard/recalculate
* Recalculate leaderboard
*/
export async function recalculateLeaderboard(
competitionId: number,
stageId: number
): Promise<CompetitionApiResult<{ entry_count: number }>> {
try {
const response = await apiFetch(
`/competition/${competitionId}/stages/${stageId}/leaderboard/recalculate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
);
return handleApiResponse<{ entry_count: number }>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to recalculate leaderboard');
}
}

@ -0,0 +1,508 @@
/**
* Competition Admin TypeScript Types
*
* Types for competition management: templates, competitions, registrations,
* participants, fixtures, standings, and leaderboards.
*/
// ============================================================================
// Competition Types (Enums)
// ============================================================================
export type CompetitionType = 'league' | 'tournament' | 'challenge' | 'hybrid';
export type CompetitionScope = 'facility' | 'cross_facility' | 'platform';
export type CompetitionStatus = 'draft' | 'published' | 'running' | 'finished' | 'cancelled';
export type CompetitionVisibility = 'public' | 'unlisted' | 'private';
export type StageType = 'round_robin' | 'group_stage' | 'knockout_single' | 'knockout_double' | 'rating_delta_challenge';
export type StageStatus = 'pending' | 'active' | 'completed';
export type RegistrationStatus = 'pending' | 'approved' | 'rejected' | 'waitlisted' | 'withdrawn';
export type ParticipantType = 'single' | 'pair' | 'team';
export type ParticipantStatus = 'active' | 'withdrawn' | 'eliminated';
export type MatchStatus = 'draft' | 'needs_scheduling' | 'scheduled' | 'in_progress' | 'completed' | 'voided';
// ============================================================================
// Competition Templates
// ============================================================================
export interface CompetitionTemplate {
template_id: number;
type: CompetitionType;
scope: 'platform' | 'facility';
facility_id: number | null;
sport_id: number;
name: string;
description: string | null;
config: CompetitionConfig;
config_version: number;
source_template_id: number | null;
is_active: boolean;
created_at: string;
updated_at: string | null;
created_by_app_user_id: number;
}
export interface CreateTemplateRequest {
type: CompetitionType;
scope: 'platform' | 'facility';
sport_id: number;
name: string;
description?: string | null;
config: CompetitionConfig;
}
export interface UpdateTemplateRequest {
name?: string;
description?: string | null;
config?: CompetitionConfig;
is_active?: boolean;
}
// ============================================================================
// Competitions
// ============================================================================
export interface Competition {
competition_id: number;
type: CompetitionType;
scope: CompetitionScope;
facility_id: number | null;
owner_app_user_id: number;
template_id: number | null;
series_id: number | null;
sport_id: number;
title: string;
description: string | null;
visibility: CompetitionVisibility;
status: CompetitionStatus;
registration_open_at: string | null;
registration_close_at: string | null;
starts_at: string;
ends_at: string | null;
config_snapshot: CompetitionConfig;
config_version: number;
created_at: string;
updated_at: string | null;
created_by_app_user_id: number;
// Joined data
stages?: CompetitionStage[];
participant_count?: number;
registration_count?: number;
}
export interface CreateCompetitionRequest {
type: CompetitionType;
scope?: CompetitionScope;
template_id?: number | null;
sport_id: number;
title: string;
description?: string | null;
visibility?: CompetitionVisibility;
registration_open_at?: string | null;
registration_close_at?: string | null;
starts_at: string;
ends_at?: string | null;
config?: CompetitionConfig;
}
export interface UpdateCompetitionRequest {
title?: string;
description?: string | null;
visibility?: CompetitionVisibility;
registration_open_at?: string | null;
registration_close_at?: string | null;
starts_at?: string;
ends_at?: string | null;
}
export interface CompetitionListFilters {
status?: CompetitionStatus;
sport_id?: number;
type?: CompetitionType;
}
// ============================================================================
// Competition Stages
// ============================================================================
export interface CompetitionStage {
stage_id: number;
competition_id: number;
stage_type: StageType;
name: string;
stage_order: number;
status: StageStatus;
config: StageConfig;
created_at: string;
// Joined data
groups?: CompetitionGroup[];
match_count?: number;
}
export interface StageConfig {
// Round robin / group stage
legs?: number;
points_win?: number;
points_draw?: number;
points_loss?: number;
// Knockout
third_place_match?: boolean;
// Challenge
leaderboard_metric?: 'rating_delta_sum' | 'rating_delta_avg' | 'rating_delta_max';
min_matches?: number;
}
// ============================================================================
// Competition Groups
// ============================================================================
export interface CompetitionGroup {
group_id: number;
stage_id: number;
name: string;
display_order: number;
// Joined data
participant_count?: number;
}
// ============================================================================
// Registrations
// ============================================================================
export interface CompetitionRegistration {
registration_id: number;
competition_id: number;
app_user_id: number;
display_name: string;
email?: string;
status: RegistrationStatus;
team_name: string | null;
notes: string | null;
created_at: string;
updated_at: string | null;
reviewed_at: string | null;
reviewed_by_app_user_id: number | null;
// Joined data
members: RegistrationMember[];
}
export interface RegistrationMember {
origin_member_id: number;
display_name: string;
role: 'captain' | 'player';
}
export interface CreateRegistrationRequest {
origin_member_ids: number[];
team_name?: string | null;
notes?: string | null;
}
export interface RegistrationListFilters {
status?: RegistrationStatus;
}
// ============================================================================
// Participants
// ============================================================================
export interface CompetitionParticipant {
participant_id: number;
competition_id: number;
registration_id: number | null;
participant_type: ParticipantType;
display_name: string;
seed: number | null;
status: ParticipantStatus;
stage_group_id: number | null;
created_at: string;
// Joined data
members: ParticipantMember[];
group_name?: string | null;
}
export interface ParticipantMember {
origin_member_id: number;
display_name: string;
role: 'captain' | 'player';
}
export interface CreateParticipantRequest {
origin_member_ids: number[];
participant_type?: ParticipantType;
display_name?: string | null;
seed?: number | null;
stage_group_id?: number | null;
}
export interface ParticipantListFilters {
status?: ParticipantStatus;
stage_group_id?: number;
}
// ============================================================================
// Fixtures / Matches
// ============================================================================
export interface CompetitionMatch {
competition_match_id: number;
competition_id: number;
stage_id: number;
group_id: number | null;
round_index: number;
match_index: number;
home_participant_id: number | null;
away_participant_id: number | null;
home_from_match_id: number | null;
home_from_outcome: 'winner' | 'loser' | null;
away_from_match_id: number | null;
away_from_outcome: 'winner' | 'loser' | null;
slot_instance_id: number | null;
status: MatchStatus;
winner_participant_id: number | null;
scheduled_at: string | null;
created_at: string;
updated_at: string | null;
// Joined data
home_participant?: CompetitionParticipant | null;
away_participant?: CompetitionParticipant | null;
stage_name?: string;
group_name?: string | null;
}
export interface GenerateFixturesRequest {
stage_type: StageType;
legs?: number;
group_id?: number | null;
third_place_match?: boolean;
}
export interface FixtureListFilters {
status?: MatchStatus;
round_index?: number;
}
export interface LinkSlotRequest {
slot_instance_id: number;
}
export interface SetWinnerRequest {
winner_participant_id: number;
}
// ============================================================================
// Standings
// ============================================================================
export interface CompetitionStanding {
standing_id: number;
stage_id: number;
group_id: number | null;
participant_id: number;
rank: number;
played: number;
won: number;
drawn: number;
lost: number;
goals_for: number;
goals_against: number;
goal_difference: number;
points: number;
updated_at: string;
// Joined data
display_name: string;
participant?: CompetitionParticipant;
}
export interface StandingsFilters {
group_id?: number;
}
// ============================================================================
// Leaderboard
// ============================================================================
export interface LeaderboardEntry {
entry_id: number;
stage_id: number;
participant_id: number;
origin_member_id: number;
rank: number;
metric_value: number;
matches_counted: number;
updated_at: string;
// Joined data
display_name: string;
participant?: CompetitionParticipant;
}
export interface LeaderboardStats {
total_entries: number;
total_matches: number;
top_metric_value: number;
}
// ============================================================================
// Competition Config
// ============================================================================
export interface CompetitionConfig {
format?: FormatConfig;
ops?: OpsConfig;
rating?: RatingConfig;
experience?: ExperienceConfig;
}
export interface FormatConfig {
stages: StageConfig[];
participant_type?: ParticipantType;
team_size?: { min: number; max: number };
}
export interface OpsConfig {
registration?: {
requires_approval?: boolean;
max_participants?: number;
waitlist_enabled?: boolean;
};
scheduling?: {
auto_schedule?: boolean;
preferred_days?: number[];
preferred_hours?: { start: number; end: number };
};
verification?: {
require_score_consensus?: boolean;
allow_admin_override?: boolean;
};
}
export interface RatingConfig {
affects_rating?: boolean;
rating_weight?: number;
}
export interface ExperienceConfig {
theme?: string;
share_cards?: boolean;
badges_enabled?: boolean;
}
// ============================================================================
// Error Types (RFC-7807)
// ============================================================================
export interface CompetitionAdminError {
type: string;
title: string;
status: number;
detail: string;
code: CompetitionErrorCode;
errors?: ValidationError[];
meta?: Record<string, unknown>;
}
export interface ValidationError {
field: string;
message: string;
}
export type CompetitionErrorCode =
| 'validation_error'
| 'not_found'
| 'unauthorized'
| 'forbidden'
| 'internal_error'
| 'competition_not_found'
| 'template_not_found'
| 'registration_not_found'
| 'participant_not_found'
| 'match_not_found'
| 'stage_not_found'
| 'invalid_status_transition'
| 'registration_closed'
| 'already_registered'
| 'fixtures_already_generated'
| 'match_already_scheduled'
| 'match_already_completed';
// ============================================================================
// API Result Wrapper
// ============================================================================
export type CompetitionApiResult<T> =
| { success: true; data: T }
| { success: false; error: CompetitionAdminError };
// ============================================================================
// User-Friendly Error Messages
// ============================================================================
export const COMPETITION_ERROR_MESSAGES: Record<CompetitionErrorCode, { title: string; hint: string }> = {
validation_error: {
title: 'Please fix the highlighted fields.',
hint: 'Check your input and try again.',
},
not_found: {
title: 'Not found.',
hint: 'The resource you requested does not exist.',
},
unauthorized: {
title: 'Not authenticated.',
hint: 'Please log in and try again.',
},
forbidden: {
title: 'Access denied.',
hint: "You don't have permission to perform this action.",
},
internal_error: {
title: 'Something went wrong.',
hint: 'Please try again later.',
},
competition_not_found: {
title: 'Competition not found.',
hint: 'This competition may have been deleted.',
},
template_not_found: {
title: 'Template not found.',
hint: 'This template may have been deleted.',
},
registration_not_found: {
title: 'Registration not found.',
hint: 'This registration may have been deleted.',
},
participant_not_found: {
title: 'Participant not found.',
hint: 'This participant may have been withdrawn.',
},
match_not_found: {
title: 'Match not found.',
hint: 'This match may have been deleted.',
},
stage_not_found: {
title: 'Stage not found.',
hint: 'This stage may have been deleted.',
},
invalid_status_transition: {
title: 'Invalid status change.',
hint: 'This status transition is not allowed.',
},
registration_closed: {
title: 'Registration is closed.',
hint: 'The registration period for this competition has ended.',
},
already_registered: {
title: 'Already registered.',
hint: 'You are already registered for this competition.',
},
fixtures_already_generated: {
title: 'Fixtures already exist.',
hint: 'Fixtures have already been generated for this stage.',
},
match_already_scheduled: {
title: 'Match already scheduled.',
hint: 'This match has already been linked to a slot.',
},
match_already_completed: {
title: 'Match already completed.',
hint: 'This match has already been completed.',
},
};
Loading…
Cancel
Save