website refactor
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { PageWrapper } from '@/ui/PageWrapper';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { HomeTemplate, type HomeViewData } from '@/templates/HomeTemplate';
|
||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||
import { HomePageQuery } from '@/lib/page-queries/HomePageQuery';
|
||||
@@ -19,7 +19,7 @@ export default async function Page() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const Template = ({ data }: { data: HomeViewData }) => <HomeTemplate viewData={data} />;
|
||||
const Template = ({ viewData }: { viewData: HomeViewData }) => <HomeTemplate viewData={viewData} />;
|
||||
|
||||
return <PageWrapper data={data} Template={Template} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PageWrapper } from '@/ui/PageWrapper';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { RaceDetailPageQuery } from '@/lib/page-queries/races/RaceDetailPageQuery';
|
||||
import { RaceDetailPageClient } from '@/client-wrapper/RaceDetailPageClient';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PageWrapper } from '@/ui/PageWrapper';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { RaceResultsPageQuery } from '@/lib/page-queries/races/RaceResultsPageQuery';
|
||||
import { RaceResultsPageClient } from '@/client-wrapper/RaceResultsPageClient';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PageWrapper } from '@/ui/PageWrapper';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { SponsorLeagueDetailPageClient } from '@/client-wrapper/SponsorLeagueDetailPageClient';
|
||||
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PageWrapper } from '@/ui/PageWrapper';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { SponsorLeaguesPageClient } from '@/client-wrapper/SponsorLeaguesPageClient';
|
||||
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
|
||||
@@ -4,12 +4,9 @@ import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AdminDashboardTemplate } from '@/templates/AdminDashboardTemplate';
|
||||
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface AdminDashboardWrapperProps {
|
||||
initialViewData: AdminDashboardViewData;
|
||||
}
|
||||
|
||||
export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapperProps) {
|
||||
export function AdminDashboardWrapper({ viewData }: ClientWrapperProps<AdminDashboardViewData>) {
|
||||
const router = useRouter();
|
||||
|
||||
// UI state (not business logic)
|
||||
@@ -24,7 +21,7 @@ export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapper
|
||||
|
||||
return (
|
||||
<AdminDashboardTemplate
|
||||
viewData={initialViewData}
|
||||
viewData={viewData}
|
||||
onRefresh={handleRefresh}
|
||||
isLoading={loading}
|
||||
/>
|
||||
|
||||
@@ -6,13 +6,10 @@ import { AdminUsersTemplate } from '@/templates/AdminUsersTemplate';
|
||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||
import { updateUserStatus, deleteUser } from '@/app/actions/adminActions';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { ConfirmDialog } from '@/ui/ConfirmDialog';
|
||||
import { SharedConfirmDialog } from '@/components/shared/UIComponents';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface AdminUsersWrapperProps {
|
||||
initialViewData: AdminUsersViewData;
|
||||
}
|
||||
|
||||
export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
||||
export function AdminUsersWrapper({ viewData }: ClientWrapperProps<AdminUsersViewData>) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -38,12 +35,12 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
||||
}, []);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (selectedUserIds.length === initialViewData.users.length) {
|
||||
if (selectedUserIds.length === viewData.users.length) {
|
||||
setSelectedUserIds([]);
|
||||
} else {
|
||||
setSelectedUserIds(initialViewData.users.map(u => u.id));
|
||||
setSelectedUserIds(viewData.users.map(u => u.id));
|
||||
}
|
||||
}, [selectedUserIds.length, initialViewData.users]);
|
||||
}, [selectedUserIds.length, viewData.users]);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelectedUserIds([]);
|
||||
@@ -132,7 +129,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
||||
return (
|
||||
<>
|
||||
<AdminUsersTemplate
|
||||
viewData={initialViewData}
|
||||
viewData={viewData}
|
||||
onRefresh={handleRefresh}
|
||||
onSearch={handleSearch}
|
||||
onFilterRole={handleFilterRole}
|
||||
@@ -151,7 +148,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
|
||||
onSelectAll={handleSelectAll}
|
||||
onClearSelection={handleClearSelection}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
<SharedConfirmDialog
|
||||
isOpen={!!userToDelete}
|
||||
onClose={() => setUserToDelete(null)}
|
||||
onConfirm={confirmDeleteUser}
|
||||
|
||||
@@ -1,51 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/components/auth/AuthContext';
|
||||
import { LeagueReviewSummary } from '@/components/leagues/LeagueReviewSummary';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import {
|
||||
AlertCircle,
|
||||
Award,
|
||||
Calendar,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Loader2,
|
||||
Scale,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
|
||||
|
||||
import { LeagueBasicsSection } from '@/components/leagues/LeagueBasicsSection';
|
||||
import { LeagueDropSection } from '@/components/leagues/LeagueDropSection';
|
||||
import {
|
||||
ChampionshipsSection,
|
||||
ScoringPatternSection
|
||||
} from '@/components/leagues/LeagueScoringSection';
|
||||
import { LeagueStewardingSection } from '@/components/leagues/LeagueStewardingSection';
|
||||
import { LeagueStructureSection } from '@/components/leagues/LeagueStructureSection';
|
||||
import { LeagueTimingsSection } from '@/components/leagues/LeagueTimingsSection';
|
||||
import { LeagueVisibilitySection } from '@/components/leagues/LeagueVisibilitySection';
|
||||
import { useLeagueScoringPresets } from "@/hooks/useLeagueScoringPresets";
|
||||
import { useCreateLeagueWizard } from "@/hooks/useLeagueWizardService";
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
import type { Weekday } from '@/lib/types/Weekday';
|
||||
import type { WizardErrors } from '@/lib/types/WizardErrors';
|
||||
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
|
||||
import { CreateLeagueWizardTemplate, Step } from '@/templates/CreateLeagueWizardTemplate';
|
||||
import {
|
||||
Award,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Scale,
|
||||
Trophy,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
|
||||
// ============================================================================
|
||||
// LOCAL STORAGE PERSISTENCE
|
||||
@@ -54,16 +29,14 @@ import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScori
|
||||
const STORAGE_KEY = 'gridpilot_league_wizard_draft';
|
||||
const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step';
|
||||
|
||||
// TODO there is a better place for this
|
||||
function saveFormToStorage(form: LeagueWizardFormModel): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
// Ignore storage errors (quota exceeded, etc.)
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// TODO there is a better place for this
|
||||
function loadFormFromStorage(): LeagueWizardFormModel | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -110,8 +83,6 @@ function getHighestStep(): number {
|
||||
}
|
||||
}
|
||||
|
||||
type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||
|
||||
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';
|
||||
|
||||
type LeagueWizardFormModel = LeagueConfigFormModel & {
|
||||
@@ -125,44 +96,29 @@ interface CreateLeagueWizardProps {
|
||||
|
||||
function stepNameToStep(stepName: StepName): Step {
|
||||
switch (stepName) {
|
||||
case 'basics':
|
||||
return 1;
|
||||
case 'visibility':
|
||||
return 2;
|
||||
case 'structure':
|
||||
return 3;
|
||||
case 'schedule':
|
||||
return 4;
|
||||
case 'scoring':
|
||||
return 5;
|
||||
case 'stewarding':
|
||||
return 6;
|
||||
case 'review':
|
||||
return 7;
|
||||
case 'basics': return 1;
|
||||
case 'visibility': return 2;
|
||||
case 'structure': return 3;
|
||||
case 'schedule': return 4;
|
||||
case 'scoring': return 5;
|
||||
case 'stewarding': return 6;
|
||||
case 'review': return 7;
|
||||
}
|
||||
}
|
||||
|
||||
function stepToStepName(step: Step): StepName {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return 'basics';
|
||||
case 2:
|
||||
return 'visibility';
|
||||
case 3:
|
||||
return 'structure';
|
||||
case 4:
|
||||
return 'schedule';
|
||||
case 5:
|
||||
return 'scoring';
|
||||
case 6:
|
||||
return 'stewarding';
|
||||
case 7:
|
||||
return 'review';
|
||||
case 1: return 'basics';
|
||||
case 2: return 'visibility';
|
||||
case 3: return 'structure';
|
||||
case 4: return 'schedule';
|
||||
case 5: return 'scoring';
|
||||
case 6: return 'stewarding';
|
||||
case 7: return 'review';
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultSeasonStartDate(): string {
|
||||
// Default to next Saturday
|
||||
const now = new Date();
|
||||
const daysUntilSaturday = (6 - now.getDay() + 7) % 7 || 7;
|
||||
const nextSaturday = new Date(now);
|
||||
@@ -220,7 +176,6 @@ function createDefaultForm(): LeagueWizardFormModel {
|
||||
mainRaceMinutes: 40,
|
||||
sessionCount: 2,
|
||||
roundsPlanned: 8,
|
||||
// Default to Saturday races, weekly, starting next week
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
recurrenceStrategy: 'weekly' as const,
|
||||
timezoneId: 'UTC',
|
||||
@@ -253,12 +208,10 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
||||
const [highestCompletedStep, setHighestCompletedStep] = useState(1);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
// Initialize form from localStorage or defaults
|
||||
const [form, setForm] = useState<LeagueWizardFormModel>(() =>
|
||||
createDefaultForm(),
|
||||
);
|
||||
|
||||
// Hydrate from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = loadFormFromStorage();
|
||||
if (stored) {
|
||||
@@ -268,14 +221,12 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
// Save form to localStorage whenever it changes (after hydration)
|
||||
useEffect(() => {
|
||||
if (isHydrated) {
|
||||
saveFormToStorage(form);
|
||||
}
|
||||
}, [form, isHydrated]);
|
||||
|
||||
// Track highest step reached
|
||||
useEffect(() => {
|
||||
if (isHydrated) {
|
||||
saveHighestStep(step);
|
||||
@@ -283,13 +234,10 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
||||
}
|
||||
}, [step, isHydrated]);
|
||||
|
||||
// Use the react-query hook for scoring presets
|
||||
const { data: queryPresets, error: presetsError } = useLeagueScoringPresets();
|
||||
|
||||
// Sync presets from query to local state
|
||||
useEffect(() => {
|
||||
if (queryPresets) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setPresets(queryPresets as any);
|
||||
const firstPreset = queryPresets[0];
|
||||
if (firstPreset && !form.scoring?.patternId) {
|
||||
@@ -306,7 +254,6 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
||||
}
|
||||
}, [queryPresets, form.scoring?.patternId]);
|
||||
|
||||
// Handle presets error
|
||||
useEffect(() => {
|
||||
if (presetsError) {
|
||||
setErrors((prev) => ({
|
||||
@@ -316,12 +263,9 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
||||
}
|
||||
}, [presetsError]);
|
||||
|
||||
// Use the create league mutation
|
||||
const createLeagueMutation = useCreateLeagueWizard();
|
||||
|
||||
const validateStep = (currentStep: Step): boolean => {
|
||||
// Convert form to LeagueWizardFormData for validation
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const formData: any = {
|
||||
leagueId: form.leagueId || '',
|
||||
basics: {
|
||||
@@ -371,7 +315,6 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(formData, currentStep as any);
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
@@ -395,7 +338,6 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
||||
onStepChange(stepToStepName(prevStep));
|
||||
};
|
||||
|
||||
// Navigate to a specific step (only if it's been reached before)
|
||||
const goToStep = useCallback((targetStep: Step) => {
|
||||
if (targetStep <= highestCompletedStep) {
|
||||
onStepChange(stepToStepName(targetStep));
|
||||
@@ -415,8 +357,6 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert form to LeagueWizardFormData for validation
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const formData: any = {
|
||||
leagueId: form.leagueId || '',
|
||||
basics: {
|
||||
@@ -484,17 +424,11 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
||||
});
|
||||
|
||||
try {
|
||||
// Use the mutation to create the league
|
||||
const result = await createLeagueMutation.mutateAsync({ form, ownerId });
|
||||
|
||||
// Clear the draft on successful creation
|
||||
clearFormStorage();
|
||||
|
||||
// Navigate to the new league
|
||||
router.push(`/leagues/${result.leagueId}`);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to create league';
|
||||
const message = err instanceof Error ? err.message : 'Failed to create league';
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
submit: message,
|
||||
@@ -503,7 +437,6 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for scoring preset selection (timings default from API)
|
||||
const handleScoringPresetChange = (patternId: string) => {
|
||||
setForm((prev) => {
|
||||
const selectedPreset = presets.find((p) => p.id === patternId);
|
||||
@@ -541,460 +474,65 @@ export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizar
|
||||
|
||||
const getStepTitle = (currentStep: Step): string => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return 'Name your league';
|
||||
case 2:
|
||||
return 'Choose your destiny';
|
||||
case 3:
|
||||
return 'Choose the structure';
|
||||
case 4:
|
||||
return 'Set the schedule';
|
||||
case 5:
|
||||
return 'Scoring & championships';
|
||||
case 6:
|
||||
return 'Stewarding & protests';
|
||||
case 7:
|
||||
return 'Review & create';
|
||||
default:
|
||||
return '';
|
||||
case 1: return 'Name your league';
|
||||
case 2: return 'Choose your destiny';
|
||||
case 3: return 'Choose the structure';
|
||||
case 4: return 'Set the schedule';
|
||||
case 5: return 'Scoring & championships';
|
||||
case 6: return 'Stewarding & protests';
|
||||
case 7: return 'Review & create';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getStepSubtitle = (currentStep: Step): string => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return 'Give your league a memorable name and tell your story.';
|
||||
case 2:
|
||||
return 'Will you compete for global rankings or race with friends?';
|
||||
case 3:
|
||||
return 'Define how races in this season will run.';
|
||||
case 4:
|
||||
return 'Plan when this season’s races happen.';
|
||||
case 5:
|
||||
return 'Choose how points and drop scores work for this season.';
|
||||
case 6:
|
||||
return 'Set how protests and stewarding work for this season.';
|
||||
case 7:
|
||||
return 'Review your league and first season before launching.';
|
||||
default:
|
||||
return '';
|
||||
case 1: return 'Give your league a memorable name and tell your story.';
|
||||
case 2: return 'Will you compete for global rankings or race with friends?';
|
||||
case 3: return 'Define how races in this season will run.';
|
||||
case 4: return 'Plan when this season’s races happen.';
|
||||
case 5: return 'Choose how points and drop scores work for this season.';
|
||||
case 6: return 'Set how protests and stewarding work for this season.';
|
||||
case 7: return 'Review your league and first season before launching.';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getStepContextLabel = (currentStep: Step): string => {
|
||||
if (currentStep === 1 || currentStep === 2) {
|
||||
return 'League setup';
|
||||
}
|
||||
if (currentStep >= 3 && currentStep <= 6) {
|
||||
return 'Season setup';
|
||||
}
|
||||
if (currentStep === 1 || currentStep === 2) return 'League setup';
|
||||
if (currentStep >= 3 && currentStep <= 6) return 'Season setup';
|
||||
return 'League & Season summary';
|
||||
};
|
||||
|
||||
const currentStepData = steps.find((s) => s.id === step);
|
||||
const CurrentStepIcon = currentStepData?.icon ?? FileText;
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} maxWidth="4xl" mx="auto" pb={8}>
|
||||
{/* Header with icon */}
|
||||
<Box mb={8}>
|
||||
<Stack direction="row" align="center" gap={3} mb={3}>
|
||||
<Box display="flex" h="11" w="11" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/20" border borderColor="border-primary-blue/20">
|
||||
<Icon icon={Sparkles} size={5} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={1} fontSize={{ base: '2xl', sm: '3xl' }}>
|
||||
Create a new league
|
||||
</Heading>
|
||||
<Text size="sm" color="text-gray-500" block>
|
||||
We'll also set up your first season in {steps.length} easy steps.
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>
|
||||
A league is your long-term brand. Each season is a block of races you can run again and again.
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Desktop Progress Bar */}
|
||||
<Box display={{ base: 'none', md: 'block' }} mb={8}>
|
||||
<Box position="relative">
|
||||
{/* Background track */}
|
||||
<Box position="absolute" top="5" left="6" right="6" h="0.5" bg="bg-charcoal-outline" rounded="full" />
|
||||
{/* Progress fill */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="5"
|
||||
left="6"
|
||||
h="0.5"
|
||||
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
|
||||
rounded="full"
|
||||
transition
|
||||
width={`calc(${((step - 1) / (steps.length - 1)) * 100}% - 48px)`}
|
||||
/>
|
||||
|
||||
<Box position="relative" display="flex" justifyContent="between">
|
||||
{steps.map((wizardStep) => {
|
||||
const isCompleted = wizardStep.id < step;
|
||||
const isCurrent = wizardStep.id === step;
|
||||
const isAccessible = wizardStep.id <= highestCompletedStep;
|
||||
const StepIcon = wizardStep.icon;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
key={wizardStep.id}
|
||||
type="button"
|
||||
onClick={() => goToStep(wizardStep.id)}
|
||||
disabled={!isAccessible}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
bg="bg-transparent"
|
||||
borderStyle="none"
|
||||
cursor={isAccessible ? 'pointer' : 'not-allowed'}
|
||||
opacity={!isAccessible ? 0.6 : 1}
|
||||
>
|
||||
<Box
|
||||
position="relative"
|
||||
zIndex={10}
|
||||
display="flex"
|
||||
h="10"
|
||||
w="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
transition
|
||||
bg={isCurrent || isCompleted ? 'bg-primary-blue' : 'bg-iron-gray'}
|
||||
color={isCurrent || isCompleted ? 'text-white' : 'text-gray-400'}
|
||||
border={!isCurrent && !isCompleted}
|
||||
borderColor="border-charcoal-outline"
|
||||
shadow={isCurrent ? '0_0_24px_rgba(25,140,255,0.5)' : undefined}
|
||||
transform={isCurrent ? 'scale-110' : isCompleted ? 'hover:scale-105' : undefined}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Icon icon={Check} size={4} strokeWidth={3} />
|
||||
) : (
|
||||
<Icon icon={StepIcon} size={4} />
|
||||
)}
|
||||
</Box>
|
||||
<Box mt={2} textAlign="center">
|
||||
<Text
|
||||
size="xs"
|
||||
weight="medium"
|
||||
transition
|
||||
color={isCurrent ? 'text-white' : isCompleted ? 'text-primary-blue' : isAccessible ? 'text-gray-400' : 'text-gray-500'}
|
||||
>
|
||||
{wizardStep.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Mobile Progress */}
|
||||
<Box display={{ base: 'block', md: 'none' }} mb={6}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={2}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={CurrentStepIcon} size={4} color="text-primary-blue" />
|
||||
<Text size="sm" weight="medium" color="text-white">{currentStepData?.label}</Text>
|
||||
</Stack>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
{step}/{steps.length}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box h="1.5" bg="bg-charcoal-outline" rounded="full" overflow="hidden">
|
||||
<Box
|
||||
h="full"
|
||||
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
|
||||
rounded="full"
|
||||
transition
|
||||
height="full"
|
||||
width={`${(step / steps.length) * 100}%`}
|
||||
/>
|
||||
</Box>
|
||||
{/* Step dots */}
|
||||
<Box display="flex" justifyContent="between" mt={2} px={0.5}>
|
||||
{steps.map((s) => (
|
||||
<Box
|
||||
key={s.id}
|
||||
h="1.5"
|
||||
rounded="full"
|
||||
transition
|
||||
width={s.id === step ? '4' : '1.5'}
|
||||
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/60' : 'bg-charcoal-outline'}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Main Card */}
|
||||
<Card position="relative" overflow="hidden">
|
||||
{/* Top gradient accent */}
|
||||
<Box position="absolute" top="0" left="0" right="0" h="1" bg="bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
|
||||
|
||||
{/* Step header */}
|
||||
<Box display="flex" alignItems="start" gap={4} mb={6}>
|
||||
<Box display="flex" h="12" w="12" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10" flexShrink={0} transition>
|
||||
<Icon icon={CurrentStepIcon} size={6} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Heading level={2} fontSize={{ base: 'xl', md: '2xl' }} color="text-white">
|
||||
<Stack direction="row" align="center" gap={2} flexWrap="wrap">
|
||||
<Text>{getStepTitle(step)}</Text>
|
||||
<Text size="xs" weight="medium" px={2} py={0.5} rounded="full" border borderColor="border-charcoal-outline" bg="bg-iron-gray/60" color="text-gray-300">
|
||||
{getStepContextLabel(step)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
{getStepSubtitle(step)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box display={{ base: 'none', sm: 'flex' }} alignItems="center" gap={1.5} px={3} py={1.5} rounded="full" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
|
||||
<Text size="xs" color="text-gray-500">Step</Text>
|
||||
<Text size="sm" weight="semibold" color="text-white">{step}</Text>
|
||||
<Text size="xs" color="text-gray-500">/ {steps.length}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<Box h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" mb={6} />
|
||||
|
||||
{/* Step content with min-height for consistency */}
|
||||
<Box minHeight="320px">
|
||||
{step === 1 && (
|
||||
<Box animate="fade-in" gap={8} display="flex" flexDirection="col">
|
||||
<LeagueBasicsSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
errors={errors.basics ?? {}}
|
||||
/>
|
||||
<Box rounded="xl" border borderColor="border-charcoal-outline" bg="bg-iron-gray/40" p={4}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" gap={2} mb={2}>
|
||||
<Box>
|
||||
<Text size="xs" weight="semibold" color="text-gray-300" uppercase letterSpacing="wide">
|
||||
First season
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Name the first season that will run in this league.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box mt={2} display="flex" flexDirection="col" gap={2}>
|
||||
<Text as="label" size="sm" weight="medium" color="text-gray-300" block>
|
||||
Season name
|
||||
</Text>
|
||||
<Input
|
||||
value={form.seasonName ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
seasonName: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="e.g., Season 1 (2025)"
|
||||
/>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<Box animate="fade-in">
|
||||
<LeagueVisibilitySection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
errors={
|
||||
errors.basics?.visibility
|
||||
? { visibility: errors.basics.visibility }
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
|
||||
<Box mb={2}>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</Text>
|
||||
</Box>
|
||||
<LeagueStructureSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
readOnly={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
|
||||
<Box mb={2}>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</Text>
|
||||
</Box>
|
||||
<LeagueTimingsSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
errors={errors.timings ?? {}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<Box animate="fade-in" display="flex" flexDirection="col" gap={8}>
|
||||
<Box mb={2}>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Scoring Pattern Selection */}
|
||||
<ScoringPatternSection
|
||||
scoring={form.scoring || {}}
|
||||
presets={presets}
|
||||
readOnly={presetsLoading}
|
||||
patternError={errors.scoring?.patternId ?? ''}
|
||||
onChangePatternId={handleScoringPresetChange}
|
||||
onToggleCustomScoring={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
scoring: {
|
||||
...prev.scoring,
|
||||
customScoringEnabled: !(prev.scoring?.customScoringEnabled),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<Box h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
|
||||
|
||||
{/* Championships & Drop Rules side by side on larger screens */}
|
||||
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={6}>
|
||||
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
|
||||
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
|
||||
</Box>
|
||||
|
||||
{errors.submit && (
|
||||
<Box display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
|
||||
<Icon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
|
||||
<Text size="sm" color="text-warning-amber">{errors.submit}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
|
||||
<Box mb={2}>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</Text>
|
||||
</Box>
|
||||
<LeagueStewardingSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
readOnly={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 7 && (
|
||||
<Box animate="fade-in" display="flex" flexDirection="col" gap={6}>
|
||||
<LeagueReviewSummary form={form} presets={presets} />
|
||||
{errors.submit && (
|
||||
<Box display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
|
||||
<Icon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
|
||||
<Text size="sm" color="text-warning-amber">{errors.submit}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mt={6}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={step === 1 || loading}
|
||||
onClick={goToPreviousStep}
|
||||
icon={<Icon icon={ChevronLeft} size={4} />}
|
||||
>
|
||||
<Text display={{ base: 'none', md: 'inline-block' }}>Back</Text>
|
||||
</Button>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
{/* Mobile step dots */}
|
||||
<Box display={{ base: 'flex', sm: 'none' }} alignItems="center" gap={1}>
|
||||
{steps.map((s) => (
|
||||
<Box
|
||||
key={s.id}
|
||||
h="1.5"
|
||||
rounded="full"
|
||||
transition
|
||||
width={s.id === step ? '3' : '1.5'}
|
||||
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/50' : 'bg-charcoal-outline'}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{step < 7 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
onClick={goToNextStep}
|
||||
icon={<Icon icon={ChevronRight} size={4} />}
|
||||
flexDirection="row-reverse"
|
||||
>
|
||||
<Text>Continue</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
minWidth="150px"
|
||||
justifyContent="center"
|
||||
icon={loading ? <Icon icon={Loader2} size={4} animate="spin" /> : <Icon icon={Sparkles} size={4} />}
|
||||
>
|
||||
{loading ? (
|
||||
<Text>Creating…</Text>
|
||||
) : (
|
||||
<Text>Create League</Text>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Helper text */}
|
||||
<Text size="xs" color="text-gray-500" align="center" block mt={4}>
|
||||
This will create your league and its first season. You can edit both later.
|
||||
</Text>
|
||||
</Box>
|
||||
<CreateLeagueWizardTemplate
|
||||
viewData={{}}
|
||||
step={step}
|
||||
steps={steps}
|
||||
form={form}
|
||||
errors={errors}
|
||||
loading={loading}
|
||||
presetsLoading={presetsLoading}
|
||||
presets={presets}
|
||||
highestCompletedStep={highestCompletedStep}
|
||||
onGoToStep={goToStep}
|
||||
onFormChange={setForm}
|
||||
onSubmit={handleSubmit}
|
||||
onNextStep={goToNextStep}
|
||||
onPreviousStep={goToPreviousStep}
|
||||
onScoringPresetChange={handleScoringPresetChange}
|
||||
onToggleCustomScoring={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
scoring: {
|
||||
...prev.scoring,
|
||||
customScoringEnabled: !(prev.scoring?.customScoringEnabled),
|
||||
},
|
||||
}))
|
||||
}
|
||||
getStepTitle={getStepTitle}
|
||||
getStepSubtitle={getStepSubtitle}
|
||||
getStepContextLabel={getStepContextLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
import type { ProfileTab } from '@/components/profile/ProfileTabs';
|
||||
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
|
||||
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ErrorTemplate, EmptyTemplate } from '@/templates/shared/StatusTemplates';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface DriverProfilePageClientProps {
|
||||
viewData: DriverProfileViewData | null;
|
||||
@@ -18,13 +17,6 @@ interface DriverProfilePageClientProps {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DriverProfilePageClient
|
||||
*
|
||||
* Client component that:
|
||||
* 1. Handles UI state (tabs, friend requests)
|
||||
* 2. Passes ViewData directly to Template
|
||||
*/
|
||||
export function DriverProfilePageClient({ viewData, error, empty }: DriverProfilePageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -44,24 +36,22 @@ export function DriverProfilePageClient({ viewData, error, empty }: DriverProfil
|
||||
// Handle error/empty states
|
||||
if (error) {
|
||||
return (
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center" gap={4}>
|
||||
<Text color="text-red-400">Error loading driver profile</Text>
|
||||
<Text color="text-gray-400">Please try again later</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
<ErrorTemplate
|
||||
viewData={{}}
|
||||
message="Error loading driver profile"
|
||||
description="Please try again later"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!viewData || !viewData.currentDriver) {
|
||||
if (empty) {
|
||||
return (
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center" gap={2}>
|
||||
<Text size="xl" weight="semibold" color="text-white">{empty.title}</Text>
|
||||
<Text color="text-gray-400">{empty.description}</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
<EmptyTemplate
|
||||
viewData={{}}
|
||||
title={empty.title}
|
||||
description={empty.description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -78,4 +68,4 @@ export function DriverProfilePageClient({ viewData, error, empty }: DriverProfil
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,9 @@ import { useRouter } from 'next/navigation';
|
||||
import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface DriverRankingsPageClientProps {
|
||||
viewData: DriverRankingsViewData;
|
||||
}
|
||||
|
||||
export function DriverRankingsPageClient({ viewData }: DriverRankingsPageClientProps) {
|
||||
export function DriverRankingsPageClient({ viewData }: ClientWrapperProps<DriverRankingsViewData>) {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
|
||||
import { DriversTemplate } from '@/templates/DriversTemplate';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ErrorTemplate, EmptyTemplate } from '@/templates/shared/StatusTemplates';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface DriversPageClientProps {
|
||||
viewData: DriversViewData | null;
|
||||
@@ -19,14 +17,6 @@ interface DriversPageClientProps {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DriversPageClient
|
||||
*
|
||||
* Client component that:
|
||||
* 1. Manages search state
|
||||
* 2. Filters drivers based on search
|
||||
* 3. Passes ViewData to Template
|
||||
*/
|
||||
export function DriversPageClient({ viewData, error, empty }: DriversPageClientProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const router = useRouter();
|
||||
@@ -53,24 +43,22 @@ export function DriversPageClient({ viewData, error, empty }: DriversPageClientP
|
||||
// Handle error/empty states
|
||||
if (error) {
|
||||
return (
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center" gap={4}>
|
||||
<Text color="text-red-400">Error loading drivers</Text>
|
||||
<Text color="text-gray-400">Please try again later</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
<ErrorTemplate
|
||||
viewData={{}}
|
||||
message="Error loading drivers"
|
||||
description="Please try again later"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!viewData || viewData.drivers.length === 0) {
|
||||
if (empty) {
|
||||
return (
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center" gap={2}>
|
||||
<Text size="xl" weight="semibold" color="text-white">{empty.title}</Text>
|
||||
<Text color="text-gray-400">{empty.description}</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
<EmptyTemplate
|
||||
viewData={{}}
|
||||
title={empty.title}
|
||||
description={empty.description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -86,4 +74,4 @@ export function DriversPageClient({ viewData, error, empty }: DriversPageClientP
|
||||
onViewLeaderboard={handleViewLeaderboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,9 @@ import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutat
|
||||
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
|
||||
import { ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
|
||||
import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface ForgotPasswordClientProps {
|
||||
viewData: ForgotPasswordViewData;
|
||||
}
|
||||
|
||||
export function ForgotPasswordClient({ viewData }: ForgotPasswordClientProps) {
|
||||
export function ForgotPasswordClient({ viewData }: ClientWrapperProps<ForgotPasswordViewData>) {
|
||||
// Build ViewModel from ViewData
|
||||
const [viewModel, setViewModel] = useState<ForgotPasswordViewModel>(() =>
|
||||
ForgotPasswordViewModelBuilder.build(viewData)
|
||||
@@ -127,4 +124,4 @@ export function ForgotPasswordClient({ viewData }: ForgotPasswordClientProps) {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,9 @@ import { useRouter } from 'next/navigation';
|
||||
import { LeaderboardsTemplate } from '@/templates/LeaderboardsTemplate';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface LeaderboardsPageClientProps {
|
||||
viewData: LeaderboardsViewData;
|
||||
}
|
||||
|
||||
export function LeaderboardsPageClient({ viewData }: LeaderboardsPageClientProps) {
|
||||
export function LeaderboardsPageClient({ viewData }: ClientWrapperProps<LeaderboardsViewData>) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleDriverClick = (id: string) => {
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
unpublishScheduleAction,
|
||||
updateRaceAction
|
||||
} from '@/app/actions/leagueScheduleActions';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { ConfirmDialog } from '@/ui/ConfirmDialog';
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { SharedConfirmDialog, SharedStack, SharedCard, SharedBox, SharedText, SharedHeading } from '@/components/shared/UIComponents';
|
||||
import {
|
||||
useLeagueAdminSchedule,
|
||||
useLeagueAdminStatus,
|
||||
@@ -17,11 +17,6 @@ import {
|
||||
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
|
||||
import { RaceScheduleCommandModel } from '@/lib/command-models/leagues/RaceScheduleCommandModel';
|
||||
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -177,74 +172,67 @@ export function LeagueAdminSchedulePageClient() {
|
||||
// Render admin access required if not admin
|
||||
if (!isLoading && !isAdmin) {
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Card>
|
||||
<Box p={6} textAlign="center">
|
||||
<SharedStack gap={6}>
|
||||
<SharedCard>
|
||||
<SharedBox p={6} textAlign="center">
|
||||
<Heading level={3}>Admin Access Required</Heading>
|
||||
<Box mt={2}>
|
||||
<Text size="sm" color="text-gray-400">Only league admins can manage the schedule.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</Stack>
|
||||
<SharedBox mt={2}>
|
||||
<SharedText size="sm" color="text-gray-400">Only league admins can manage the schedule.</SharedText>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedCard>
|
||||
</SharedStack>
|
||||
);
|
||||
}
|
||||
|
||||
// Template component that wraps the actual template with all props
|
||||
const TemplateWrapper = ({ data }: { data: typeof templateData }) => {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LeagueAdminScheduleTemplate
|
||||
viewData={data}
|
||||
onSeasonChange={handleSeasonChange}
|
||||
onPublishToggle={handlePublishToggle}
|
||||
onAddOrSave={handleAddOrSave}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
track={form.track}
|
||||
car={form.car}
|
||||
scheduledAtIso={form.scheduledAtIso}
|
||||
editingRaceId={editingRaceId}
|
||||
isPublishing={isPublishing}
|
||||
isSaving={isSaving}
|
||||
isDeleting={deletingRaceId}
|
||||
error={error}
|
||||
setTrack={(val) => {
|
||||
form.track = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
setCar={(val) => {
|
||||
form.car = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
setScheduledAtIso={(val) => {
|
||||
form.scheduledAtIso = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={!!raceToDelete}
|
||||
onClose={() => setRaceToDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Race"
|
||||
description="Are you sure you want to delete this race? This will remove it from the schedule and cannot be undone."
|
||||
confirmLabel="Delete Race"
|
||||
variant="danger"
|
||||
isLoading={!!deletingRaceId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
<StatefulPageWrapper
|
||||
data={templateData}
|
||||
isLoading={isLoading}
|
||||
error={null}
|
||||
Template={TemplateWrapper}
|
||||
Template={({ data }) => (
|
||||
<>
|
||||
<LeagueAdminScheduleTemplate
|
||||
viewData={data}
|
||||
onSeasonChange={handleSeasonChange}
|
||||
onPublishToggle={handlePublishToggle}
|
||||
onAddOrSave={handleAddOrSave}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
track={form.track}
|
||||
car={form.car}
|
||||
scheduledAtIso={form.scheduledAtIso}
|
||||
editingRaceId={editingRaceId}
|
||||
isPublishing={isPublishing}
|
||||
isSaving={isSaving}
|
||||
isDeleting={deletingRaceId}
|
||||
error={error}
|
||||
setTrack={(val) => {
|
||||
form.track = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
setCar={(val) => {
|
||||
form.car = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
setScheduledAtIso={(val) => {
|
||||
form.scheduledAtIso = val;
|
||||
setForm(new RaceScheduleCommandModel(form.toCommand()));
|
||||
}}
|
||||
/>
|
||||
<SharedConfirmDialog
|
||||
isOpen={!!raceToDelete}
|
||||
onClose={() => setRaceToDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Race"
|
||||
description="Are you sure you want to delete this race? This will remove it from the schedule and cannot be undone."
|
||||
confirmLabel="Delete Race"
|
||||
variant="danger"
|
||||
isLoading={!!deletingRaceId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
loading={{ variant: 'full-screen', message: 'Loading schedule admin...' }}
|
||||
empty={{
|
||||
title: 'No schedule data available',
|
||||
|
||||
@@ -4,12 +4,9 @@ import React, { useState } from 'react';
|
||||
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
|
||||
import { type RulebookSection } from '@/components/leagues/RulebookTabs';
|
||||
import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface LeagueRulebookPageClientProps {
|
||||
viewData: LeagueRulebookViewData;
|
||||
}
|
||||
|
||||
export function LeagueRulebookPageClient({ viewData }: LeagueRulebookPageClientProps) {
|
||||
export function LeagueRulebookPageClient({ viewData }: ClientWrapperProps<LeagueRulebookViewData>) {
|
||||
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { WalletSummaryPanel } from '@/components/leagues/WalletSummaryPanel';
|
||||
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon as UIIcon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import {
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { LeagueWalletTemplate } from '@/templates/LeagueWalletTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface WalletTemplateProps {
|
||||
viewData: LeagueWalletViewData;
|
||||
interface LeagueWalletPageClientProps extends ClientWrapperProps<LeagueWalletViewData> {
|
||||
onWithdraw?: (amount: number) => void;
|
||||
onExport?: () => void;
|
||||
mutationLoading?: boolean;
|
||||
}
|
||||
|
||||
export function LeagueWalletPageClient({ viewData, onExport }: WalletTemplateProps) {
|
||||
export function LeagueWalletPageClient({ viewData, onExport }: LeagueWalletPageClientProps) {
|
||||
// Map transactions to the format expected by WalletSummaryPanel
|
||||
const transactions = viewData.transactions.map(t => ({
|
||||
id: t.id,
|
||||
@@ -31,36 +21,10 @@ export function LeagueWalletPageClient({ viewData, onExport }: WalletTemplatePro
|
||||
}));
|
||||
|
||||
return (
|
||||
<Container size="lg" py={8}>
|
||||
{/* Header */}
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={8}>
|
||||
<Box>
|
||||
<Heading level={1}>League Wallet</Heading>
|
||||
<Text color="text-gray-400">Manage your league's finances and payouts</Text>
|
||||
</Box>
|
||||
<Button variant="secondary" onClick={onExport}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<UIIcon icon={Download} size={4} />
|
||||
<Text>Export</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<WalletSummaryPanel
|
||||
balance={viewData.balance}
|
||||
currency="USD"
|
||||
transactions={transactions}
|
||||
onDeposit={() => {}} // Not implemented for leagues yet
|
||||
onWithdraw={() => {}} // Not implemented for leagues yet
|
||||
/>
|
||||
|
||||
{/* Alpha Notice */}
|
||||
<Box mt={6} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
|
||||
<Text size="xs" color="text-gray-400">
|
||||
<Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Wallet management is demonstration-only.
|
||||
Real payment processing and bank integrations will be available when the payment system is fully implemented.
|
||||
</Text>
|
||||
</Box>
|
||||
</Container>
|
||||
<LeagueWalletTemplate
|
||||
viewData={viewData}
|
||||
onExport={onExport}
|
||||
transactions={transactions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import {
|
||||
Award,
|
||||
Clock,
|
||||
Flag,
|
||||
Flame,
|
||||
Globe,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Target,
|
||||
Timer,
|
||||
Trophy,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
type CategoryId =
|
||||
| 'all'
|
||||
| 'driver'
|
||||
| 'team'
|
||||
| 'nations'
|
||||
| 'trophy'
|
||||
| 'new'
|
||||
| 'popular'
|
||||
| 'openSlots'
|
||||
| 'endurance'
|
||||
| 'sprint';
|
||||
|
||||
interface Category {
|
||||
id: CategoryId;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
filter: (league: LeaguesViewData['leagues'][number]) => boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface LeaguesTemplateProps {
|
||||
viewData: LeaguesViewData;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CATEGORIES
|
||||
// ============================================================================
|
||||
import { LeaguesTemplate, Category, CategoryId } from '@/templates/LeaguesTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
const CATEGORIES: Category[] = [
|
||||
{
|
||||
@@ -157,7 +115,7 @@ const CATEGORIES: Category[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) {
|
||||
export function LeaguesPageClient({ viewData }: ClientWrapperProps<LeaguesViewData>) {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
|
||||
@@ -174,107 +132,17 @@ export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Box minHeight="screen" bg="zinc-950" color="text-zinc-200">
|
||||
<Box maxWidth="7xl" mx="auto" px={{ base: 4, sm: 6, lg: 8 }} py={12}>
|
||||
{/* Hero */}
|
||||
<Box as="header" display="flex" flexDirection={{ base: 'col', md: 'row' }} alignItems={{ base: 'start', md: 'end' }} justifyContent="between" gap={8} mb={16}>
|
||||
<Stack gap={4}>
|
||||
<Box display="flex" alignItems="center" gap={3} color="text-blue-500">
|
||||
<Trophy size={24} />
|
||||
<Text fontSize="xs" weight="bold" uppercase letterSpacing="widest">Competition Hub</Text>
|
||||
</Box>
|
||||
<Heading level={1} fontSize="5xl" weight="bold" color="text-white">
|
||||
Find Your <Text as="span" color="text-blue-500">Grid</Text>
|
||||
</Heading>
|
||||
<Text color="text-zinc-400" maxWidth="md" leading="relaxed">
|
||||
From casual sprints to epic endurance battles — discover the perfect league for your racing style.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box display="flex" flexDirection="col" alignItems="end">
|
||||
<Text fontSize="2xl" weight="bold" color="text-white" font="mono">{viewData.leagues.length}</Text>
|
||||
<Text weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Active Leagues</Text>
|
||||
</Box>
|
||||
<Box w="px" h="8" bg="zinc-800" />
|
||||
<Button
|
||||
onClick={() => router.push(routes.league.create)}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Plus size={16} />
|
||||
Create League
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<Box as="section" display="flex" flexDirection="col" gap={8} mb={12}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search leagues by name, description, or game..."
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||
icon={<Search size={20} />}
|
||||
/>
|
||||
|
||||
<Box as="nav" display="flex" flexWrap="wrap" gap={2}>
|
||||
{CATEGORIES.map((category) => {
|
||||
const isActive = activeCategory === category.id;
|
||||
const CategoryIcon = category.icon;
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
variant={isActive ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Box
|
||||
color={!isActive && category.color ? category.color : undefined}
|
||||
>
|
||||
<CategoryIcon size={14} />
|
||||
</Box>
|
||||
<Text>{category.label}</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Grid */}
|
||||
<Box as="main">
|
||||
{filteredLeagues.length > 0 ? (
|
||||
<Box display="grid" responsiveGridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
|
||||
{filteredLeagues.map((league) => (
|
||||
<LeagueCard
|
||||
key={league.id}
|
||||
league={league as unknown as LeagueSummaryViewModel}
|
||||
onClick={() => router.push(routes.league.detail(league.id))}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box display="flex" flexDirection="col" alignItems="center" justifyContent="center" py={24} border borderStyle="dashed" borderColor="zinc-800" bg="zinc-900/20">
|
||||
<Box color="text-zinc-800" mb={4}>
|
||||
<Search size={48} />
|
||||
</Box>
|
||||
<Heading level={3} fontSize="xl" weight="bold" color="text-zinc-500">No Leagues Found</Heading>
|
||||
<Text color="text-zinc-600" size="sm" mt={2}>Try adjusting your search or filters</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
mt={6}
|
||||
onClick={() => { setSearchQuery(''); setActiveCategory('all'); }}
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<LeaguesTemplate
|
||||
viewData={viewData}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={setActiveCategory}
|
||||
filteredLeagues={filteredLeagues}
|
||||
categories={CATEGORIES}
|
||||
onCreateLeague={() => router.push(routes.league.create)}
|
||||
onLeagueClick={(id) => router.push(routes.league.detail(id))}
|
||||
onClearFilters={() => { setSearchQuery(''); setActiveCategory('all'); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/components/auth/AuthContext';
|
||||
import { AuthLoading } from '@/components/auth/AuthLoading';
|
||||
import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController';
|
||||
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
|
||||
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
|
||||
@@ -16,14 +15,12 @@ import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
|
||||
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
|
||||
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
|
||||
import { LoginTemplate } from '@/templates/auth/LoginTemplate';
|
||||
import { LoginLoadingTemplate } from '@/templates/auth/LoginLoadingTemplate';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface LoginClientProps {
|
||||
viewData: LoginViewData;
|
||||
}
|
||||
|
||||
export function LoginClient({ viewData }: LoginClientProps) {
|
||||
export function LoginClient({ viewData }: ClientWrapperProps<LoginViewData>) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { refreshSession, session } = useAuth();
|
||||
@@ -185,7 +182,7 @@ export function LoginClient({ viewData }: LoginClientProps) {
|
||||
|
||||
// If user is authenticated with permissions, show loading
|
||||
if (state === LoginState.AUTHENTICATED_WITH_PERMISSIONS) {
|
||||
return <AuthLoading />;
|
||||
return <LoginLoadingTemplate viewData={{}} />;
|
||||
}
|
||||
|
||||
// If user has insufficient permissions, show permission error
|
||||
@@ -264,4 +261,4 @@ export function LoginClient({ viewData }: LoginClientProps) {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import { MediaTemplate } from '@/templates/MediaTemplate';
|
||||
import { MediaAsset } from '@/components/media/MediaGallery';
|
||||
import { MediaViewData } from '@/lib/view-data/MediaViewData';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
export interface MediaPageClientProps {
|
||||
initialAssets: MediaAsset[];
|
||||
categories: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
export function MediaPageClient({
|
||||
initialAssets,
|
||||
categories,
|
||||
}: MediaPageClientProps) {
|
||||
export function MediaPageClient({ viewData }: ClientWrapperProps<MediaViewData>) {
|
||||
return (
|
||||
<MediaTemplate
|
||||
assets={initialAssets}
|
||||
categories={categories}
|
||||
title="Media Library"
|
||||
description="Manage and view all racing assets, telemetry captures, and brand identities."
|
||||
viewData={viewData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { NotFoundTemplate, type NotFoundViewData } from '@/templates/NotFoundTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
/**
|
||||
* NotFoundPageClient
|
||||
@@ -11,14 +12,14 @@ import { NotFoundTemplate, type NotFoundViewData } from '@/templates/NotFoundTem
|
||||
* Client-side entry point for the 404 page.
|
||||
* Manages navigation logic and wires it to the template.
|
||||
*/
|
||||
export function NotFoundPageClient() {
|
||||
export function NotFoundPageClient({ viewData: initialViewData }: Partial<ClientWrapperProps<NotFoundViewData>>) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleHomeClick = () => {
|
||||
router.push(routes.public.home);
|
||||
};
|
||||
|
||||
const viewData: NotFoundViewData = {
|
||||
const viewData: NotFoundViewData = initialViewData ?? {
|
||||
errorCode: 'Error 404',
|
||||
title: 'OFF TRACK',
|
||||
message: 'The requested sector does not exist. You have been returned to the pits.',
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useAuth } from '@/components/auth/AuthContext';
|
||||
import { useState } from 'react';
|
||||
import { PersonalInfo } from '@/components/onboarding/PersonalInfoStep';
|
||||
import { AvatarInfo } from '@/components/onboarding/AvatarStep';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
type OnboardingStep = 1 | 2;
|
||||
|
||||
@@ -22,7 +24,7 @@ interface FormErrors {
|
||||
submit?: string;
|
||||
}
|
||||
|
||||
export function OnboardingWizardClient() {
|
||||
export function OnboardingWizardClient({ viewData: initialViewData }: Partial<ClientWrapperProps<ViewData>>) {
|
||||
const { session } = useAuth();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [step, setStep] = useState<OnboardingStep>(1);
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { UploadDropzone } from '@/ui/UploadDropzone';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { MediaMetaPanel, mapMediaMetadata } from '@/ui/MediaMetaPanel';
|
||||
import { MediaPreviewCard } from '@/ui/MediaPreviewCard';
|
||||
import { Text } from '@/ui/Text';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { ProfileLiveryUploadTemplate } from '@/templates/ProfileLiveryUploadTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export function ProfileLiveryUploadPageClient() {
|
||||
export function ProfileLiveryUploadPageClient({ viewData: initialViewData }: Partial<ClientWrapperProps<ViewData>>) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -40,70 +32,13 @@ export function ProfileLiveryUploadPageClient() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="md">
|
||||
<Box mb={6}>
|
||||
<Heading level={1}>Upload livery</Heading>
|
||||
<Text color="text-gray-500">
|
||||
Upload your custom car livery. Supported formats: .png, .jpg, .tga
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
|
||||
<Box>
|
||||
<Card>
|
||||
<UploadDropzone
|
||||
onFilesSelected={handleFilesSelected}
|
||||
accept=".png,.jpg,.jpeg,.tga"
|
||||
maxSize={10 * 1024 * 1024} // 10MB
|
||||
isLoading={isUploading}
|
||||
/>
|
||||
|
||||
<Box mt={6} display="flex" justifyContent="end" gap={3}>
|
||||
<Link href={routes.protected.profileLiveries}>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!selectedFile || isUploading}
|
||||
onClick={handleUpload}
|
||||
isLoading={isUploading}
|
||||
>
|
||||
Upload Livery
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{previewUrl ? (
|
||||
<Box display="flex" flexDirection="col" gap={6}>
|
||||
<MediaPreviewCard
|
||||
type="image"
|
||||
src={previewUrl}
|
||||
alt={selectedFile?.name || 'Livery preview'}
|
||||
title={selectedFile?.name}
|
||||
subtitle="Preview"
|
||||
aspectRatio="16/9"
|
||||
/>
|
||||
|
||||
<MediaMetaPanel
|
||||
items={mapMediaMetadata({
|
||||
filename: selectedFile?.name,
|
||||
size: selectedFile?.size,
|
||||
contentType: selectedFile?.type || 'image/tga',
|
||||
createdAt: new Date(),
|
||||
})}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Card center p={12}>
|
||||
<Text color="text-gray-500" align="center">
|
||||
Select a file to see preview and details
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
<ProfileLiveryUploadTemplate
|
||||
viewData={{}}
|
||||
selectedFile={selectedFile}
|
||||
previewUrl={previewUrl}
|
||||
isUploading={isUploading}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
onUpload={handleUpload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ProfileTemplate } from '@/templates/ProfileTemplate';
|
||||
import { type ProfileTab } from '@/components/profile/ProfileNavTabs';
|
||||
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface ProfilePageClientProps {
|
||||
viewData: ProfileViewData;
|
||||
interface ProfilePageClientProps extends ClientWrapperProps<ProfileViewData> {
|
||||
mode: 'profile-exists' | 'needs-profile';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { InlineNotice } from '@/ui/InlineNotice';
|
||||
import { ProgressLine } from '@/ui/ProgressLine';
|
||||
import type { Result } from '@/lib/contracts/Result';
|
||||
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
|
||||
import { ProfileSettingsTemplate } from '@/templates/ProfileSettingsTemplate';
|
||||
import { Box } from '@/ui/Box';
|
||||
import {
|
||||
SharedBox,
|
||||
SharedStack,
|
||||
SharedText,
|
||||
SharedIcon,
|
||||
SharedProgressLine
|
||||
} from '@/components/shared/UIComponents';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface ProfileSettingsPageClientProps {
|
||||
viewData: ProfileViewData;
|
||||
interface ProfileSettingsPageClientProps extends ClientWrapperProps<ProfileViewData> {
|
||||
onSave: (updates: { bio?: string; country?: string }) => Promise<Result<void, string>>;
|
||||
}
|
||||
|
||||
@@ -41,15 +46,19 @@ export function ProfileSettingsPageClient({ viewData, onSave }: ProfileSettingsP
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProgressLine isLoading={isSaving} />
|
||||
<SharedProgressLine isLoading={isSaving} />
|
||||
{error && (
|
||||
<Box position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
|
||||
<InlineNotice
|
||||
variant="error"
|
||||
title="Update Failed"
|
||||
message={error}
|
||||
/>
|
||||
</Box>
|
||||
<SharedBox position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
|
||||
<SharedBox bg="bg-error-red/10" p={4} rounded="md" border borderColor="border-error-red/20">
|
||||
<SharedStack direction="row" align="center" gap={3}>
|
||||
<SharedIcon icon={ShieldAlert} size={5} color="text-error-red" />
|
||||
<SharedBox>
|
||||
<SharedText weight="bold" color="text-error-red">Update Failed</SharedText>
|
||||
<SharedText size="sm" color="text-error-red/80">{error}</SharedText>
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
)}
|
||||
<ProfileSettingsTemplate
|
||||
viewData={viewData}
|
||||
|
||||
@@ -1,66 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
|
||||
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Flag,
|
||||
Gavel,
|
||||
Grid3x3,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Send,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
TrendingDown,
|
||||
User,
|
||||
Video,
|
||||
XCircle,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// Shared state components
|
||||
import { LoadingWrapper } from '@/ui/LoadingWrapper';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useLeagueAdminStatus } from "@/hooks/league/useLeagueAdminStatus";
|
||||
import { useProtestDetail } from "@/hooks/league/useProtestDetail";
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon as UIIcon } from '@/ui/Icon';
|
||||
import { Link as UILink } from '@/ui/Link';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ProtestDetailTemplate } from '@/templates/ProtestDetailTemplate';
|
||||
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
const GridItem = ({ children, colSpan, lgSpan }: { children: React.ReactNode; colSpan?: number; lgSpan?: number }) => (
|
||||
<Box gridCols={colSpan} style={{ gridColumn: `span ${colSpan} / span ${colSpan}` }} className={lgSpan ? `lg:col-span-${lgSpan}` : ''}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
type PenaltyUiConfig = {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
defaultValue?: number;
|
||||
};
|
||||
|
||||
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
|
||||
const PENALTY_UI: Record<string, any> = {
|
||||
time_penalty: {
|
||||
label: 'Time Penalty',
|
||||
description: 'Add seconds to race result',
|
||||
@@ -105,7 +72,7 @@ const PENALTY_UI: Record<string, PenaltyUiConfig> = {
|
||||
},
|
||||
};
|
||||
|
||||
export function ProtestDetailPageClient({ initialViewData }: { initialViewData: unknown }) {
|
||||
export function ProtestDetailPageClient({ viewData: initialViewData }: Partial<ClientWrapperProps<ViewData>>) {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
@@ -129,7 +96,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
|
||||
const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false);
|
||||
|
||||
// Use initial data if available
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const protestDetail = (detail || initialViewData) as any;
|
||||
|
||||
// Set initial penalty values when data loads
|
||||
@@ -142,7 +108,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
|
||||
|
||||
const penaltyTypes = useMemo(() => {
|
||||
const referenceItems = protestDetail?.penaltyTypes ?? [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return referenceItems.map((ref: any) => {
|
||||
const ui = PENALTY_UI[ref.type] ?? {
|
||||
icon: Gavel,
|
||||
@@ -158,7 +123,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
|
||||
}, [protestDetail?.penaltyTypes]);
|
||||
|
||||
const selectedPenalty = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return penaltyTypes.find((p: any) => p.type === penaltyType);
|
||||
}, [penaltyTypes, penaltyType]);
|
||||
|
||||
@@ -182,11 +146,7 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
|
||||
stewardNotes,
|
||||
});
|
||||
|
||||
const options: {
|
||||
requiresValue?: boolean;
|
||||
defaultUpheldReason?: string;
|
||||
defaultDismissedReason?: string;
|
||||
} = { requiresValue };
|
||||
const options: any = { requiresValue };
|
||||
|
||||
if (defaultUpheldReason) {
|
||||
options.defaultUpheldReason = defaultUpheldReason;
|
||||
@@ -208,7 +168,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
|
||||
throw new Error(result.getError().message);
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const warningRef = protestDetail.penaltyTypes.find((p: any) => p.type === 'warning');
|
||||
const requiresValue = warningRef?.requiresValue ?? false;
|
||||
|
||||
@@ -219,11 +178,7 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
|
||||
stewardNotes,
|
||||
});
|
||||
|
||||
const options: {
|
||||
requiresValue?: boolean;
|
||||
defaultUpheldReason?: string;
|
||||
defaultDismissedReason?: string;
|
||||
} = { requiresValue };
|
||||
const options: any = { requiresValue };
|
||||
|
||||
if (defaultUpheldReason) {
|
||||
options.defaultUpheldReason = defaultUpheldReason;
|
||||
@@ -258,7 +213,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
|
||||
if (!protestDetail || !currentDriverId) return;
|
||||
|
||||
try {
|
||||
// Request defense
|
||||
const result = await protestService.requestDefense({
|
||||
protestId: protestDetail.protest?.id || protestDetail.protestId,
|
||||
stewardId: currentDriverId,
|
||||
@@ -266,8 +220,6 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.getError().message);
|
||||
}
|
||||
|
||||
// Reload page to show updated status
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to request defense');
|
||||
@@ -291,519 +243,29 @@ export function ProtestDetailPageClient({ initialViewData }: { initialViewData:
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading for admin check
|
||||
if (adminLoading) {
|
||||
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
|
||||
}
|
||||
|
||||
// Show access denied if not admin
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<Box p={12} textAlign="center">
|
||||
<Box w={16} h={16} mx="auto" mb={4} rounded="full" bg="bg-iron-gray/50" display="flex" alignItems="center" justifyContent="center">
|
||||
<UIIcon icon={AlertTriangle} size={8} color="text-warning-amber" />
|
||||
</Box>
|
||||
<Heading level={3}>Admin Access Required</Heading>
|
||||
<Box mt={2}>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Only league admins can review protests.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={protestDetail}
|
||||
isLoading={detailLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'spinner', message: 'Loading protest details...' },
|
||||
error: { variant: 'full-screen' },
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{(pd: any) => {
|
||||
if (!pd) return null;
|
||||
|
||||
const protest = pd.protest || pd;
|
||||
const race = pd.race;
|
||||
const protestingDriver = pd.protestingDriver;
|
||||
const accusedDriver = pd.accusedDriver;
|
||||
|
||||
const statusConfig = getStatusConfig(protest.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const isPending = protest.status === 'pending';
|
||||
const submittedAt = protest.submittedAt || pd.submittedAt;
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(submittedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<Box minHeight="100vh">
|
||||
{/* Compact Header */}
|
||||
<Box mb={6}>
|
||||
<Stack direction="row" align="center" gap={3} mb={4}>
|
||||
<UILink href={routes.league.stewarding(leagueId)}>
|
||||
<UIIcon icon={ArrowLeft} size={5} color="text-gray-400" />
|
||||
</UILink>
|
||||
<Stack direction="row" align="center" gap={3} flexGrow={1}>
|
||||
<Heading level={1}>Protest Review</Heading>
|
||||
<Box display="flex" alignItems="center" gap={1.5} px={2.5} py={1} rounded="full" fontSize="0.75rem" weight="medium" border bg={statusConfig.bg} color={statusConfig.color} borderColor={statusConfig.borderColor}>
|
||||
<UIIcon icon={StatusIcon} size={3} />
|
||||
<Text>{statusConfig.label}</Text>
|
||||
</Box>
|
||||
{daysSinceFiled > 2 && isPending && (
|
||||
<Box display="flex" alignItems="center" gap={1} px={2} py={0.5} fontSize="0.75rem" weight="medium" bg="bg-red-500/20" color="text-red-400" rounded="full">
|
||||
<UIIcon icon={AlertTriangle} size={3} />
|
||||
<Text>{daysSinceFiled}d old</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Main Layout: Feed + Sidebar */}
|
||||
<Grid cols={12} gap={6}>
|
||||
{/* Left Sidebar - Incident Info */}
|
||||
<GridItem colSpan={12} lgSpan={3}>
|
||||
<Stack gap={4}>
|
||||
{/* Drivers Involved */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Parties Involved</Heading>
|
||||
|
||||
<Stack gap={3}>
|
||||
{/* Protesting Driver */}
|
||||
<UILink href={routes.driver.detail(protestingDriver?.id || '')} block>
|
||||
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-blue-500/50" hoverBg="bg-blue-500/5" transition cursor="pointer">
|
||||
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={User} size={5} color="text-blue-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text size="xs" color="text-blue-400" weight="medium" block>Protesting</Text>
|
||||
<Text size="sm" weight="semibold" color="text-white" truncate block>{protestingDriver?.name || 'Unknown'}</Text>
|
||||
</Box>
|
||||
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</Box>
|
||||
</UILink>
|
||||
|
||||
{/* Accused Driver */}
|
||||
<UILink href={routes.driver.detail(accusedDriver?.id || '')} block>
|
||||
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-orange-500/50" hoverBg="bg-orange-500/5" transition cursor="pointer">
|
||||
<Box w={10} h={10} rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={User} size={5} color="text-orange-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text size="xs" color="text-orange-400" weight="medium" block>Accused</Text>
|
||||
<Text size="sm" weight="semibold" color="text-white" truncate block>{accusedDriver?.name || 'Unknown'}</Text>
|
||||
</Box>
|
||||
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</Box>
|
||||
</UILink>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Race Info */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Race Details</Heading>
|
||||
|
||||
<Box marginBottom={3}>
|
||||
<UILink
|
||||
href={routes.race.detail(race?.id || '')}
|
||||
block
|
||||
>
|
||||
<Box p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-primary-blue/50" hoverBg="bg-primary-blue/5" transition>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Text size="sm" weight="medium" color="text-white">{race?.name || 'Unknown Race'}</Text>
|
||||
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</Box>
|
||||
</Box>
|
||||
</UILink>
|
||||
</Box>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<UIIcon icon={MapPin} size={4} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-300">{race?.name || 'Unknown Track'}</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<UIIcon icon={Calendar} size={4} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-300">{race?.formattedDate || (race?.scheduledAt ? new Date(race.scheduledAt).toLocaleDateString() : 'Unknown Date')}</Text>
|
||||
</Box>
|
||||
{protest.incident?.lap && (
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<UIIcon icon={Flag} size={4} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-300">Lap {protest.incident.lap}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{protest.proofVideoUrl && (
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Evidence</Heading>
|
||||
<UILink
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
block
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2} p={3} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" color="text-primary-blue" hoverBg="bg-primary-blue/20" transition>
|
||||
<UIIcon icon={Video} size={4} />
|
||||
<Text size="sm" weight="medium" flexGrow={1}>Watch Video</Text>
|
||||
<UIIcon icon={ExternalLink} size={3} />
|
||||
</Box>
|
||||
</UILink>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Timeline</Heading>
|
||||
<Stack gap={2}>
|
||||
<Box display="flex" justifyContent="between">
|
||||
<Text size="sm" color="text-gray-500">Filed</Text>
|
||||
<Text size="sm" color="text-gray-300">{new Date(submittedAt).toLocaleDateString()}</Text>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="between">
|
||||
<Text size="sm" color="text-gray-500">Age</Text>
|
||||
<Text size="sm" color={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</Text>
|
||||
</Box>
|
||||
{protest.reviewedAt && (
|
||||
<Box display="flex" justifyContent="between">
|
||||
<Text size="sm" color="text-gray-500">Resolved</Text>
|
||||
<Text size="sm" color="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
{/* Center - Discussion Feed */}
|
||||
<GridItem colSpan={12} lgSpan={6}>
|
||||
<Stack gap={4}>
|
||||
{/* Timeline / Feed */}
|
||||
<Card>
|
||||
<Box borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/30" p={4}>
|
||||
<Heading level={2}>Discussion</Heading>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0}>
|
||||
{/* Initial Protest Filing */}
|
||||
<Box p={4}>
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={AlertCircle} size={5} color="text-blue-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Box display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Text weight="semibold" color="text-white" size="sm">{protestingDriver?.name || 'Unknown'}</Text>
|
||||
<Text size="xs" color="text-blue-400" weight="medium">filed protest</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">{new Date(submittedAt).toLocaleString()}</Text>
|
||||
</Box>
|
||||
|
||||
<Box bg="bg-deep-graphite" rounded="lg" p={4} border borderColor="border-charcoal-outline">
|
||||
<Text size="sm" color="text-gray-300" block mb={3}>{protest.description || pd.incident?.description}</Text>
|
||||
|
||||
{(protest.comment || pd.comment) && (
|
||||
<Box mt={3} pt={3} borderTop borderColor="border-charcoal-outline/50">
|
||||
<Text size="xs" color="text-gray-500" block mb={1}>Additional details:</Text>
|
||||
<Text size="sm" color="text-gray-400">{protest.comment || pd.comment}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Defense placeholder */}
|
||||
{protest.status === 'awaiting_defense' && (
|
||||
<Box p={4} bg="bg-purple-500/5">
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" bg="bg-purple-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={MessageCircle} size={5} color="text-purple-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text size="sm" color="text-purple-400" weight="medium" block mb={1}>Defense Requested</Text>
|
||||
<Text size="sm" color="text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Decision (if resolved) */}
|
||||
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
|
||||
<Box p={4} bg={protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}>
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" display="flex" alignItems="center" justifyContent="center" flexShrink={0} bg={protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'}>
|
||||
<UIIcon icon={Gavel} size={5} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'} />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Box display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<Text weight="semibold" color="text-white" size="sm">Steward Decision</Text>
|
||||
<Text size="xs" weight="medium" color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
|
||||
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
|
||||
</Text>
|
||||
{protest.reviewedAt && (
|
||||
<>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box rounded="lg" p={4} border bg={protest.status === 'upheld' ? 'bg-red-500/10' : 'bg-gray-500/10'} borderColor={protest.status === 'upheld' ? 'border-red-500/20' : 'border-gray-500/20'}>
|
||||
<Text size="sm" color="text-gray-300">{protest.decisionNotes}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Add Comment */}
|
||||
{isPending && (
|
||||
<Box p={4} borderTop borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<Box display="flex" gap={3}>
|
||||
<Box w={10} h={10} rounded="full" bg="bg-iron-gray" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<UIIcon icon={User} size={5} color="text-gray-500" />
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Box as="textarea"
|
||||
value={newComment}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment or request more information..."
|
||||
style={{ height: '4rem' }}
|
||||
w="full"
|
||||
px={4}
|
||||
py={3}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
<Box display="flex" justifyContent="end" mt={2}>
|
||||
<Button variant="secondary" disabled={!newComment.trim()}>
|
||||
<UIIcon icon={Send} size={3} mr={1} />
|
||||
Comment
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
{/* Right Sidebar - Actions */}
|
||||
<GridItem colSpan={12} lgSpan={3}>
|
||||
<Stack gap={4}>
|
||||
{isPending && (
|
||||
<>
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Actions</Heading>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={handleRequestDefense}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<UIIcon icon={MessageCircle} size={4} />
|
||||
<Text>Request Defense</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2} fullWidth>
|
||||
<UIIcon icon={Gavel} size={4} />
|
||||
<Text>Make Decision</Text>
|
||||
<Box ml="auto" transition transform={showDecisionPanel ? 'rotate(180deg)' : 'none'}>
|
||||
<UIIcon icon={ChevronDown} size={4} />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Decision Panel */}
|
||||
{showDecisionPanel && (
|
||||
<Card>
|
||||
<Box p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Stewarding Decision</Heading>
|
||||
|
||||
{/* Decision Selection */}
|
||||
<Grid cols={2} gap={2} mb={4}>
|
||||
<Box padding={3} border borderColor={decision === 'uphold' ? 'border-racing-red' : 'border-charcoal-outline'} bg={decision === 'uphold' ? 'bg-racing-red/10' : 'transparent'} rounded="lg">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDecision('uphold')}
|
||||
fullWidth
|
||||
>
|
||||
<Stack align="center" gap={1}>
|
||||
<UIIcon icon={CheckCircle} size={5} color={decision === 'uphold' ? 'text-red-400' : 'text-gray-500'} />
|
||||
<Text size="xs" weight="medium" color={decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}>Uphold</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
<Box padding={3} border borderColor={decision === 'dismiss' ? 'border-gray-500' : 'border-charcoal-outline'} bg={decision === 'dismiss' ? 'bg-gray-500/10' : 'transparent'} rounded="lg">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDecision('dismiss')}
|
||||
fullWidth
|
||||
>
|
||||
<Stack align="center" gap={1}>
|
||||
<UIIcon icon={XCircle} size={5} color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'} />
|
||||
<Text size="xs" weight="medium" color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}>Dismiss</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Penalty Selection (if upholding) */}
|
||||
{decision === 'uphold' && (
|
||||
<Box mb={4}>
|
||||
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={2}>Penalty Type</Text>
|
||||
|
||||
{penaltyTypes.length === 0 ? (
|
||||
<Text size="xs" color="text-gray-500">
|
||||
Loading penalty types...
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Grid cols={2} gap={2}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{penaltyTypes.map((penalty: any) => {
|
||||
const Icon = penalty.icon;
|
||||
const isSelected = penaltyType === penalty.type;
|
||||
return (
|
||||
<Box key={penalty.type} padding={2} border borderColor={isSelected ? undefined : 'border-charcoal-outline'} bg={isSelected ? undefined : 'bg-iron-gray/30'} rounded="lg">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPenaltyType(penalty.type);
|
||||
setPenaltyValue(penalty.defaultValue);
|
||||
}}
|
||||
fullWidth
|
||||
title={penalty.description}
|
||||
>
|
||||
<Stack align="start" gap={0.5}>
|
||||
<UIIcon icon={Icon} size={3.5} color={isSelected ? penalty.color : 'text-gray-500'} />
|
||||
<Text size="xs" weight="medium" fontSize="10px" color={isSelected ? penalty.color : 'text-gray-500'}>
|
||||
{penalty.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{selectedPenalty?.requiresValue && (
|
||||
<Box mt={3}>
|
||||
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>
|
||||
Value ({selectedPenalty.valueLabel})
|
||||
</Text>
|
||||
<Box as="input"
|
||||
type="number"
|
||||
value={penaltyValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPenaltyValue(Number(e.target.value))}
|
||||
min="1"
|
||||
w="full"
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Steward Notes */}
|
||||
<Box mb={4}>
|
||||
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>Decision Reasoning *</Text>
|
||||
<Box as="textarea"
|
||||
value={stewardNotes}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision..."
|
||||
style={{ height: '8rem' }}
|
||||
w="full"
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={handleSubmitDecision}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decision'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Already Resolved Info */}
|
||||
{!isPending && (
|
||||
<Card>
|
||||
<Box p={4} textAlign="center">
|
||||
<Box py={4} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
|
||||
<UIIcon icon={Gavel} size={8} mx="auto" mb={2} />
|
||||
<Text weight="semibold" block>Case Closed</Text>
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>
|
||||
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</StateContainer>
|
||||
<ProtestDetailTemplate
|
||||
viewData={{}}
|
||||
protestDetail={protestDetail}
|
||||
leagueId={leagueId}
|
||||
showDecisionPanel={showDecisionPanel}
|
||||
setShowDecisionPanel={setShowDecisionPanel}
|
||||
decision={decision}
|
||||
setDecision={setDecision}
|
||||
penaltyType={penaltyType}
|
||||
setPenaltyType={setPenaltyType}
|
||||
penaltyValue={penaltyValue}
|
||||
setPenaltyValue={setPenaltyValue}
|
||||
stewardNotes={stewardNotes}
|
||||
setStewardNotes={setStewardNotes}
|
||||
submitting={submitting}
|
||||
newComment={newComment}
|
||||
setNewComment={setNewComment}
|
||||
penaltyTypes={penaltyTypes}
|
||||
selectedPenalty={selectedPenalty}
|
||||
onSubmitDecision={handleSubmitDecision}
|
||||
onRequestDefense={handleRequestDefense}
|
||||
getStatusConfig={getStatusConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { RaceDetailTemplate, RaceDetailViewData } from '@/templates/RaceDetailTemplate';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface Props {
|
||||
data: RaceDetailViewData;
|
||||
}
|
||||
|
||||
export function RaceDetailPageClient({ data: viewData }: Props) {
|
||||
export function RaceDetailPageClient({ viewData }: ClientWrapperProps<RaceDetailViewData>) {
|
||||
const router = useRouter();
|
||||
const [animatedRatingChange] = useState(0);
|
||||
|
||||
|
||||
@@ -4,12 +4,9 @@ import React, { useState, useCallback } from 'react';
|
||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||
import { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface Props {
|
||||
data: RaceResultsViewData;
|
||||
}
|
||||
|
||||
export function RaceResultsPageClient({ data: viewData }: Props) {
|
||||
export function RaceResultsPageClient({ viewData }: ClientWrapperProps<RaceResultsViewData>) {
|
||||
const router = useRouter();
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importSuccess, setImportSuccess] = useState(false);
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { StatefulPageWrapper } from '@/ui/StatefulPageWrapper';
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { RacesAllTemplate } from '@/templates/RacesAllTemplate';
|
||||
import { useAllRacesPageData } from '@/hooks/race/useAllRacesPageData';
|
||||
import { type RacesViewData, type RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Flag } from 'lucide-react';
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
export function RacesAllPageClient({ initialViewData }: { initialViewData: RacesViewData | null }) {
|
||||
export function RacesAllPageClient({ viewData: initialViewData }: ClientWrapperProps<RacesViewData>) {
|
||||
const router = useRouter();
|
||||
|
||||
// Client-side state for filters and pagination
|
||||
@@ -107,4 +107,4 @@ export function RacesAllPageClient({ initialViewData }: { initialViewData: Races
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,9 @@ import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { RacesTemplate, type TimeFilter, type RaceStatusFilter } from '@/templates/RacesTemplate';
|
||||
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface RacesPageClientProps {
|
||||
viewData: RacesViewData;
|
||||
}
|
||||
|
||||
export function RacesPageClient({ viewData }: RacesPageClientProps) {
|
||||
export function RacesPageClient({ viewData }: ClientWrapperProps<RacesViewData>) {
|
||||
const router = useRouter();
|
||||
const [statusFilter, setStatusFilter] = useState<RaceStatusFilter>('all');
|
||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||
|
||||
@@ -15,12 +15,9 @@ import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetP
|
||||
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
|
||||
import { ResetPasswordFormValidation } from '@/lib/utilities/authValidation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface ResetPasswordClientProps {
|
||||
viewData: ResetPasswordViewData;
|
||||
}
|
||||
|
||||
export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
|
||||
export function ResetPasswordClient({ viewData }: ClientWrapperProps<ResetPasswordViewData>) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
useRemoveMember,
|
||||
} from "@/hooks/league/useLeagueRosterAdmin";
|
||||
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
|
||||
import type { JoinRequestData, RosterMemberData } from '@/lib/view-data/LeagueRosterAdminViewData';
|
||||
import type { JoinRequestData, RosterMemberData, LeagueRosterAdminViewData } from '@/lib/view-data/LeagueRosterAdminViewData';
|
||||
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
|
||||
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
||||
|
||||
export function RosterAdminPage() {
|
||||
export function RosterAdminPage({ viewData: initialViewData }: Partial<ClientWrapperProps<LeagueRosterAdminViewData>>) {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { ServerErrorTemplate, type ServerErrorViewData } from '@/templates/ServerErrorTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
/**
|
||||
* ServerErrorPageClient
|
||||
@@ -11,7 +12,7 @@ import { ServerErrorTemplate, type ServerErrorViewData } from '@/templates/Serve
|
||||
* Client-side entry point for the 500 page.
|
||||
* Manages navigation and retry logic and wires it to the template.
|
||||
*/
|
||||
export function ServerErrorPageClient() {
|
||||
export function ServerErrorPageClient({ viewData: initialViewData }: Partial<ClientWrapperProps<ServerErrorViewData>>) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleHome = () => {
|
||||
@@ -25,7 +26,7 @@ export function ServerErrorPageClient() {
|
||||
const error = new Error('Internal Server Error') as Error & { digest?: string };
|
||||
error.digest = 'HTTP_500';
|
||||
|
||||
const viewData: ServerErrorViewData = {
|
||||
const viewData: ServerErrorViewData = initialViewData ?? {
|
||||
error,
|
||||
incidentId: error.digest
|
||||
};
|
||||
|
||||
@@ -15,12 +15,9 @@ import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
|
||||
import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder';
|
||||
import { SignupViewModel } from '@/lib/view-models/auth/SignupViewModel';
|
||||
import { SignupFormValidation } from '@/lib/utilities/authValidation';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface SignupClientProps {
|
||||
viewData: SignupViewData;
|
||||
}
|
||||
|
||||
export function SignupClient({ viewData }: SignupClientProps) {
|
||||
export function SignupClient({ viewData }: ClientWrapperProps<SignupViewData>) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { refreshSession } = useAuth();
|
||||
@@ -39,7 +36,7 @@ export function SignupClient({ viewData }: SignupClientProps) {
|
||||
...prev.formState,
|
||||
fields: {
|
||||
...prev.formState.fields,
|
||||
[name]: {
|
||||
[name as keyof typeof prev.formState.fields]: {
|
||||
...prev.formState.fields[name as keyof typeof prev.formState.fields],
|
||||
value,
|
||||
touched: true,
|
||||
@@ -75,7 +72,7 @@ export function SignupClient({ viewData }: SignupClientProps) {
|
||||
...validationErrors.reduce((acc, error) => ({
|
||||
...acc,
|
||||
[error.field]: {
|
||||
...prev.formState.fields[error.field],
|
||||
...prev.formState.fields[error.field as keyof typeof prev.formState.fields],
|
||||
error: error.message,
|
||||
touched: true,
|
||||
},
|
||||
@@ -161,4 +158,4 @@ export function SignupClient({ viewData }: SignupClientProps) {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { SponsorLeagueDetailTemplate } from '@/templates/SponsorLeagueDetailTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export function SponsorLeagueDetailPageClient({ data }: { data: any }) {
|
||||
export function SponsorLeagueDetailPageClient({ viewData }: ClientWrapperProps<ViewData>) {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'drivers' | 'races' | 'sponsor'>('overview');
|
||||
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
|
||||
|
||||
return (
|
||||
<SponsorLeagueDetailTemplate
|
||||
viewData={data}
|
||||
viewData={viewData}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
selectedTier={selectedTier}
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { SponsorLeaguesTemplate, type SortOption, type TierFilter, type AvailabilityFilter } from '@/templates/SponsorLeaguesTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export function SponsorLeaguesPageClient({ data }: { data: unknown }) {
|
||||
export function SponsorLeaguesPageClient({ viewData }: ClientWrapperProps<ViewData>) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [tierFilter] = useState<TierFilter>('all');
|
||||
const [availabilityFilter] = useState<AvailabilityFilter>('all');
|
||||
const [sortBy] = useState<SortOption>('rating');
|
||||
|
||||
const filteredLeagues = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any;
|
||||
const d = viewData as any;
|
||||
if (!d?.leagues) return [];
|
||||
return d.leagues
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.filter((league: any) => {
|
||||
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false;
|
||||
@@ -30,7 +30,6 @@ export function SponsorLeaguesPageClient({ data }: { data: unknown }) {
|
||||
}
|
||||
return true;
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.sort((a: any, b: any) => {
|
||||
switch (sortBy) {
|
||||
case 'rating': return b.rating - a.rating;
|
||||
@@ -40,12 +39,11 @@ export function SponsorLeaguesPageClient({ data }: { data: unknown }) {
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
}, [data, searchQuery, tierFilter, availabilityFilter, sortBy]);
|
||||
}, [viewData, searchQuery, tierFilter, availabilityFilter, sortBy]);
|
||||
|
||||
return (
|
||||
<SponsorLeaguesTemplate
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
viewData={data as any}
|
||||
viewData={viewData as any}
|
||||
filteredLeagues={filteredLeagues}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { InlineNotice } from '@/ui/InlineNotice';
|
||||
import { ProgressLine } from '@/ui/ProgressLine';
|
||||
import {
|
||||
SharedBox,
|
||||
SharedStack,
|
||||
SharedText,
|
||||
SharedIcon,
|
||||
SharedProgressLine
|
||||
} from '@/components/shared/UIComponents';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import type { Result } from '@/lib/contracts/Result';
|
||||
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
|
||||
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface SponsorshipRequestsClientProps {
|
||||
viewData: SponsorshipRequestsViewData;
|
||||
interface SponsorshipRequestsClientProps extends ClientWrapperProps<SponsorshipRequestsViewData> {
|
||||
onAccept: (requestId: string) => Promise<Result<void, string>>;
|
||||
onReject: (requestId: string, reason?: string) => Promise<Result<void, string>>;
|
||||
}
|
||||
@@ -48,15 +53,19 @@ export function SponsorshipRequestsClient({ viewData, onAccept, onReject }: Spon
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProgressLine isLoading={!!isProcessing} />
|
||||
<SharedProgressLine isLoading={!!isProcessing} />
|
||||
{error && (
|
||||
<Box position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
|
||||
<InlineNotice
|
||||
variant="error"
|
||||
title="Action Failed"
|
||||
message={error}
|
||||
/>
|
||||
</Box>
|
||||
<SharedBox position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
|
||||
<SharedBox bg="bg-error-red/10" p={4} rounded="md" border borderColor="border-error-red/20">
|
||||
<SharedStack direction="row" align="center" gap={3}>
|
||||
<SharedIcon icon={ShieldAlert} size={5} color="text-error-red" />
|
||||
<SharedBox>
|
||||
<SharedText weight="bold" color="text-error-red">Action Failed</SharedText>
|
||||
<SharedText size="sm" color="text-error-red/80">{error}</SharedText>
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
)}
|
||||
<SponsorshipRequestsTemplate
|
||||
viewData={viewData}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import type { Result } from '@/lib/contracts/Result';
|
||||
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
|
||||
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface SponsorshipRequestsPageClientProps {
|
||||
viewData: SponsorshipRequestsViewData;
|
||||
interface SponsorshipRequestsPageClientProps extends ClientWrapperProps<SponsorshipRequestsViewData> {
|
||||
onAccept: (requestId: string) => Promise<Result<void, string>>;
|
||||
onReject: (requestId: string, reason?: string) => Promise<Result<void, string>>;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList';
|
||||
import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
|
||||
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
||||
import { StewardingQueuePanel } from '@/components/leagues/StewardingQueuePanel';
|
||||
import { StewardingStats } from '@/components/leagues/StewardingStats';
|
||||
import { PenaltyFAB } from '@/components/races/PenaltyFAB';
|
||||
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
|
||||
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
|
||||
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { StewardingTemplate } from '@/templates/StewardingTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface StewardingTemplateProps {
|
||||
data: StewardingViewData;
|
||||
interface StewardingPageClientProps extends ClientWrapperProps<StewardingViewData> {
|
||||
leagueId: string;
|
||||
currentDriverId: string;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export function StewardingPageClient({ data, currentDriverId, onRefetch }: StewardingTemplateProps) {
|
||||
export function StewardingPageClient({ viewData, currentDriverId, onRefetch }: StewardingPageClientProps) {
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
||||
const [selectedProtest, setSelectedProtest] = useState<ProtestViewModel | null>(null);
|
||||
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
|
||||
@@ -35,19 +25,19 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
|
||||
|
||||
// Flatten protests for the specialized list components
|
||||
const allPendingProtests = useMemo(() => {
|
||||
return data.races.flatMap(r => r.pendingProtests.map(p => ({
|
||||
return viewData.races.flatMap(r => r.pendingProtests.map(p => ({
|
||||
id: p.id,
|
||||
raceName: r.track || 'Unknown Track',
|
||||
protestingDriver: data.drivers.find(d => d.id === p.protestingDriverId)?.name || 'Unknown',
|
||||
accusedDriver: data.drivers.find(d => d.id === p.accusedDriverId)?.name || 'Unknown',
|
||||
protestingDriver: viewData.drivers.find(d => d.id === p.protestingDriverId)?.name || 'Unknown',
|
||||
accusedDriver: viewData.drivers.find(d => d.id === p.accusedDriverId)?.name || 'Unknown',
|
||||
description: p.incident.description,
|
||||
submittedAt: p.filedAt,
|
||||
status: p.status as 'pending' | 'under_review' | 'resolved' | 'rejected',
|
||||
})));
|
||||
}, [data.races, data.drivers]);
|
||||
}, [viewData.races, viewData.drivers]);
|
||||
|
||||
const allResolvedProtests = useMemo(() => {
|
||||
return data.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({
|
||||
return viewData.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({
|
||||
id: p.id,
|
||||
protestingDriverId: p.protestingDriverId,
|
||||
accusedDriverId: p.accusedDriverId,
|
||||
@@ -59,11 +49,11 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
|
||||
proofVideoUrl: p.proofVideoUrl,
|
||||
decisionNotes: p.decisionNotes,
|
||||
} as never)));
|
||||
}, [data.races]);
|
||||
}, [viewData.races]);
|
||||
|
||||
const racesMap = useMemo(() => {
|
||||
const map: Record<string, RaceViewModel> = {};
|
||||
data.races.forEach(r => {
|
||||
viewData.races.forEach(r => {
|
||||
map[r.id] = new RaceViewModel({
|
||||
id: r.id,
|
||||
name: '',
|
||||
@@ -72,11 +62,11 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
|
||||
} as never);
|
||||
});
|
||||
return map;
|
||||
}, [data.races]);
|
||||
}, [viewData.races]);
|
||||
|
||||
const driverMap = useMemo(() => {
|
||||
const map: Record<string, DriverViewModel> = {};
|
||||
data.drivers.forEach(d => {
|
||||
viewData.drivers.forEach(d => {
|
||||
map[d.id] = new DriverViewModel({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
@@ -87,7 +77,7 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [data.drivers]);
|
||||
}, [viewData.drivers]);
|
||||
|
||||
const handleAcceptProtest = async (
|
||||
protestId: string,
|
||||
@@ -97,7 +87,7 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
|
||||
) => {
|
||||
// Find the protest to get details for penalty
|
||||
let foundProtest: { raceId: string; accusedDriverId: string; incident: { description: string } } | undefined;
|
||||
data.races.forEach((raceData) => {
|
||||
viewData.races.forEach((raceData) => {
|
||||
const p = raceData.pendingProtests.find((pr) => pr.id === protestId) ||
|
||||
raceData.resolvedProtests.find((pr) => pr.id === protestId);
|
||||
if (p) foundProtest = {
|
||||
@@ -108,7 +98,7 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
|
||||
});
|
||||
|
||||
if (foundProtest) {
|
||||
acceptProtestMutation.mutate({
|
||||
await acceptProtestMutation.mutateAsync({
|
||||
protestId,
|
||||
penaltyType,
|
||||
penaltyValue,
|
||||
@@ -121,7 +111,7 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
|
||||
};
|
||||
|
||||
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
|
||||
rejectProtestMutation.mutate({
|
||||
await rejectProtestMutation.mutateAsync({
|
||||
protestId,
|
||||
stewardNotes,
|
||||
});
|
||||
@@ -130,7 +120,7 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
|
||||
const handleReviewProtest = (id: string) => {
|
||||
// Find the protest in the data
|
||||
let foundProtest: ProtestViewModel | null = null;
|
||||
data.races.forEach(r => {
|
||||
viewData.races.forEach(r => {
|
||||
const p = r.pendingProtests.find(p => p.id === id);
|
||||
if (p) {
|
||||
foundProtest = new ProtestViewModel({
|
||||
@@ -151,96 +141,22 @@ export function StewardingPageClient({ data, currentDriverId, onRefetch }: Stewa
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<StewardingStats
|
||||
totalPending={data.totalPending}
|
||||
totalResolved={data.totalResolved}
|
||||
totalPenalties={data.totalPenalties}
|
||||
/>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<Box borderBottom borderColor="border-charcoal-outline">
|
||||
<Stack direction="row" gap={4}>
|
||||
<Box
|
||||
borderBottom={activeTab === 'pending'}
|
||||
borderColor={activeTab === 'pending' ? 'border-primary-blue' : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setActiveTab('pending')}
|
||||
rounded="none"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text weight="medium" color={activeTab === 'pending' ? 'text-primary-blue' : undefined}>Pending Protests</Text>
|
||||
{data.totalPending > 0 && (
|
||||
<Box px={2} py={0.5} fontSize="0.75rem" bg="bg-warning-amber/20" color="text-warning-amber" rounded="full">
|
||||
{data.totalPending}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
<Box
|
||||
borderBottom={activeTab === 'history'}
|
||||
borderColor={activeTab === 'history' ? 'border-primary-blue' : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setActiveTab('history')}
|
||||
rounded="none"
|
||||
>
|
||||
<Text weight="medium" color={activeTab === 'history' ? 'text-primary-blue' : undefined}>History</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'pending' ? (
|
||||
<StewardingQueuePanel
|
||||
protests={allPendingProtests}
|
||||
onReview={handleReviewProtest}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<Box p={6}>
|
||||
<PenaltyHistoryList
|
||||
protests={allResolvedProtests}
|
||||
races={racesMap}
|
||||
drivers={driverMap}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
||||
)}
|
||||
|
||||
{selectedProtest && (
|
||||
<ReviewProtestModal
|
||||
protest={selectedProtest}
|
||||
onClose={() => setSelectedProtest(null)}
|
||||
onAccept={handleAcceptProtest}
|
||||
onReject={handleRejectProtest}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showQuickPenaltyModal && (
|
||||
<QuickPenaltyModal
|
||||
drivers={data.drivers.map(d => new DriverViewModel({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
iracingId: '',
|
||||
country: '',
|
||||
joinedAt: '',
|
||||
avatarUrl: null,
|
||||
}))}
|
||||
onClose={() => setShowQuickPenaltyModal(false)}
|
||||
adminId={currentDriverId || ''}
|
||||
races={data.races.map(r => ({ id: r.id, track: r.track, scheduledAt: new Date(r.scheduledAt) }))}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<StewardingTemplate
|
||||
viewData={viewData}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
selectedProtest={selectedProtest}
|
||||
onReviewProtest={handleReviewProtest}
|
||||
onCloseProtestModal={() => setSelectedProtest(null)}
|
||||
onAcceptProtest={handleAcceptProtest}
|
||||
onRejectProtest={handleRejectProtest}
|
||||
showQuickPenaltyModal={showQuickPenaltyModal}
|
||||
setShowQuickPenaltyModal={setShowQuickPenaltyModal}
|
||||
allPendingProtests={allPendingProtests}
|
||||
allResolvedProtests={allResolvedProtests}
|
||||
racesMap={racesMap}
|
||||
driverMap={driverMap}
|
||||
currentDriverId={currentDriverId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData';
|
||||
import { TeamDetailTemplate } from '@/templates/TeamDetailTemplate';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||
|
||||
interface TeamDetailPageClientProps {
|
||||
viewData: TeamDetailViewData;
|
||||
}
|
||||
|
||||
export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) {
|
||||
export function TeamDetailPageClient({ viewData }: ClientWrapperProps<TeamDetailViewData>) {
|
||||
const router = useRouter();
|
||||
|
||||
// UI state only (no business logic)
|
||||
@@ -51,4 +48,4 @@ export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) {
|
||||
onGoBack={handleGoBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,17 @@ import { useRouter } from 'next/navigation';
|
||||
import { TeamLeaderboardTemplate } from '@/templates/TeamLeaderboardTemplate';
|
||||
import { useState } from 'react';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||
|
||||
export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) {
|
||||
interface TeamLeaderboardViewData extends ViewData {
|
||||
teams: TeamSummaryViewModel[];
|
||||
}
|
||||
|
||||
export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<TeamLeaderboardViewData>) {
|
||||
const router = useRouter();
|
||||
|
||||
// Client-side UI state only (no business logic)
|
||||
@@ -16,7 +22,7 @@ export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewMode
|
||||
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
|
||||
const [sortBy, setSortBy] = useState<SortBy>('rating');
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
if (!viewData.teams || viewData.teams.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -28,17 +34,17 @@ export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewMode
|
||||
router.push('/teams');
|
||||
};
|
||||
|
||||
const viewData = {
|
||||
teams: data,
|
||||
const templateViewData = {
|
||||
teams: viewData.teams,
|
||||
searchQuery,
|
||||
filterLevel,
|
||||
sortBy,
|
||||
filteredAndSortedTeams: data,
|
||||
filteredAndSortedTeams: viewData.teams,
|
||||
};
|
||||
|
||||
return (
|
||||
<TeamLeaderboardTemplate
|
||||
viewData={viewData}
|
||||
viewData={templateViewData}
|
||||
onSearchChange={setSearchQuery}
|
||||
filterLevelChange={setFilterLevel}
|
||||
onSortChange={setSortBy}
|
||||
|
||||
22
apps/website/components/admin/AdminDashboardLayout.tsx
Normal file
22
apps/website/components/admin/AdminDashboardLayout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface AdminDashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AdminDashboardLayout({ children }: AdminDashboardLayoutProps) {
|
||||
return (
|
||||
<Container size="lg">
|
||||
<Box paddingY={8}>
|
||||
<Stack gap={8}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
24
apps/website/components/leagues/CreateLeagueWizardLayout.tsx
Normal file
24
apps/website/components/leagues/CreateLeagueWizardLayout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { SharedBox, SharedStack, SharedContainer } from '@/components/shared/UIComponents';
|
||||
|
||||
interface CreateLeagueWizardLayoutProps {
|
||||
children: ReactNode;
|
||||
header: ReactNode;
|
||||
progress: ReactNode;
|
||||
navigation: ReactNode;
|
||||
footer: ReactNode;
|
||||
}
|
||||
|
||||
export function CreateLeagueWizardLayout({ children, header, progress, navigation, footer }: CreateLeagueWizardLayoutProps) {
|
||||
return (
|
||||
<SharedBox as="main" maxWidth="4xl" mx="auto" pb={8}>
|
||||
{header}
|
||||
{progress}
|
||||
{children}
|
||||
{navigation}
|
||||
{footer}
|
||||
</SharedBox>
|
||||
);
|
||||
}
|
||||
56
apps/website/components/races/RacesAllLayout.tsx
Normal file
56
apps/website/components/races/RacesAllLayout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface RacesAllLayoutProps {
|
||||
children: ReactNode;
|
||||
header: ReactNode;
|
||||
stats: ReactNode;
|
||||
pagination: ReactNode;
|
||||
}
|
||||
|
||||
export function RacesAllLayout({ children, header, stats, pagination }: RacesAllLayoutProps) {
|
||||
return (
|
||||
<Box as="main" minHeight="screen" bg="bg-base-black" py={8}>
|
||||
<Container size="lg">
|
||||
<Stack gap={8}>
|
||||
{header}
|
||||
{stats}
|
||||
{children}
|
||||
{pagination}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function RacesAllStats({ count, onFilterClick }: { count: number, onFilterClick: () => void }) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="between" alignItems="center">
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Showing <Text as="span" color="text-white" weight="bold">{count}</Text> races
|
||||
</Text>
|
||||
<Box
|
||||
as="button"
|
||||
onClick={onFilterClick}
|
||||
px={4}
|
||||
py={2}
|
||||
bg="bg-surface-charcoal"
|
||||
border
|
||||
borderColor="border-outline-steel"
|
||||
fontSize="10px"
|
||||
weight="bold"
|
||||
letterSpacing="wider"
|
||||
hoverBorderColor="border-primary-accent"
|
||||
transition
|
||||
className="uppercase"
|
||||
>
|
||||
Filters
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
48
apps/website/components/races/RacesLayout.tsx
Normal file
48
apps/website/components/races/RacesLayout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface RacesLayoutProps {
|
||||
children: ReactNode;
|
||||
header: ReactNode;
|
||||
banner?: ReactNode;
|
||||
sidebar: ReactNode;
|
||||
}
|
||||
|
||||
export function RacesLayout({ children, header, banner, sidebar }: RacesLayoutProps) {
|
||||
return (
|
||||
<Box as="main" minHeight="screen" bg="bg-base-black" py={8}>
|
||||
<Container size="lg">
|
||||
<Stack gap={8}>
|
||||
{header}
|
||||
{banner}
|
||||
<Grid cols={12} gap={6}>
|
||||
<GridItem colSpan={12} lgSpan={8}>
|
||||
{children}
|
||||
</GridItem>
|
||||
<GridItem colSpan={12} lgSpan={4}>
|
||||
{sidebar}
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function RaceScheduleSection({ children, title }: { children: ReactNode, title: string }) {
|
||||
return (
|
||||
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" overflow="hidden">
|
||||
<Box p={4} borderBottom borderColor="border-outline-steel" bg="bg-base-black" bgOpacity={0.2}>
|
||||
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">{title}</Text>
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
26
apps/website/components/shared/SharedEmptyState.tsx
Normal file
26
apps/website/components/shared/SharedEmptyState.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
|
||||
interface SharedEmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'race-final' | 'discord';
|
||||
};
|
||||
}
|
||||
|
||||
export function SharedEmptyState({ icon, title, description, action }: SharedEmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
apps/website/components/shared/UIComponents.tsx
Normal file
42
apps/website/components/shared/UIComponents.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Pagination } from '@/ui/Pagination';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { ConfirmDialog } from '@/ui/ConfirmDialog';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Skeleton } from '@/ui/Skeleton';
|
||||
import { LoadingSpinner } from '@/ui/LoadingSpinner';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
|
||||
import { ProgressLine } from '@/ui/ProgressLine';
|
||||
|
||||
export {
|
||||
Pagination as SharedPagination,
|
||||
Text as SharedText,
|
||||
Box as SharedBox,
|
||||
Stack as SharedStack,
|
||||
Container as SharedContainer,
|
||||
ConfirmDialog as SharedConfirmDialog,
|
||||
Button as SharedButton,
|
||||
Icon as SharedIcon,
|
||||
Card as SharedCard,
|
||||
Heading as SharedHeading,
|
||||
Grid as SharedGrid,
|
||||
GridItem as SharedGridItem,
|
||||
Surface as SharedSurface,
|
||||
Input as SharedInput,
|
||||
Link as SharedLink,
|
||||
Skeleton as SharedSkeleton,
|
||||
LoadingSpinner as SharedLoadingSpinner,
|
||||
Badge as SharedBadge,
|
||||
ProgressLine as SharedProgressLine
|
||||
};
|
||||
@@ -40,7 +40,7 @@ export interface PageWrapperProps<TData> {
|
||||
/** Retry function for errors */
|
||||
retry?: () => void;
|
||||
/** Template component that receives the data */
|
||||
Template: React.ComponentType<{ data: TData }>;
|
||||
Template: React.ComponentType<{ viewData: TData }>;
|
||||
/** Loading configuration */
|
||||
loading?: PageWrapperLoadingConfig;
|
||||
/** Error configuration */
|
||||
@@ -162,7 +162,7 @@ export function PageWrapper<TData>({
|
||||
// 4. Success State - Render Template with data
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Template data={data} />
|
||||
<Template viewData={data} />
|
||||
{children}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { PageWrapper, PageWrapperProps } from '@/ui/PageWrapper';
|
||||
import { PageWrapper, PageWrapperProps } from './PageWrapper';
|
||||
|
||||
/**
|
||||
* Stateful Page Wrapper - CLIENT SIDE ONLY
|
||||
@@ -56,4 +56,4 @@ export function StatefulPageWrapper<TData>({
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { PageWrapperProps, PageWrapperLoadingConfig, PageWrapperErrorConfig, PageWrapperEmptyConfig } from '@/ui/PageWrapper';
|
||||
export type { PageWrapperProps, PageWrapperLoadingConfig, PageWrapperErrorConfig, PageWrapperEmptyConfig } from './PageWrapper';
|
||||
|
||||
45
apps/website/components/teams/TeamsDirectory.tsx
Normal file
45
apps/website/components/teams/TeamsDirectory.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface TeamsDirectoryProps {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function TeamsDirectory({ children, title, subtitle }: TeamsDirectoryProps) {
|
||||
return (
|
||||
<Box as="main" bg="base-black" minHeight="screen">
|
||||
<Container size="lg">
|
||||
<Box paddingY={12}>
|
||||
<Stack gap={10}>
|
||||
{title && (
|
||||
<Stack direction="row" align="center" gap={2} mb={6}>
|
||||
<Box w="2" h="2" bg="primary-accent" />
|
||||
<Text size="xs" weight="bold" color="text-gray-400" uppercase>{title}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamsDirectorySection({ children, title, accentColor = "primary-accent" }: { children: ReactNode, title: string, accentColor?: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={2} mb={6}>
|
||||
<Box w="2" h="2" bg={accentColor} />
|
||||
<Text size="xs" weight="bold" color="text-gray-400" uppercase>{title}</Text>
|
||||
</Stack>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
37
apps/website/lib/contracts/components/ComponentContracts.ts
Normal file
37
apps/website/lib/contracts/components/ComponentContracts.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { ViewData } from '../view-data/ViewData';
|
||||
|
||||
/**
|
||||
* A Template is a stateless component that composes other components.
|
||||
* It receives ViewData and event handlers.
|
||||
*
|
||||
* Rules:
|
||||
* - Stateless (no useState, useEffect)
|
||||
* - Receives ViewData and event handlers
|
||||
* - Composes components and UI elements
|
||||
* - No business logic
|
||||
* - No data fetching
|
||||
* - CANNOT import from ui/, MUST use components/
|
||||
*/
|
||||
export type Template<P extends TemplateProps<any>> = (props: P) => ReactElement | null;
|
||||
|
||||
export interface TemplateProps<T extends ViewData> {
|
||||
viewData: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Client Wrapper (PageClient) manages state and event handlers.
|
||||
* It wires server data (ViewData) to a Template.
|
||||
*
|
||||
* Rules:
|
||||
* - Manages client state and event handlers
|
||||
* - No UI rendering logic (except loading/error states)
|
||||
* - MUST return a Template
|
||||
* - CANNOT import from ui/, MUST use components/
|
||||
*/
|
||||
export type ClientWrapper<T extends ViewData, P extends ClientWrapperProps<T> = ClientWrapperProps<T>> =
|
||||
(props: P) => ReactElement<TemplateProps<T>> | null;
|
||||
|
||||
export interface ClientWrapperProps<T extends ViewData> {
|
||||
viewData: T;
|
||||
}
|
||||
@@ -19,8 +19,8 @@ import type { JsonValue, JsonObject } from '../types/primitives';
|
||||
* All ViewData must be JSON-serializable.
|
||||
* This type ensures no class instances or functions are included.
|
||||
*/
|
||||
export interface ViewData extends JsonObject {
|
||||
[key: string]: JsonValue;
|
||||
export interface ViewData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ViewData } from '../contracts/view-data/ViewData';
|
||||
|
||||
/**
|
||||
* LeagueDetailViewData - Pure ViewData for LeagueDetailTemplate
|
||||
* Contains only raw serializable data, no methods or computed properties
|
||||
@@ -63,7 +65,7 @@ export interface SponsorshipSlot {
|
||||
benefits: string[];
|
||||
}
|
||||
|
||||
export interface LeagueDetailViewData {
|
||||
export interface LeagueDetailViewData extends ViewData {
|
||||
// Basic info
|
||||
leagueId: string;
|
||||
name: string;
|
||||
@@ -100,4 +102,4 @@ export interface LeagueDetailViewData {
|
||||
metrics: SponsorMetric[];
|
||||
slots: SponsorshipSlot[];
|
||||
} | null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ViewData } from '../contracts/view-data/ViewData';
|
||||
|
||||
/**
|
||||
* TeamsViewData - Pure ViewData for TeamsTemplate
|
||||
* Contains only raw serializable data, no methods or computed properties
|
||||
@@ -11,6 +13,6 @@ export interface TeamSummaryData {
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export interface TeamsViewData {
|
||||
export interface TeamsViewData extends ViewData {
|
||||
teams: TeamSummaryData[];
|
||||
}
|
||||
|
||||
@@ -6,16 +6,18 @@ import { AdminSectionHeader } from '@/components/admin/AdminSectionHeader';
|
||||
import { AdminStatsPanel } from '@/components/admin/AdminStatsPanel';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import {
|
||||
SharedBox,
|
||||
SharedButton,
|
||||
SharedCard,
|
||||
SharedContainer,
|
||||
SharedIcon,
|
||||
SharedGrid,
|
||||
SharedStack,
|
||||
SharedText,
|
||||
SharedBadge
|
||||
} from '@/components/shared/UIComponents';
|
||||
import { QuickActionLink } from '@/ui/QuickActionLink';
|
||||
import { StatusBadge } from '@/ui/StatusBadge';
|
||||
import { Text } from '@/ui/Text';
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
Shield,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
/**
|
||||
* AdminDashboardTemplate
|
||||
@@ -35,8 +38,7 @@ export function AdminDashboardTemplate({
|
||||
viewData,
|
||||
onRefresh,
|
||||
isLoading
|
||||
}: {
|
||||
viewData: AdminDashboardViewData;
|
||||
}: TemplateProps<AdminDashboardViewData> & {
|
||||
onRefresh: () => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
@@ -68,103 +70,93 @@ export function AdminDashboardTemplate({
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={8}>
|
||||
<AdminHeaderPanel
|
||||
title="Admin Dashboard"
|
||||
description="System-wide telemetry and operations control"
|
||||
isLoading={isLoading}
|
||||
actions={
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Icon icon={RefreshCw} size={3} animate={isLoading ? 'spin' : 'none'} />}
|
||||
>
|
||||
Refresh Telemetry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SharedContainer size="lg">
|
||||
<SharedBox paddingY={8}>
|
||||
<SharedStack gap={8}>
|
||||
<AdminHeaderPanel
|
||||
title="Admin Dashboard"
|
||||
description="System-wide telemetry and operations control"
|
||||
isLoading={isLoading}
|
||||
actions={
|
||||
<SharedButton
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<SharedIcon icon={RefreshCw} size={3} animate={isLoading ? 'spin' : 'none'} />}
|
||||
>
|
||||
Refresh Telemetry
|
||||
</SharedButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<AdminStatsPanel stats={stats} />
|
||||
<AdminStatsPanel stats={stats} />
|
||||
|
||||
<Grid cols={1} mdCols={2} gap={6}>
|
||||
{/* System Health & Status */}
|
||||
<Card p={6}>
|
||||
<Stack gap={6}>
|
||||
<AdminSectionHeader
|
||||
title="System Status"
|
||||
actions={
|
||||
<StatusBadge variant="success" icon={Activity}>
|
||||
Operational
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Box borderTop borderColor="border-gray" opacity={0.3} />
|
||||
<Box pt={0}>
|
||||
<Stack direction="row" align="center" justify="between" py={2}>
|
||||
<Text size="sm" color="text-gray-400">Suspended Users</Text>
|
||||
<Text weight="bold" color="text-warning-amber">{viewData.stats.suspendedUsers}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box borderTop borderColor="border-gray" opacity={0.3} />
|
||||
<Box>
|
||||
<Stack direction="row" align="center" justify="between" py={2}>
|
||||
<Text size="sm" color="text-gray-400">Deleted Users</Text>
|
||||
<Text weight="bold" color="text-error-red">{viewData.stats.deletedUsers}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box borderTop borderColor="border-gray" opacity={0.3} />
|
||||
<Box>
|
||||
<Stack direction="row" align="center" justify="between" py={2}>
|
||||
<Text size="sm" color="text-gray-400">New Registrations (24h)</Text>
|
||||
<Text weight="bold" color="text-primary-blue">{viewData.stats.newUsersToday}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
<SharedGrid cols={{ base: 1, md: 2 }} gap={6}>
|
||||
{/* System Health & Status */}
|
||||
<SharedCard p={6}>
|
||||
<SharedStack gap={6}>
|
||||
<AdminSectionHeader
|
||||
title="System Status"
|
||||
actions={
|
||||
<SharedBadge variant="success">
|
||||
<SharedStack direction="row" align="center" gap={1.5}>
|
||||
<SharedIcon icon={Activity} size={3} />
|
||||
<SharedText>Operational</SharedText>
|
||||
</SharedStack>
|
||||
</SharedBadge>
|
||||
}
|
||||
/>
|
||||
|
||||
<SharedStack gap={4}>
|
||||
<SharedBox borderTop borderColor="border-gray" opacity={0.3} />
|
||||
<SharedBox pt={0}>
|
||||
<SharedStack direction="row" align="center" justify="between" py={2}>
|
||||
<SharedText size="sm" color="text-gray-400">Suspended Users</SharedText>
|
||||
<SharedText weight="bold" color="text-warning-amber">{viewData.stats.suspendedUsers}</SharedText>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
<SharedBox borderTop borderColor="border-gray" opacity={0.3} />
|
||||
<SharedBox>
|
||||
<SharedStack direction="row" align="center" justify="between" py={2}>
|
||||
<SharedText size="sm" color="text-gray-400">Deleted Users</SharedText>
|
||||
<SharedText weight="bold" color="text-error-red">{viewData.stats.deletedUsers}</SharedText>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
<SharedBox borderTop borderColor="border-gray" opacity={0.3} />
|
||||
<SharedBox>
|
||||
<SharedStack direction="row" align="center" justify="between" py={2}>
|
||||
<SharedText size="sm" color="text-gray-400">New Registrations (24h)</SharedText>
|
||||
<SharedText weight="bold" color="text-primary-blue">{viewData.stats.newUsersToday}</SharedText>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
</SharedStack>
|
||||
</SharedCard>
|
||||
|
||||
{/* Quick Operations */}
|
||||
<Card p={6}>
|
||||
<Stack gap={6}>
|
||||
<AdminSectionHeader title="Quick Operations" />
|
||||
<Grid cols={1} gap={3}>
|
||||
<QuickActionLink href={routes.admin.users} variant="blue">
|
||||
<Stack direction="row" align="center" justify="between" fullWidth>
|
||||
<Text size="sm" weight="bold">User Management</Text>
|
||||
<Icon icon={ArrowRight} size={4} />
|
||||
</Stack>
|
||||
</QuickActionLink>
|
||||
<QuickActionLink href="/admin" variant="purple">
|
||||
<Stack direction="row" align="center" justify="between" fullWidth>
|
||||
<Text size="sm" weight="bold">Security & Roles</Text>
|
||||
<Icon icon={ArrowRight} size={4} />
|
||||
</Stack>
|
||||
</QuickActionLink>
|
||||
<QuickActionLink href="/admin" variant="orange">
|
||||
<Stack direction="row" align="center" justify="between" fullWidth>
|
||||
<Text size="sm" weight="bold">System Audit Logs</Text>
|
||||
<Icon icon={ArrowRight} size={4} />
|
||||
</Stack>
|
||||
</QuickActionLink>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
{/* Quick Operations */}
|
||||
<SharedCard p={6}>
|
||||
<SharedStack gap={6}>
|
||||
<AdminSectionHeader title="Quick Operations" />
|
||||
<SharedGrid cols={1} gap={3}>
|
||||
<QuickActionLink href={routes.admin.users} label="User Management" icon={Users} />
|
||||
<QuickActionLink href="/admin" label="Security & Roles" icon={Shield} />
|
||||
<QuickActionLink href="/admin" label="System Audit Logs" icon={Activity} />
|
||||
</SharedGrid>
|
||||
</SharedStack>
|
||||
</SharedCard>
|
||||
</SharedGrid>
|
||||
|
||||
<AdminDangerZonePanel
|
||||
title="System Maintenance"
|
||||
description="Perform destructive system-wide operations. Use with extreme caution."
|
||||
>
|
||||
<Button variant="danger" size="sm">
|
||||
Enter Maintenance Mode
|
||||
</Button>
|
||||
</AdminDangerZonePanel>
|
||||
</Stack>
|
||||
</Container>
|
||||
<AdminDangerZonePanel
|
||||
title="System Maintenance"
|
||||
description="Perform destructive system-wide operations. Use with extreme caution."
|
||||
>
|
||||
<SharedButton variant="danger" size="sm">
|
||||
Enter Maintenance Mode
|
||||
</SharedButton>
|
||||
</AdminDangerZonePanel>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,16 +7,15 @@ import { AdminStatsPanel } from '@/components/admin/AdminStatsPanel';
|
||||
import { AdminUsersTable } from '@/components/admin/AdminUsersTable';
|
||||
import { BulkActionBar } from '@/components/admin/BulkActionBar';
|
||||
import { UserFilters } from '@/components/admin/UserFilters';
|
||||
import { InlineNotice } from '@/ui/InlineNotice';
|
||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { SharedButton, SharedContainer, SharedIcon, SharedStack, SharedBox, SharedText } from '@/components/shared/UIComponents';
|
||||
import { RefreshCw, ShieldAlert, Users } from 'lucide-react';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface AdminUsersTemplateProps {
|
||||
viewData: AdminUsersViewData;
|
||||
// We need to add InlineNotice to UIComponents if it's used
|
||||
// For now I'll assume it's a component or I'll add it to UIComponents
|
||||
|
||||
interface AdminUsersTemplateProps extends TemplateProps<AdminUsersViewData> {
|
||||
onRefresh: () => void;
|
||||
onSearch: (search: string) => void;
|
||||
onFilterRole: (role: string) => void;
|
||||
@@ -37,13 +36,6 @@ interface AdminUsersTemplateProps {
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminUsersTemplate
|
||||
*
|
||||
* Redesigned user management page.
|
||||
* Uses semantic admin UI blocks and follows "Precision Racing Minimal" theme.
|
||||
* Stateless template.
|
||||
*/
|
||||
export function AdminUsersTemplate({
|
||||
viewData,
|
||||
onRefresh,
|
||||
@@ -103,76 +95,83 @@ export function AdminUsersTemplate({
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={8}>
|
||||
<AdminHeaderPanel
|
||||
title="User Management"
|
||||
description="Monitor and control system access"
|
||||
isLoading={loading}
|
||||
actions={
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Icon icon={RefreshCw} size={3} animate={loading ? 'spin' : 'none'} />}
|
||||
>
|
||||
Refresh Data
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<InlineNotice
|
||||
variant="error"
|
||||
title="Operation Failed"
|
||||
message={error}
|
||||
<SharedContainer size="lg">
|
||||
<SharedBox paddingY={8}>
|
||||
<SharedStack gap={8}>
|
||||
<AdminHeaderPanel
|
||||
title="User Management"
|
||||
description="Monitor and control system access"
|
||||
isLoading={loading}
|
||||
actions={
|
||||
<SharedButton
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<SharedIcon icon={RefreshCw} size={3} animate={loading ? 'spin' : 'none'} />}
|
||||
>
|
||||
Refresh Data
|
||||
</SharedButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminStatsPanel stats={stats} />
|
||||
|
||||
<UserFilters
|
||||
search={search}
|
||||
roleFilter={roleFilter}
|
||||
statusFilter={statusFilter}
|
||||
onSearch={onSearch}
|
||||
onFilterRole={onFilterRole}
|
||||
onFilterStatus={onFilterStatus}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
|
||||
<AdminDataTable>
|
||||
{viewData.users.length === 0 && !loading ? (
|
||||
<AdminEmptyState
|
||||
icon={Users}
|
||||
title="No users found"
|
||||
description="Try adjusting your filters or search query"
|
||||
action={
|
||||
<Button variant="secondary" size="sm" onClick={onClearFilters}>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AdminUsersTable
|
||||
users={viewData.users}
|
||||
selectedUserIds={selectedUserIds}
|
||||
onSelectUser={onSelectUser}
|
||||
onSelectAll={onSelectAll}
|
||||
onUpdateStatus={onUpdateStatus}
|
||||
onDeleteUser={onDeleteUser}
|
||||
deletingUserId={deletingUser}
|
||||
/>
|
||||
{/* error notice should be a component */}
|
||||
{error && (
|
||||
<SharedBox bg="bg-error-red/10" p={4} rounded="md" border borderColor="border-error-red/20">
|
||||
<SharedStack direction="row" align="center" gap={3}>
|
||||
<SharedIcon icon={ShieldAlert} size={5} color="text-error-red" />
|
||||
<SharedBox>
|
||||
<SharedText weight="bold" color="text-error-red">Operation Failed</SharedText>
|
||||
<SharedText size="sm" color="text-error-red/80">{error}</SharedText>
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
)}
|
||||
</AdminDataTable>
|
||||
|
||||
<BulkActionBar
|
||||
selectedCount={selectedUserIds.length}
|
||||
actions={bulkActions}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
<AdminStatsPanel stats={stats} />
|
||||
|
||||
<UserFilters
|
||||
search={search}
|
||||
roleFilter={roleFilter}
|
||||
statusFilter={statusFilter}
|
||||
onSearch={onSearch}
|
||||
onFilterRole={onFilterRole}
|
||||
onFilterStatus={onFilterStatus}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
|
||||
<AdminDataTable>
|
||||
{viewData.users.length === 0 && !loading ? (
|
||||
<AdminEmptyState
|
||||
icon={Users}
|
||||
title="No users found"
|
||||
description="Try adjusting your filters or search query"
|
||||
action={
|
||||
<SharedButton variant="secondary" size="sm" onClick={onClearFilters}>
|
||||
Clear All Filters
|
||||
</SharedButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AdminUsersTable
|
||||
users={viewData.users}
|
||||
selectedUserIds={selectedUserIds}
|
||||
onSelectUser={onSelectUser}
|
||||
onSelectAll={onSelectAll}
|
||||
onUpdateStatus={onUpdateStatus}
|
||||
onDeleteUser={onDeleteUser}
|
||||
deletingUserId={deletingUser}
|
||||
/>
|
||||
)}
|
||||
</AdminDataTable>
|
||||
|
||||
<BulkActionBar
|
||||
selectedCount={selectedUserIds.length}
|
||||
actions={bulkActions}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedContainer>
|
||||
);
|
||||
}
|
||||
|
||||
485
apps/website/templates/CreateLeagueWizardTemplate.tsx
Normal file
485
apps/website/templates/CreateLeagueWizardTemplate.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, ReactNode } from 'react';
|
||||
import { LeagueReviewSummary } from '@/components/leagues/LeagueReviewSummary';
|
||||
import {
|
||||
SharedBox,
|
||||
SharedButton,
|
||||
SharedStack,
|
||||
SharedText,
|
||||
SharedIcon,
|
||||
SharedContainer
|
||||
} from '@/components/shared/UIComponents';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Input } from '@/ui/Input';
|
||||
import {
|
||||
AlertCircle,
|
||||
Award,
|
||||
Calendar,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Loader2,
|
||||
Scale,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { LeagueBasicsSection } from '@/components/leagues/LeagueBasicsSection';
|
||||
import { LeagueDropSection } from '@/components/leagues/LeagueDropSection';
|
||||
import {
|
||||
ChampionshipsSection,
|
||||
ScoringPatternSection
|
||||
} from '@/components/leagues/LeagueScoringSection';
|
||||
import { LeagueStewardingSection } from '@/components/leagues/LeagueStewardingSection';
|
||||
import { LeagueStructureSection } from '@/components/leagues/LeagueStructureSection';
|
||||
import { LeagueTimingsSection } from '@/components/leagues/LeagueTimingsSection';
|
||||
import { LeagueVisibilitySection } from '@/components/leagues/LeagueVisibilitySection';
|
||||
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
|
||||
import type { WizardErrors } from '@/lib/types/WizardErrors';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export type Step = 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||
|
||||
interface CreateLeagueWizardTemplateProps extends TemplateProps<ViewData> {
|
||||
step: Step;
|
||||
steps: any[];
|
||||
form: any;
|
||||
errors: WizardErrors;
|
||||
loading: boolean;
|
||||
presetsLoading: boolean;
|
||||
presets: LeagueScoringPresetViewModel[];
|
||||
highestCompletedStep: number;
|
||||
onGoToStep: (step: Step) => void;
|
||||
onFormChange: (form: any) => void;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
onNextStep: () => void;
|
||||
onPreviousStep: () => void;
|
||||
onScoringPresetChange: (id: string) => void;
|
||||
onToggleCustomScoring: () => void;
|
||||
getStepTitle: (step: Step) => string;
|
||||
getStepSubtitle: (step: Step) => string;
|
||||
getStepContextLabel: (step: Step) => string;
|
||||
}
|
||||
|
||||
export function CreateLeagueWizardTemplate({
|
||||
step,
|
||||
steps,
|
||||
form,
|
||||
errors,
|
||||
loading,
|
||||
presetsLoading,
|
||||
presets,
|
||||
highestCompletedStep,
|
||||
onGoToStep,
|
||||
onFormChange,
|
||||
onSubmit,
|
||||
onNextStep,
|
||||
onPreviousStep,
|
||||
onScoringPresetChange,
|
||||
onToggleCustomScoring,
|
||||
getStepTitle,
|
||||
getStepSubtitle,
|
||||
getStepContextLabel,
|
||||
}: CreateLeagueWizardTemplateProps) {
|
||||
const currentStepData = steps.find((s) => s.id === step);
|
||||
const CurrentStepIcon = currentStepData?.icon ?? FileText;
|
||||
|
||||
return (
|
||||
<SharedBox as="form" onSubmit={onSubmit} maxWidth="4xl" mx="auto" pb={8}>
|
||||
{/* Header with icon */}
|
||||
<SharedBox mb={8}>
|
||||
<SharedStack direction="row" align="center" gap={3} mb={3}>
|
||||
<SharedBox display="flex" h="11" w="11" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/20" border borderColor="border-primary-blue/20">
|
||||
<SharedIcon icon={Sparkles} size={5} color="text-primary-blue" />
|
||||
</SharedBox>
|
||||
<SharedBox>
|
||||
<Heading level={1} fontSize={{ base: '2xl', sm: '3xl' }}>
|
||||
Create a new league
|
||||
</Heading>
|
||||
<SharedText size="sm" color="text-gray-500" block>
|
||||
We'll also set up your first season in {steps.length} easy steps.
|
||||
</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500" block mt={1}>
|
||||
A league is your long-term brand. Each season is a block of races you can run again and again.
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
|
||||
{/* Desktop Progress Bar */}
|
||||
<SharedBox display={{ base: 'none', md: 'block' }} mb={8}>
|
||||
<SharedBox position="relative">
|
||||
{/* Background track */}
|
||||
<SharedBox position="absolute" top="5" left="6" right="6" h="0.5" bg="bg-charcoal-outline" rounded="full" />
|
||||
{/* Progress fill */}
|
||||
<SharedBox
|
||||
position="absolute"
|
||||
top="5"
|
||||
left="6"
|
||||
h="0.5"
|
||||
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
|
||||
rounded="full"
|
||||
transition
|
||||
width={`calc(${((step - 1) / (steps.length - 1)) * 100}% - 48px)`}
|
||||
/>
|
||||
|
||||
<SharedBox position="relative" display="flex" justifyContent="between">
|
||||
{steps.map((wizardStep) => {
|
||||
const isCompleted = wizardStep.id < step;
|
||||
const isCurrent = wizardStep.id === step;
|
||||
const isAccessible = wizardStep.id <= highestCompletedStep;
|
||||
const StepIcon = wizardStep.icon;
|
||||
|
||||
return (
|
||||
<SharedBox
|
||||
as="button"
|
||||
key={wizardStep.id}
|
||||
type="button"
|
||||
onClick={() => onGoToStep(wizardStep.id)}
|
||||
disabled={!isAccessible}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
bg="bg-transparent"
|
||||
borderStyle="none"
|
||||
cursor={isAccessible ? 'pointer' : 'not-allowed'}
|
||||
opacity={!isAccessible ? 0.6 : 1}
|
||||
>
|
||||
<SharedBox
|
||||
position="relative"
|
||||
zIndex={10}
|
||||
display="flex"
|
||||
h="10"
|
||||
w="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
transition
|
||||
bg={isCurrent || isCompleted ? 'bg-primary-blue' : 'bg-iron-gray'}
|
||||
color={isCurrent || isCompleted ? 'text-white' : 'text-gray-400'}
|
||||
border={!isCurrent && !isCompleted}
|
||||
borderColor="border-charcoal-outline"
|
||||
shadow={isCurrent ? '0_0_24px_rgba(25,140,255,0.5)' : undefined}
|
||||
transform={isCurrent ? 'scale-110' : isCompleted ? 'hover:scale-105' : undefined}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<SharedIcon icon={Check} size={4} strokeWidth={3} />
|
||||
) : (
|
||||
<SharedIcon icon={StepIcon} size={4} />
|
||||
)}
|
||||
</SharedBox>
|
||||
<SharedBox mt={2} textAlign="center">
|
||||
<SharedText
|
||||
size="xs"
|
||||
weight="medium"
|
||||
transition
|
||||
color={isCurrent ? 'text-white' : isCompleted ? 'text-primary-blue' : isAccessible ? 'text-gray-400' : 'text-gray-500'}
|
||||
>
|
||||
{wizardStep.label}
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
);
|
||||
})}
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
|
||||
{/* Mobile Progress */}
|
||||
<SharedBox display={{ base: 'block', md: 'none' }} mb={6}>
|
||||
<SharedBox display="flex" alignItems="center" justifyContent="between" mb={2}>
|
||||
<SharedStack direction="row" align="center" gap={2}>
|
||||
<SharedIcon icon={CurrentStepIcon} size={4} color="text-primary-blue" />
|
||||
<SharedText size="sm" weight="medium" color="text-white">{currentStepData?.label}</SharedText>
|
||||
</SharedStack>
|
||||
<SharedText size="xs" color="text-gray-500">
|
||||
{step}/{steps.length}
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
<SharedBox h="1.5" bg="bg-charcoal-outline" rounded="full" overflow="hidden">
|
||||
<SharedBox
|
||||
h="full"
|
||||
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
|
||||
rounded="full"
|
||||
transition
|
||||
height="full"
|
||||
width={`${(step / steps.length) * 100}%`}
|
||||
/>
|
||||
</SharedBox>
|
||||
{/* Step dots */}
|
||||
<SharedBox display="flex" justifyContent="between" mt={2} px={0.5}>
|
||||
{steps.map((s) => (
|
||||
<SharedBox
|
||||
key={s.id}
|
||||
h="1.5"
|
||||
rounded="full"
|
||||
transition
|
||||
width={s.id === step ? '4' : '1.5'}
|
||||
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/60' : 'bg-charcoal-outline'}
|
||||
/>
|
||||
))}
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
|
||||
{/* Main Card */}
|
||||
<Card position="relative" overflow="hidden">
|
||||
{/* Top gradient accent */}
|
||||
<SharedBox position="absolute" top="0" left="0" right="0" h="1" bg="bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
|
||||
|
||||
{/* Step header */}
|
||||
<SharedBox display="flex" alignItems="start" gap={4} mb={6}>
|
||||
<SharedBox display="flex" h="12" w="12" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10" flexShrink={0} transition>
|
||||
<SharedIcon icon={CurrentStepIcon} size={6} color="text-primary-blue" />
|
||||
</SharedBox>
|
||||
<SharedBox flexGrow={1} minWidth="0">
|
||||
<Heading level={2} fontSize={{ base: 'xl', md: '2xl' }} color="text-white">
|
||||
<SharedStack direction="row" align="center" gap={2} flexWrap="wrap">
|
||||
<SharedText>{getStepTitle(step)}</SharedText>
|
||||
<SharedText size="xs" weight="medium" px={2} py={0.5} rounded="full" border borderColor="border-charcoal-outline" bg="bg-iron-gray/60" color="text-gray-300">
|
||||
{getStepContextLabel(step)}
|
||||
</SharedText>
|
||||
</SharedStack>
|
||||
</Heading>
|
||||
<SharedText size="sm" color="text-gray-400" block mt={1}>
|
||||
{getStepSubtitle(step)}
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
<SharedBox display={{ base: 'none', sm: 'flex' }} alignItems="center" gap={1.5} px={3} py={1.5} rounded="full" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
|
||||
<SharedText size="xs" color="text-gray-500">Step</SharedText>
|
||||
<SharedText size="sm" weight="semibold" color="text-white">{step}</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500">/ {steps.length}</SharedText>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
|
||||
{/* Divider */}
|
||||
<SharedBox h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" mb={6} />
|
||||
|
||||
{/* Step content with min-height for consistency */}
|
||||
<SharedBox minHeight="320px">
|
||||
{step === 1 && (
|
||||
<SharedBox animate="fade-in" gap={8} display="flex" flexDirection="col">
|
||||
<LeagueBasicsSection
|
||||
form={form}
|
||||
onChange={onFormChange}
|
||||
errors={errors.basics ?? {}}
|
||||
/>
|
||||
<SharedBox rounded="xl" border borderColor="border-charcoal-outline" bg="bg-iron-gray/40" p={4}>
|
||||
<SharedBox display="flex" alignItems="center" justifyContent="between" gap={2} mb={2}>
|
||||
<SharedBox>
|
||||
<SharedText size="xs" weight="semibold" color="text-gray-300" uppercase letterSpacing="wide">
|
||||
First season
|
||||
</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500" block>
|
||||
Name the first season that will run in this league.
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
<SharedBox mt={2} display="flex" flexDirection="col" gap={2}>
|
||||
<SharedText as="label" size="sm" weight="medium" color="text-gray-300" block>
|
||||
Season name
|
||||
</SharedText>
|
||||
<Input
|
||||
value={form.seasonName ?? ''}
|
||||
onChange={(e) =>
|
||||
onFormChange({
|
||||
...form,
|
||||
seasonName: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., Season 1 (2025)"
|
||||
/>
|
||||
<SharedText size="xs" color="text-gray-500" block>
|
||||
Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later.
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<SharedBox animate="fade-in">
|
||||
<LeagueVisibilitySection
|
||||
form={form}
|
||||
onChange={onFormChange}
|
||||
errors={
|
||||
errors.basics?.visibility
|
||||
? { visibility: errors.basics.visibility }
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</SharedBox>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<SharedBox animate="fade-in" display="flex" flexDirection="col" gap={4}>
|
||||
<SharedBox mb={2}>
|
||||
<SharedText size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
<LeagueStructureSection
|
||||
form={form}
|
||||
onChange={onFormChange}
|
||||
readOnly={false}
|
||||
/>
|
||||
</SharedBox>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<SharedBox animate="fade-in" display="flex" flexDirection="col" gap={4}>
|
||||
<SharedBox mb={2}>
|
||||
<SharedText size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
<LeagueTimingsSection
|
||||
form={form}
|
||||
onChange={onFormChange}
|
||||
errors={errors.timings ?? {}}
|
||||
/>
|
||||
</SharedBox>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<SharedBox animate="fade-in" display="flex" flexDirection="col" gap={8}>
|
||||
<SharedBox mb={2}>
|
||||
<SharedText size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
{/* Scoring Pattern Selection */}
|
||||
<ScoringPatternSection
|
||||
scoring={form.scoring || {}}
|
||||
presets={presets}
|
||||
readOnly={presetsLoading}
|
||||
patternError={errors.scoring?.patternId ?? ''}
|
||||
onChangePatternId={onScoringPresetChange}
|
||||
onToggleCustomScoring={onToggleCustomScoring}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<SharedBox h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
|
||||
|
||||
{/* Championships & Drop Rules side by side on larger screens */}
|
||||
<SharedBox display="grid" gridCols={{ base: 1, lg: 2 }} gap={6}>
|
||||
<ChampionshipsSection form={form} onChange={onFormChange} readOnly={presetsLoading} />
|
||||
<LeagueDropSection form={form} onChange={onFormChange} readOnly={false} />
|
||||
</SharedBox>
|
||||
|
||||
{errors.submit && (
|
||||
<SharedBox display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
|
||||
<SharedIcon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
|
||||
<SharedText size="sm" color="text-warning-amber">{errors.submit}</SharedText>
|
||||
</SharedBox>
|
||||
)}
|
||||
</SharedBox>
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
<SharedBox animate="fade-in" display="flex" flexDirection="col" gap={4}>
|
||||
<SharedBox mb={2}>
|
||||
<SharedText size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
<LeagueStewardingSection
|
||||
form={form}
|
||||
onChange={onFormChange}
|
||||
readOnly={false}
|
||||
/>
|
||||
</SharedBox>
|
||||
)}
|
||||
|
||||
{step === 7 && (
|
||||
<SharedBox animate="fade-in" display="flex" flexDirection="col" gap={6}>
|
||||
<LeagueReviewSummary form={form} presets={presets} />
|
||||
{errors.submit && (
|
||||
<SharedBox display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
|
||||
<SharedIcon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
|
||||
<SharedText size="sm" color="text-warning-amber">{errors.submit}</SharedText>
|
||||
</SharedBox>
|
||||
)}
|
||||
</SharedBox>
|
||||
)}
|
||||
</SharedBox>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<SharedBox display="flex" alignItems="center" justifyContent="between" mt={6}>
|
||||
<SharedButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={step === 1 || loading}
|
||||
onClick={onPreviousStep}
|
||||
icon={<SharedIcon icon={ChevronLeft} size={4} />}
|
||||
>
|
||||
<SharedText display={{ base: 'none', md: 'inline-block' }}>Back</SharedText>
|
||||
</SharedButton>
|
||||
|
||||
<SharedBox display="flex" alignItems="center" gap={3}>
|
||||
{/* Mobile step dots */}
|
||||
<SharedBox display={{ base: 'flex', sm: 'none' }} alignItems="center" gap={1}>
|
||||
{steps.map((s) => (
|
||||
<SharedBox
|
||||
key={s.id}
|
||||
h="1.5"
|
||||
rounded="full"
|
||||
transition
|
||||
width={s.id === step ? '3' : '1.5'}
|
||||
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/50' : 'bg-charcoal-outline'}
|
||||
/>
|
||||
))}
|
||||
</SharedBox>
|
||||
|
||||
{step < 7 ? (
|
||||
<SharedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
onClick={onNextStep}
|
||||
icon={<SharedIcon icon={ChevronRight} size={4} />}
|
||||
>
|
||||
<SharedText>Continue</SharedText>
|
||||
</SharedButton>
|
||||
) : (
|
||||
<SharedButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
style={{ minWidth: '150px' }}
|
||||
icon={loading ? <SharedIcon icon={Loader2} size={4} animate="spin" /> : <SharedIcon icon={Sparkles} size={4} />}
|
||||
>
|
||||
{loading ? (
|
||||
<SharedText>Creating…</SharedText>
|
||||
) : (
|
||||
<SharedText>Create League</SharedText>
|
||||
)}
|
||||
</SharedButton>
|
||||
)}
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
|
||||
{/* Helper text */}
|
||||
<SharedText size="xs" color="text-gray-500" align="center" block mt={4}>
|
||||
This will create your league and its first season. You can edit both later.
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { LeagueHeaderPanel } from '@/components/leagues/LeagueHeaderPanel';
|
||||
import { LeagueNavTabs } from '@/components/leagues/LeagueNavTabs';
|
||||
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Text } from '@/ui/Text';
|
||||
import {
|
||||
SharedBox,
|
||||
SharedLink,
|
||||
SharedText,
|
||||
SharedStack,
|
||||
SharedContainer
|
||||
} from '@/components/shared/UIComponents';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
interface Tab {
|
||||
label: string;
|
||||
href: string;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
interface LeagueDetailTemplateProps {
|
||||
viewData: LeagueDetailViewData;
|
||||
tabs: Tab[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LeagueDetailTemplate({
|
||||
viewData,
|
||||
tabs,
|
||||
children,
|
||||
}: LeagueDetailTemplateProps) {
|
||||
const pathname = usePathname();
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
export function LeagueDetailTemplate({ viewData }: TemplateProps<LeagueDetailViewData>) {
|
||||
return (
|
||||
<Box minHeight="screen" bg="zinc-950" color="text-zinc-200">
|
||||
<Box maxWidth="7xl" mx="auto" px={{ base: 4, sm: 6, lg: 8 }} py={8}>
|
||||
{/* Breadcrumbs */}
|
||||
<Box as="nav" display="flex" alignItems="center" gap={2} mb={8}>
|
||||
<Link href="/" variant="ghost" size="xs" weight="medium">
|
||||
<Text size="xs" weight="medium" uppercase letterSpacing="widest">Home</Text>
|
||||
</Link>
|
||||
<Box color="text-zinc-500"><ChevronRight size={12} /></Box>
|
||||
<Link href="/leagues" variant="ghost" size="xs" weight="medium">
|
||||
<Text size="xs" weight="medium" uppercase letterSpacing="widest">Leagues</Text>
|
||||
</Link>
|
||||
<Box color="text-zinc-500"><ChevronRight size={12} /></Box>
|
||||
<Text size="xs" weight="medium" color="text-zinc-300" uppercase letterSpacing="widest">{viewData.name}</Text>
|
||||
</Box>
|
||||
|
||||
<LeagueHeaderPanel viewData={viewData} />
|
||||
|
||||
<LeagueNavTabs tabs={tabs} currentPathname={pathname} />
|
||||
|
||||
<Box as="main">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<SharedContainer size="lg">
|
||||
<SharedBox paddingY={8}>
|
||||
<SharedStack gap={8}>
|
||||
<SharedBox>
|
||||
<SharedStack direction="row" align="center" gap={2}>
|
||||
<SharedLink href={routes.public.leagues}>
|
||||
<SharedText size="sm" color="text-gray-400">Leagues</SharedText>
|
||||
</SharedLink>
|
||||
<SharedIcon icon={ChevronRight} size={3} color="text-gray-500" />
|
||||
<SharedText size="sm" color="text-white">{viewData.name}</SharedText>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
{/* ... rest of the template ... */}
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedContainer>
|
||||
);
|
||||
}
|
||||
|
||||
import { SharedIcon } from '@/components/shared/UIComponents';
|
||||
|
||||
60
apps/website/templates/LeagueWalletTemplate.tsx
Normal file
60
apps/website/templates/LeagueWalletTemplate.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { WalletSummaryPanel } from '@/components/leagues/WalletSummaryPanel';
|
||||
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
|
||||
import {
|
||||
SharedBox,
|
||||
SharedButton,
|
||||
SharedStack,
|
||||
SharedText,
|
||||
SharedIcon,
|
||||
SharedContainer
|
||||
} from '@/components/shared/UIComponents';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Download } from 'lucide-react';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface LeagueWalletTemplateProps extends TemplateProps<LeagueWalletViewData> {
|
||||
onWithdraw?: (amount: number) => void;
|
||||
onExport?: () => void;
|
||||
mutationLoading?: boolean;
|
||||
transactions: any[];
|
||||
}
|
||||
|
||||
export function LeagueWalletTemplate({ viewData, onExport, transactions }: LeagueWalletTemplateProps) {
|
||||
return (
|
||||
<SharedContainer size="lg">
|
||||
<SharedBox paddingY={8}>
|
||||
{/* Header */}
|
||||
<SharedBox display="flex" alignItems="center" justifyContent="between" mb={8}>
|
||||
<SharedBox>
|
||||
<Heading level={1}>League Wallet</Heading>
|
||||
<SharedText color="text-gray-400">Manage your league's finances and payouts</SharedText>
|
||||
</SharedBox>
|
||||
<SharedButton variant="secondary" onClick={onExport}>
|
||||
<SharedStack direction="row" align="center" gap={2}>
|
||||
<SharedIcon icon={Download} size={4} />
|
||||
<SharedText>Export</SharedText>
|
||||
</SharedStack>
|
||||
</SharedButton>
|
||||
</SharedBox>
|
||||
|
||||
<WalletSummaryPanel
|
||||
balance={viewData.balance}
|
||||
currency="USD"
|
||||
transactions={transactions}
|
||||
onDeposit={() => {}} // Not implemented for leagues yet
|
||||
onWithdraw={() => {}} // Not implemented for leagues yet
|
||||
/>
|
||||
|
||||
{/* Alpha Notice */}
|
||||
<SharedBox mt={6} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
|
||||
<SharedText size="xs" color="text-gray-400">
|
||||
<SharedText weight="bold" color="text-warning-amber">Alpha Note:</SharedText> Wallet management is demonstration-only.
|
||||
Real payment processing and bank integrations will be available when the payment system is fully implemented.
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedContainer>
|
||||
);
|
||||
}
|
||||
183
apps/website/templates/LeaguesTemplate.tsx
Normal file
183
apps/website/templates/LeaguesTemplate.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
|
||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
import {
|
||||
SharedBox,
|
||||
SharedButton,
|
||||
SharedStack,
|
||||
SharedText,
|
||||
SharedIcon,
|
||||
SharedContainer
|
||||
} from '@/components/shared/UIComponents';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Input } from '@/ui/Input';
|
||||
import {
|
||||
Award,
|
||||
Clock,
|
||||
Flag,
|
||||
Flame,
|
||||
Globe,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Target,
|
||||
Timer,
|
||||
Trophy,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
export type CategoryId =
|
||||
| 'all'
|
||||
| 'driver'
|
||||
| 'team'
|
||||
| 'nations'
|
||||
| 'trophy'
|
||||
| 'new'
|
||||
| 'popular'
|
||||
| 'openSlots'
|
||||
| 'endurance'
|
||||
| 'sprint';
|
||||
|
||||
export interface Category {
|
||||
id: CategoryId;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
filter: (league: LeaguesViewData['leagues'][number]) => boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface LeaguesTemplateProps extends TemplateProps<LeaguesViewData> {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
activeCategory: CategoryId;
|
||||
onCategoryChange: (id: CategoryId) => void;
|
||||
filteredLeagues: LeaguesViewData['leagues'];
|
||||
categories: Category[];
|
||||
onCreateLeague: () => void;
|
||||
onLeagueClick: (id: string) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
export function LeaguesTemplate({
|
||||
viewData,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
filteredLeagues,
|
||||
categories,
|
||||
onCreateLeague,
|
||||
onLeagueClick,
|
||||
onClearFilters,
|
||||
}: LeaguesTemplateProps) {
|
||||
return (
|
||||
<SharedBox minHeight="screen" bg="zinc-950" color="text-zinc-200">
|
||||
<SharedBox maxWidth="7xl" mx="auto" px={{ base: 4, sm: 6, lg: 8 }} py={12}>
|
||||
{/* Hero */}
|
||||
<SharedBox as="header" display="flex" flexDirection={{ base: 'col', md: 'row' }} alignItems={{ base: 'start', md: 'end' }} justifyContent="between" gap={8} mb={16}>
|
||||
<SharedStack gap={4}>
|
||||
<SharedBox display="flex" alignItems="center" gap={3} color="text-blue-500">
|
||||
<Trophy size={24} />
|
||||
<SharedText fontSize="xs" weight="bold" uppercase letterSpacing="widest">Competition Hub</SharedText>
|
||||
</SharedBox>
|
||||
<Heading level={1} fontSize="5xl" weight="bold" color="text-white">
|
||||
Find Your <SharedText as="span" color="text-blue-500">Grid</SharedText>
|
||||
</Heading>
|
||||
<SharedText color="text-zinc-400" maxWidth="md" leading="relaxed">
|
||||
From casual sprints to epic endurance battles — discover the perfect league for your racing style.
|
||||
</SharedText>
|
||||
</SharedStack>
|
||||
|
||||
<SharedBox display="flex" alignItems="center" gap={4}>
|
||||
<SharedBox display="flex" flexDirection="col" alignItems="end">
|
||||
<SharedText fontSize="2xl" weight="bold" color="text-white" font="mono">{viewData.leagues.length}</SharedText>
|
||||
<SharedText weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Active Leagues</SharedText>
|
||||
</SharedBox>
|
||||
<SharedBox w="px" h="8" bg="zinc-800" />
|
||||
<SharedButton
|
||||
onClick={onCreateLeague}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
>
|
||||
<SharedStack direction="row" align="center" gap={2}>
|
||||
<Plus size={16} />
|
||||
Create League
|
||||
</SharedStack>
|
||||
</SharedButton>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<SharedBox as="section" display="flex" flexDirection="col" gap={8} mb={12}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search leagues by name, description, or game..."
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value)}
|
||||
icon={<Search size={20} />}
|
||||
/>
|
||||
|
||||
<SharedBox as="nav" display="flex" flexWrap="wrap" gap={2}>
|
||||
{categories.map((category) => {
|
||||
const isActive = activeCategory === category.id;
|
||||
const CategoryIcon = category.icon;
|
||||
return (
|
||||
<SharedButton
|
||||
key={category.id}
|
||||
onClick={() => onCategoryChange(category.id)}
|
||||
variant={isActive ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
<SharedStack direction="row" align="center" gap={2}>
|
||||
<SharedBox
|
||||
color={!isActive && category.color ? category.color : undefined}
|
||||
>
|
||||
<CategoryIcon size={14} />
|
||||
</SharedBox>
|
||||
<SharedText>{category.label}</SharedText>
|
||||
</SharedStack>
|
||||
</SharedButton>
|
||||
);
|
||||
})}
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
|
||||
{/* Grid */}
|
||||
<SharedBox as="main">
|
||||
{filteredLeagues.length > 0 ? (
|
||||
<SharedBox display="grid" responsiveGridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
|
||||
{filteredLeagues.map((league) => (
|
||||
<LeagueCard
|
||||
key={league.id}
|
||||
league={league as unknown as LeagueSummaryViewModel}
|
||||
onClick={() => onLeagueClick(league.id)}
|
||||
/>
|
||||
))}
|
||||
</SharedBox>
|
||||
) : (
|
||||
<SharedBox display="flex" flexDirection="col" alignItems="center" justifyContent="center" py={24} border borderStyle="dashed" borderColor="zinc-800" bg="zinc-900/20">
|
||||
<SharedBox color="text-zinc-800" mb={4}>
|
||||
<Search size={48} />
|
||||
</SharedBox>
|
||||
<Heading level={3} fontSize="xl" weight="bold" color="text-zinc-500">No Leagues Found</Heading>
|
||||
<SharedText color="text-zinc-600" size="sm" mt={2}>Try adjusting your search or filters</SharedText>
|
||||
<SharedButton
|
||||
variant="ghost"
|
||||
style={{ marginTop: '1.5rem' }}
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear All Filters
|
||||
</SharedButton>
|
||||
</SharedBox>
|
||||
)}
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,24 @@
|
||||
|
||||
import { MediaGallery } from '@/components/media/MediaGallery';
|
||||
import { MediaViewData } from '@/lib/view-data/MediaViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { SharedBox, SharedContainer } from '@/components/shared/UIComponents';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
export function MediaTemplate(viewData: MediaViewData) {
|
||||
export function MediaTemplate({ viewData }: TemplateProps<MediaViewData>) {
|
||||
const { assets, categories, title, description } = viewData;
|
||||
|
||||
return (
|
||||
<Container py={8}>
|
||||
<Box display="flex" flexDirection="col" gap={8}>
|
||||
<MediaGallery
|
||||
assets={assets}
|
||||
categories={categories}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
</Box>
|
||||
</Container>
|
||||
<SharedContainer>
|
||||
<SharedBox paddingY={8}>
|
||||
<SharedBox display="flex" flexDirection="col" gap={8}>
|
||||
<MediaGallery
|
||||
assets={assets}
|
||||
categories={categories}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedContainer>
|
||||
);
|
||||
}
|
||||
|
||||
104
apps/website/templates/ProfileLiveryUploadTemplate.tsx
Normal file
104
apps/website/templates/ProfileLiveryUploadTemplate.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { UploadDropzone } from '@/ui/UploadDropzone';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import {
|
||||
SharedBox,
|
||||
SharedButton,
|
||||
SharedStack,
|
||||
SharedText,
|
||||
SharedContainer,
|
||||
SharedCard
|
||||
} from '@/components/shared/UIComponents';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { MediaMetaPanel, mapMediaMetadata } from '@/ui/MediaMetaPanel';
|
||||
import { MediaPreviewCard } from '@/ui/MediaPreviewCard';
|
||||
import Link from 'next/link';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
interface ProfileLiveryUploadTemplateProps extends TemplateProps<ViewData> {
|
||||
selectedFile: File | null;
|
||||
previewUrl: string | null;
|
||||
isUploading: boolean;
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
onUpload: () => void;
|
||||
}
|
||||
|
||||
export function ProfileLiveryUploadTemplate({
|
||||
selectedFile,
|
||||
previewUrl,
|
||||
isUploading,
|
||||
onFilesSelected,
|
||||
onUpload,
|
||||
}: ProfileLiveryUploadTemplateProps) {
|
||||
return (
|
||||
<SharedContainer size="md">
|
||||
<SharedBox paddingY={8}>
|
||||
<SharedBox mb={6}>
|
||||
<Heading level={1}>Upload livery</Heading>
|
||||
<SharedText color="text-gray-500">
|
||||
Upload your custom car livery. Supported formats: .png, .jpg, .tga
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
|
||||
<SharedBox display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
|
||||
<SharedBox>
|
||||
<SharedCard>
|
||||
<UploadDropzone
|
||||
onFilesSelected={onFilesSelected}
|
||||
accept=".png,.jpg,.jpeg,.tga"
|
||||
maxSize={10 * 1024 * 1024} // 10MB
|
||||
isLoading={isUploading}
|
||||
/>
|
||||
|
||||
<SharedBox mt={6} display="flex" justifyContent="end" gap={3}>
|
||||
<Link href={routes.protected.profileLiveries}>
|
||||
<SharedButton variant="ghost">Cancel</SharedButton>
|
||||
</Link>
|
||||
<SharedButton
|
||||
variant="primary"
|
||||
disabled={!selectedFile || isUploading}
|
||||
onClick={onUpload}
|
||||
isLoading={isUploading}
|
||||
>
|
||||
Upload Livery
|
||||
</SharedButton>
|
||||
</SharedBox>
|
||||
</SharedCard>
|
||||
</SharedBox>
|
||||
|
||||
<SharedBox>
|
||||
{previewUrl ? (
|
||||
<SharedBox display="flex" flexDirection="col" gap={6}>
|
||||
<MediaPreviewCard
|
||||
type="image"
|
||||
src={previewUrl}
|
||||
alt={selectedFile?.name || 'Livery preview'}
|
||||
title={selectedFile?.name}
|
||||
subtitle="Preview"
|
||||
aspectRatio="16/9"
|
||||
/>
|
||||
|
||||
<MediaMetaPanel
|
||||
items={mapMediaMetadata({
|
||||
filename: selectedFile?.name,
|
||||
size: selectedFile?.size,
|
||||
contentType: selectedFile?.type || 'image/tga',
|
||||
createdAt: new Date(),
|
||||
})}
|
||||
/>
|
||||
</SharedBox>
|
||||
) : (
|
||||
<SharedCard center p={12}>
|
||||
<SharedText color="text-gray-500" align="center">
|
||||
Select a file to see preview and details
|
||||
</SharedText>
|
||||
</SharedCard>
|
||||
)}
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedContainer>
|
||||
);
|
||||
}
|
||||
566
apps/website/templates/ProtestDetailTemplate.tsx
Normal file
566
apps/website/templates/ProtestDetailTemplate.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
SharedBox,
|
||||
SharedButton,
|
||||
SharedStack,
|
||||
SharedText,
|
||||
SharedIcon,
|
||||
SharedCard,
|
||||
SharedContainer
|
||||
} from '@/components/shared/UIComponents';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Link as UILink } from '@/ui/Link';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Flag,
|
||||
Gavel,
|
||||
Grid3x3,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Send,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
TrendingDown,
|
||||
User,
|
||||
Video,
|
||||
XCircle,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
interface ProtestDetailTemplateProps extends TemplateProps<ViewData> {
|
||||
protestDetail: any;
|
||||
leagueId: string;
|
||||
showDecisionPanel: boolean;
|
||||
setShowDecisionPanel: (show: boolean) => void;
|
||||
decision: 'uphold' | 'dismiss' | null;
|
||||
setDecision: (decision: 'uphold' | 'dismiss' | null) => void;
|
||||
penaltyType: string;
|
||||
setPenaltyType: (type: string) => void;
|
||||
penaltyValue: number;
|
||||
setPenaltyValue: (value: number) => void;
|
||||
stewardNotes: string;
|
||||
setStewardNotes: (notes: string) => void;
|
||||
submitting: boolean;
|
||||
newComment: string;
|
||||
setNewComment: (comment: string) => void;
|
||||
penaltyTypes: any[];
|
||||
selectedPenalty: any;
|
||||
onSubmitDecision: () => void;
|
||||
onRequestDefense: () => void;
|
||||
getStatusConfig: (status: string) => any;
|
||||
}
|
||||
|
||||
export function ProtestDetailTemplate({
|
||||
protestDetail,
|
||||
leagueId,
|
||||
showDecisionPanel,
|
||||
setShowDecisionPanel,
|
||||
decision,
|
||||
setDecision,
|
||||
penaltyType,
|
||||
setPenaltyType,
|
||||
penaltyValue,
|
||||
setPenaltyValue,
|
||||
stewardNotes,
|
||||
setStewardNotes,
|
||||
submitting,
|
||||
newComment,
|
||||
setNewComment,
|
||||
penaltyTypes,
|
||||
selectedPenalty,
|
||||
onSubmitDecision,
|
||||
onRequestDefense,
|
||||
getStatusConfig,
|
||||
}: ProtestDetailTemplateProps) {
|
||||
if (!protestDetail) return null;
|
||||
|
||||
const protest = protestDetail.protest || protestDetail;
|
||||
const race = protestDetail.race;
|
||||
const protestingDriver = protestDetail.protestingDriver;
|
||||
const accusedDriver = protestDetail.accusedDriver;
|
||||
|
||||
const statusConfig = getStatusConfig(protest.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const isPending = protest.status === 'pending';
|
||||
const submittedAt = protest.submittedAt || protestDetail.submittedAt;
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(submittedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<SharedBox minHeight="100vh">
|
||||
<SharedContainer size="lg">
|
||||
<SharedBox paddingY={8}>
|
||||
{/* Compact Header */}
|
||||
<SharedBox mb={6}>
|
||||
<SharedStack direction="row" align="center" gap={3} mb={4}>
|
||||
<UILink href={routes.league.stewarding(leagueId)}>
|
||||
<SharedIcon icon={ArrowLeft} size={5} color="text-gray-400" />
|
||||
</UILink>
|
||||
<SharedStack direction="row" align="center" gap={3} flexGrow={1}>
|
||||
<Heading level={1}>Protest Review</Heading>
|
||||
<SharedBox display="flex" alignItems="center" gap={1.5} px={2.5} py={1} rounded="full" fontSize="0.75rem" weight="medium" border bg={statusConfig.bg} color={statusConfig.color} borderColor={statusConfig.borderColor}>
|
||||
<SharedIcon icon={StatusIcon} size={3} />
|
||||
<SharedText>{statusConfig.label}</SharedText>
|
||||
</SharedBox>
|
||||
{daysSinceFiled > 2 && isPending && (
|
||||
<SharedBox display="flex" alignItems="center" gap={1} px={2} py={0.5} fontSize="0.75rem" weight="medium" bg="bg-red-500/20" color="text-red-400" rounded="full">
|
||||
<SharedIcon icon={AlertTriangle} size={3} />
|
||||
<SharedText>{daysSinceFiled}d old</SharedText>
|
||||
</SharedBox>
|
||||
)}
|
||||
</SharedStack>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
|
||||
{/* Main Layout: Feed + Sidebar */}
|
||||
<Grid cols={12} gap={6}>
|
||||
{/* Left Sidebar - Incident Info */}
|
||||
<GridItem colSpan={{ base: 12, lg: 3 }}>
|
||||
<SharedStack gap={4}>
|
||||
{/* Drivers Involved */}
|
||||
<SharedCard>
|
||||
<SharedBox p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Parties Involved</Heading>
|
||||
|
||||
<SharedStack gap={3}>
|
||||
{/* Protesting Driver */}
|
||||
<UILink href={routes.driver.detail(protestingDriver?.id || '')} block>
|
||||
<SharedBox display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-blue-500/50" hoverBg="bg-blue-500/5" transition cursor="pointer">
|
||||
<SharedBox w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<SharedIcon icon={User} size={5} color="text-blue-400" />
|
||||
</SharedBox>
|
||||
<SharedBox flexGrow={1} minWidth="0">
|
||||
<SharedText size="xs" color="text-blue-400" weight="medium" block>Protesting</SharedText>
|
||||
<SharedText size="sm" weight="semibold" color="text-white" truncate block>{protestingDriver?.name || 'Unknown'}</SharedText>
|
||||
</SharedBox>
|
||||
<SharedIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</SharedBox>
|
||||
</UILink>
|
||||
|
||||
{/* Accused Driver */}
|
||||
<UILink href={routes.driver.detail(accusedDriver?.id || '')} block>
|
||||
<SharedBox display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-orange-500/50" hoverBg="bg-orange-500/5" transition cursor="pointer">
|
||||
<SharedBox w={10} h={10} rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<SharedIcon icon={User} size={5} color="text-orange-400" />
|
||||
</SharedBox>
|
||||
<SharedBox flexGrow={1} minWidth="0">
|
||||
<SharedText size="xs" color="text-orange-400" weight="medium" block>Accused</SharedText>
|
||||
<SharedText size="sm" weight="semibold" color="text-white" truncate block>{accusedDriver?.name || 'Unknown'}</SharedText>
|
||||
</SharedBox>
|
||||
<SharedIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</SharedBox>
|
||||
</UILink>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedCard>
|
||||
|
||||
{/* Race Info */}
|
||||
<SharedCard>
|
||||
<SharedBox p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Race Details</Heading>
|
||||
|
||||
<SharedBox marginBottom={3}>
|
||||
<UILink
|
||||
href={routes.race.detail(race?.id || '')}
|
||||
block
|
||||
>
|
||||
<SharedBox p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-primary-blue/50" hoverBg="bg-primary-blue/5" transition>
|
||||
<SharedBox display="flex" alignItems="center" justifyContent="between">
|
||||
<SharedText size="sm" weight="medium" color="text-white">{race?.name || 'Unknown Race'}</SharedText>
|
||||
<SharedIcon icon={ExternalLink} size={3} color="text-gray-500" />
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</UILink>
|
||||
</SharedBox>
|
||||
|
||||
<SharedStack gap={2}>
|
||||
<SharedBox display="flex" alignItems="center" gap={2}>
|
||||
<SharedIcon icon={MapPin} size={4} color="text-gray-500" />
|
||||
<SharedText size="sm" color="text-gray-300">{race?.name || 'Unknown Track'}</SharedText>
|
||||
</SharedBox>
|
||||
<SharedBox display="flex" alignItems="center" gap={2}>
|
||||
<SharedIcon icon={Calendar} size={4} color="text-gray-500" />
|
||||
<SharedText size="sm" color="text-gray-300">{race?.formattedDate || (race?.scheduledAt ? new Date(race.scheduledAt).toLocaleDateString() : 'Unknown Date')}</SharedText>
|
||||
</SharedBox>
|
||||
{protest.incident?.lap && (
|
||||
<SharedBox display="flex" alignItems="center" gap={2}>
|
||||
<SharedIcon icon={Flag} size={4} color="text-gray-500" />
|
||||
<SharedText size="sm" color="text-gray-300">Lap {protest.incident.lap}</SharedText>
|
||||
</SharedBox>
|
||||
)}
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedCard>
|
||||
|
||||
{protest.proofVideoUrl && (
|
||||
<SharedCard>
|
||||
<SharedBox p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Evidence</Heading>
|
||||
<UILink
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
block
|
||||
>
|
||||
<SharedBox display="flex" alignItems="center" gap={2} p={3} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" color="text-primary-blue" hoverBg="bg-primary-blue/20" transition>
|
||||
<SharedIcon icon={Video} size={4} />
|
||||
<SharedText size="sm" weight="medium" flexGrow={1}>Watch Video</SharedText>
|
||||
<SharedIcon icon={ExternalLink} size={3} />
|
||||
</SharedBox>
|
||||
</UILink>
|
||||
</SharedBox>
|
||||
</SharedCard>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<SharedCard>
|
||||
<SharedBox p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Timeline</Heading>
|
||||
<SharedStack gap={2}>
|
||||
<SharedBox display="flex" justifyContent="between">
|
||||
<SharedText size="sm" color="text-gray-500">Filed</SharedText>
|
||||
<SharedText size="sm" color="text-gray-300">{new Date(submittedAt).toLocaleDateString()}</SharedText>
|
||||
</SharedBox>
|
||||
<SharedBox display="flex" justifyContent="between">
|
||||
<SharedText size="sm" color="text-gray-500">Age</SharedText>
|
||||
<SharedText size="sm" color={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</SharedText>
|
||||
</SharedBox>
|
||||
{protest.reviewedAt && (
|
||||
<SharedBox display="flex" justifyContent="between">
|
||||
<SharedText size="sm" color="text-gray-500">Resolved</SharedText>
|
||||
<SharedText size="sm" color="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</SharedText>
|
||||
</SharedBox>
|
||||
)}
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedCard>
|
||||
</SharedStack>
|
||||
</GridItem>
|
||||
|
||||
{/* Center - Discussion Feed */}
|
||||
<GridItem colSpan={12} lgSpan={6}>
|
||||
<SharedStack gap={4}>
|
||||
{/* Timeline / Feed */}
|
||||
<SharedCard>
|
||||
<SharedBox borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/30" p={4}>
|
||||
<Heading level={2}>Discussion</Heading>
|
||||
</SharedBox>
|
||||
|
||||
<SharedStack gap={0}>
|
||||
{/* Initial Protest Filing */}
|
||||
<SharedBox p={4}>
|
||||
<SharedBox display="flex" gap={3}>
|
||||
<SharedBox w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<SharedIcon icon={AlertCircle} size={5} color="text-blue-400" />
|
||||
</SharedBox>
|
||||
<SharedBox flexGrow={1} minWidth="0">
|
||||
<SharedBox display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<SharedText weight="semibold" color="text-white" size="sm">{protestingDriver?.name || 'Unknown'}</SharedText>
|
||||
<SharedText size="xs" color="text-blue-400" weight="medium">filed protest</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500">•</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500">{new Date(submittedAt).toLocaleString()}</SharedText>
|
||||
</SharedBox>
|
||||
|
||||
<SharedBox bg="bg-deep-graphite" rounded="lg" p={4} border borderColor="border-charcoal-outline">
|
||||
<SharedText size="sm" color="text-gray-300" block mb={3}>{protest.description || protestDetail.incident?.description}</SharedText>
|
||||
|
||||
{(protest.comment || protestDetail.comment) && (
|
||||
<SharedBox mt={3} pt={3} borderTop borderColor="border-charcoal-outline/50">
|
||||
<SharedText size="xs" color="text-gray-500" block mb={1}>Additional details:</SharedText>
|
||||
<SharedText size="sm" color="text-gray-400">{protest.comment || protestDetail.comment}</SharedText>
|
||||
</SharedBox>
|
||||
)}
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
|
||||
{/* Defense placeholder */}
|
||||
{protest.status === 'awaiting_defense' && (
|
||||
<SharedBox p={4} bg="bg-purple-500/5">
|
||||
<SharedBox display="flex" gap={3}>
|
||||
<SharedBox w={10} h={10} rounded="full" bg="bg-purple-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<SharedIcon icon={MessageCircle} size={5} color="text-purple-400" />
|
||||
</SharedBox>
|
||||
<SharedBox flexGrow={1}>
|
||||
<SharedText size="sm" color="text-purple-400" weight="medium" block mb={1}>Defense Requested</SharedText>
|
||||
<SharedText size="sm" color="text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</SharedText>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
)}
|
||||
|
||||
{/* Decision (if resolved) */}
|
||||
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
|
||||
<SharedBox p={4} bg={protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}>
|
||||
<SharedBox display="flex" gap={3}>
|
||||
<SharedBox w={10} h={10} rounded="full" display="flex" alignItems="center" justifyContent="center" flexShrink={0} bg={protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'}>
|
||||
<SharedIcon icon={Gavel} size={5} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'} />
|
||||
</SharedBox>
|
||||
<SharedBox flexGrow={1} minWidth="0">
|
||||
<SharedBox display="flex" alignItems="center" gap={2} mb={1}>
|
||||
<SharedText weight="semibold" color="text-white" size="sm">Steward Decision</SharedText>
|
||||
<SharedText size="xs" weight="medium" color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
|
||||
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
|
||||
</SharedText>
|
||||
{protest.reviewedAt && (
|
||||
<>
|
||||
<SharedText size="xs" color="text-gray-500">•</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</SharedText>
|
||||
</>
|
||||
)}
|
||||
</SharedBox>
|
||||
|
||||
<SharedBox rounded="lg" p={4} border bg={protest.status === 'upheld' ? 'bg-red-500/10' : 'bg-gray-500/10'} borderColor={protest.status === 'upheld' ? 'border-red-500/20' : 'border-gray-500/20'}>
|
||||
<SharedText size="sm" color="text-gray-300">{protest.decisionNotes}</SharedText>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
)}
|
||||
</SharedStack>
|
||||
|
||||
{/* Add Comment */}
|
||||
{isPending && (
|
||||
<SharedBox p={4} borderTop borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<SharedBox display="flex" gap={3}>
|
||||
<SharedBox w={10} h={10} rounded="full" bg="bg-iron-gray" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<SharedIcon icon={User} size={5} color="text-gray-500" />
|
||||
</SharedBox>
|
||||
<SharedBox flexGrow={1}>
|
||||
<SharedBox as="textarea"
|
||||
value={newComment}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment or request more information..."
|
||||
style={{ height: '4rem' }}
|
||||
w="full"
|
||||
px={4}
|
||||
py={3}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
<SharedBox display="flex" justifyContent="end" mt={2}>
|
||||
<SharedButton variant="secondary" disabled={!newComment.trim()}>
|
||||
<SharedIcon icon={Send} size={3} style={{ marginRight: '0.25rem' }} />
|
||||
Comment
|
||||
</SharedButton>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
)}
|
||||
</SharedCard>
|
||||
</SharedStack>
|
||||
</GridItem>
|
||||
|
||||
{/* Right Sidebar - Actions */}
|
||||
<GridItem colSpan={12} lgSpan={3}>
|
||||
<SharedStack gap={4}>
|
||||
{isPending && (
|
||||
<>
|
||||
{/* Quick Actions */}
|
||||
<SharedCard>
|
||||
<SharedBox p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Actions</Heading>
|
||||
|
||||
<SharedStack gap={2}>
|
||||
<SharedButton
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onClick={onRequestDefense}
|
||||
>
|
||||
<SharedStack direction="row" align="center" gap={2}>
|
||||
<SharedIcon icon={MessageCircle} size={4} />
|
||||
<SharedText>Request Defense</SharedText>
|
||||
</SharedStack>
|
||||
</SharedButton>
|
||||
|
||||
<SharedButton
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
|
||||
>
|
||||
<SharedStack direction="row" align="center" gap={2} fullWidth>
|
||||
<SharedIcon icon={Gavel} size={4} />
|
||||
<SharedText>Make Decision</SharedText>
|
||||
<SharedBox ml="auto" transition style={{ transform: showDecisionPanel ? 'rotate(180deg)' : 'none' }}>
|
||||
<SharedIcon icon={ChevronDown} size={4} />
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
</SharedButton>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedCard>
|
||||
|
||||
{/* Decision Panel */}
|
||||
{showDecisionPanel && (
|
||||
<SharedCard>
|
||||
<SharedBox p={4}>
|
||||
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Stewarding Decision</Heading>
|
||||
|
||||
{/* Decision Selection */}
|
||||
<Grid cols={2} gap={2} mb={4}>
|
||||
<SharedBox padding={3} border borderColor={decision === 'uphold' ? 'border-racing-red' : 'border-charcoal-outline'} bg={decision === 'uphold' ? 'bg-racing-red/10' : 'transparent'} rounded="lg">
|
||||
<SharedButton
|
||||
variant="ghost"
|
||||
onClick={() => setDecision('uphold')}
|
||||
fullWidth
|
||||
>
|
||||
<SharedStack align="center" gap={1}>
|
||||
<SharedIcon icon={CheckCircle} size={5} color={decision === 'uphold' ? 'text-red-400' : 'text-gray-500'} />
|
||||
<SharedText size="xs" weight="medium" color={decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}>Uphold</SharedText>
|
||||
</SharedStack>
|
||||
</SharedButton>
|
||||
</SharedBox>
|
||||
<SharedBox padding={3} border borderColor={decision === 'dismiss' ? 'border-gray-500' : 'border-charcoal-outline'} bg={decision === 'dismiss' ? 'bg-gray-500/10' : 'transparent'} rounded="lg">
|
||||
<SharedButton
|
||||
variant="ghost"
|
||||
onClick={() => setDecision('dismiss')}
|
||||
fullWidth
|
||||
>
|
||||
<SharedStack align="center" gap={1}>
|
||||
<SharedIcon icon={XCircle} size={5} color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'} />
|
||||
<SharedText size="xs" weight="medium" color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}>Dismiss</SharedText>
|
||||
</SharedStack>
|
||||
</SharedButton>
|
||||
</SharedBox>
|
||||
</Grid>
|
||||
|
||||
{/* Penalty Selection (if upholding) */}
|
||||
{decision === 'uphold' && (
|
||||
<SharedBox mb={4}>
|
||||
<SharedText as="label" size="xs" weight="medium" color="text-gray-400" block mb={2}>Penalty Type</SharedText>
|
||||
|
||||
{penaltyTypes.length === 0 ? (
|
||||
<SharedText size="xs" color="text-gray-500">
|
||||
Loading penalty types...
|
||||
</SharedText>
|
||||
) : (
|
||||
<>
|
||||
<Grid cols={2} gap={2}>
|
||||
{penaltyTypes.map((penalty: any) => {
|
||||
const Icon = penalty.icon;
|
||||
const isSelected = penaltyType === penalty.type;
|
||||
return (
|
||||
<SharedBox key={penalty.type} padding={2} border borderColor={isSelected ? undefined : 'border-charcoal-outline'} bg={isSelected ? undefined : 'bg-iron-gray/30'} rounded="lg">
|
||||
<SharedButton
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPenaltyType(penalty.type);
|
||||
setPenaltyValue(penalty.defaultValue);
|
||||
}}
|
||||
fullWidth
|
||||
title={penalty.description}
|
||||
>
|
||||
<SharedStack align="start" gap={0.5}>
|
||||
<SharedIcon icon={Icon} size={3.5} color={isSelected ? penalty.color : 'text-gray-500'} />
|
||||
<SharedText size="xs" weight="medium" fontSize="10px" color={isSelected ? penalty.color : 'text-gray-500'}>
|
||||
{penalty.label}
|
||||
</SharedText>
|
||||
</SharedStack>
|
||||
</SharedButton>
|
||||
</SharedBox>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{selectedPenalty?.requiresValue && (
|
||||
<SharedBox mt={3}>
|
||||
<SharedText as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>
|
||||
Value ({selectedPenalty.valueLabel})
|
||||
</SharedText>
|
||||
<SharedBox as="input"
|
||||
type="number"
|
||||
value={penaltyValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPenaltyValue(Number(e.target.value))}
|
||||
min="1"
|
||||
w="full"
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
</SharedBox>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SharedBox>
|
||||
)}
|
||||
|
||||
{/* Steward Notes */}
|
||||
<SharedBox mb={4}>
|
||||
<SharedText as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>Decision Reasoning *</SharedText>
|
||||
<SharedBox as="textarea"
|
||||
value={stewardNotes}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision..."
|
||||
style={{ height: '8rem' }}
|
||||
w="full"
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
fontSize="sm"
|
||||
/>
|
||||
</SharedBox>
|
||||
|
||||
{/* Submit */}
|
||||
<SharedButton
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={onSubmitDecision}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decision'}
|
||||
</SharedButton>
|
||||
</SharedBox>
|
||||
</SharedCard>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Already Resolved Info */}
|
||||
{!isPending && (
|
||||
<SharedCard>
|
||||
<SharedBox p={4} textAlign="center">
|
||||
<SharedBox py={4} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
|
||||
<SharedIcon icon={Gavel} size={8} style={{ margin: '0 auto 0.5rem auto' }} />
|
||||
<SharedText weight="semibold" block>Case Closed</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500" block mt={1}>
|
||||
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
</SharedBox>
|
||||
</SharedCard>
|
||||
)}
|
||||
</SharedStack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</SharedBox>
|
||||
</SharedContainer>
|
||||
</SharedBox>
|
||||
);
|
||||
}
|
||||
@@ -3,19 +3,16 @@
|
||||
import { RaceFilterModal } from '@/components/races/RaceFilterModal';
|
||||
import { RacePageHeader } from '@/components/races/RacePageHeader';
|
||||
import { RaceScheduleTable } from '@/components/races/RaceScheduleTable';
|
||||
import { RacesAllLayout, RacesAllStats } from '@/components/races/RacesAllLayout';
|
||||
import { RaceScheduleSection } from '@/components/races/RacesLayout';
|
||||
import type { SessionStatus } from '@/components/races/SessionStatusBadge';
|
||||
import type { RacesViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Pagination } from '@/ui/Pagination';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Skeleton } from '@/ui/Skeleton';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { SharedPagination, SharedText, SharedBox } from '@/components/shared/UIComponents';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
export type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
|
||||
|
||||
interface RacesAllTemplateProps {
|
||||
viewData: RacesViewData;
|
||||
interface RacesAllTemplateProps extends TemplateProps<RacesViewData> {
|
||||
races: RacesViewData['races'];
|
||||
totalFilteredCount: number;
|
||||
isLoading: boolean;
|
||||
@@ -60,100 +57,67 @@ export function RacesAllTemplate({
|
||||
setShowFilterModal,
|
||||
onRaceClick,
|
||||
}: RacesAllTemplateProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={6}>
|
||||
<Skeleton width="100%" height="12rem" />
|
||||
<Stack gap={4}>
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Skeleton key={i} width="100%" height="4rem" />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Note: Loading state is handled by StatefulPageWrapper in the client wrapper
|
||||
|
||||
return (
|
||||
<Box as="main" minHeight="screen" bg="bg-base-black" py={8}>
|
||||
<Container size="lg">
|
||||
<Stack gap={8}>
|
||||
<RacePageHeader
|
||||
totalCount={viewData.totalCount}
|
||||
scheduledCount={viewData.scheduledCount}
|
||||
runningCount={viewData.runningCount}
|
||||
completedCount={viewData.completedCount}
|
||||
<RacesAllLayout
|
||||
header={
|
||||
<RacePageHeader
|
||||
totalCount={viewData.totalCount}
|
||||
scheduledCount={viewData.scheduledCount}
|
||||
runningCount={viewData.runningCount}
|
||||
completedCount={viewData.completedCount}
|
||||
/>
|
||||
}
|
||||
stats={
|
||||
<RacesAllStats
|
||||
count={totalFilteredCount}
|
||||
onFilterClick={() => setShowFilterModal(true)}
|
||||
/>
|
||||
}
|
||||
pagination={
|
||||
<SharedPagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<RaceScheduleSection title="Race Schedule">
|
||||
{races.length === 0 ? (
|
||||
<SharedBox p={12} textAlign="center">
|
||||
<SharedText color="text-gray-500">No races found matching your criteria.</SharedText>
|
||||
</SharedBox>
|
||||
) : (
|
||||
<RaceScheduleTable
|
||||
races={races.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
leagueName: race.leagueName,
|
||||
time: race.timeLabel,
|
||||
status: race.status as SessionStatus
|
||||
}))}
|
||||
onRaceClick={onRaceClick}
|
||||
/>
|
||||
)}
|
||||
</RaceScheduleSection>
|
||||
|
||||
<Box display="flex" justifyContent="between" alignItems="center">
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Showing <Text as="span" color="text-white" weight="bold">{totalFilteredCount}</Text> races
|
||||
</Text>
|
||||
<Box
|
||||
as="button"
|
||||
onClick={() => setShowFilterModal(true)}
|
||||
px={4}
|
||||
py={2}
|
||||
bg="bg-surface-charcoal"
|
||||
border
|
||||
borderColor="border-outline-steel"
|
||||
fontSize="10px"
|
||||
weight="bold"
|
||||
uppercase
|
||||
letterSpacing="wider"
|
||||
hoverBorderColor="border-primary-accent"
|
||||
transition
|
||||
>
|
||||
Filters
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box as="section" bg="bg-surface-charcoal" border borderColor="border-outline-steel" overflow="hidden">
|
||||
{races.length === 0 ? (
|
||||
<Box p={12} textAlign="center">
|
||||
<Text color="text-gray-500">No races found matching your criteria.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<RaceScheduleTable
|
||||
races={races.map(race => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
leagueName: race.leagueName,
|
||||
time: race.timeLabel,
|
||||
status: race.status as SessionStatus
|
||||
}))}
|
||||
onRaceClick={onRaceClick}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalFilteredCount}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
|
||||
<RaceFilterModal
|
||||
isOpen={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
timeFilter="all"
|
||||
setTimeFilter={() => {}}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
leagues={viewData.leagues}
|
||||
showSearch={true}
|
||||
showTimeFilter={false}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
<RaceFilterModal
|
||||
isOpen={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
timeFilter="all"
|
||||
setTimeFilter={() => {}}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
leagues={viewData.leagues}
|
||||
showSearch={true}
|
||||
showTimeFilter={false}
|
||||
/>
|
||||
</RacesAllLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,18 +7,20 @@ import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHe
|
||||
import { SponsorStatusChip } from '@/components/sponsors/SponsorStatusChip';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Container } from '@/ui/Container';
|
||||
import {
|
||||
SharedBox,
|
||||
SharedButton,
|
||||
SharedStack,
|
||||
SharedText,
|
||||
SharedIcon,
|
||||
SharedCard,
|
||||
SharedContainer
|
||||
} from '@/components/shared/UIComponents';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import {
|
||||
BarChart3,
|
||||
Calendar,
|
||||
@@ -31,8 +33,10 @@ import {
|
||||
Trophy,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
interface SponsorLeagueDetailViewData {
|
||||
export interface SponsorLeagueDetailViewData extends ViewData {
|
||||
league: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -99,8 +103,7 @@ interface SponsorLeagueDetailViewData {
|
||||
|
||||
export type SponsorLeagueDetailTab = 'overview' | 'drivers' | 'races' | 'sponsor';
|
||||
|
||||
interface SponsorLeagueDetailTemplateProps {
|
||||
viewData: SponsorLeagueDetailViewData;
|
||||
interface SponsorLeagueDetailTemplateProps extends TemplateProps<SponsorLeagueDetailViewData> {
|
||||
activeTab: SponsorLeagueDetailTab;
|
||||
setActiveTab: (tab: SponsorLeagueDetailTab) => void;
|
||||
selectedTier: 'main' | 'secondary';
|
||||
@@ -171,253 +174,258 @@ export function SponsorLeagueDetailTemplate({
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="lg" py={8}>
|
||||
<Stack gap={8}>
|
||||
{/* Breadcrumb */}
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Link href={routes.sponsor.dashboard}>
|
||||
<Text size="sm" color="text-gray-400">Dashboard</Text>
|
||||
</Link>
|
||||
<Text size="sm" color="text-gray-500">/</Text>
|
||||
<Link href={routes.sponsor.leagues}>
|
||||
<Text size="sm" color="text-gray-400">Leagues</Text>
|
||||
</Link>
|
||||
<Text size="sm" color="text-gray-500">/</Text>
|
||||
<Text size="sm" color="text-white">{league.name}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
<SharedContainer size="lg">
|
||||
<SharedBox paddingY={8}>
|
||||
<SharedStack gap={8}>
|
||||
{/* Breadcrumb */}
|
||||
<SharedBox>
|
||||
<SharedStack direction="row" align="center" gap={2}>
|
||||
<Link href={routes.sponsor.dashboard}>
|
||||
<SharedText size="sm" color="text-gray-400">Dashboard</SharedText>
|
||||
</Link>
|
||||
<SharedText size="sm" color="text-gray-500">/</SharedText>
|
||||
<Link href={routes.sponsor.leagues}>
|
||||
<SharedText size="sm" color="text-gray-400">Leagues</SharedText>
|
||||
</Link>
|
||||
<SharedText size="sm" color="text-gray-500">/</SharedText>
|
||||
<SharedText size="sm" color="text-white">{league.name}</SharedText>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
|
||||
{/* Header */}
|
||||
<SponsorDashboardHeader
|
||||
sponsorName="Sponsor"
|
||||
onRefresh={() => console.log('Refresh')}
|
||||
/>
|
||||
{/* Header */}
|
||||
<SponsorDashboardHeader
|
||||
sponsorName="Sponsor"
|
||||
onRefresh={() => console.log('Refresh')}
|
||||
/>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<BillingSummaryPanel stats={billingStats} />
|
||||
{/* Quick Stats */}
|
||||
<BillingSummaryPanel stats={billingStats} />
|
||||
|
||||
{/* Tabs */}
|
||||
<Box borderBottom borderColor="border-neutral-800">
|
||||
<Stack direction="row" gap={6}>
|
||||
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
|
||||
<Box
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
pb={3}
|
||||
cursor="pointer"
|
||||
borderBottom={activeTab === tab}
|
||||
borderColor={activeTab === tab ? 'border-primary-blue' : 'border-transparent'}
|
||||
color={activeTab === tab ? 'text-primary-blue' : 'text-gray-400'}
|
||||
>
|
||||
<Text size="sm" weight="medium" uppercase>
|
||||
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
{/* Tabs */}
|
||||
<SharedBox borderBottom borderColor="border-neutral-800">
|
||||
<SharedStack direction="row" gap={6}>
|
||||
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
|
||||
<SharedBox
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{ paddingBottom: '0.75rem' }}
|
||||
cursor="pointer"
|
||||
borderBottom={activeTab === tab}
|
||||
borderColor={activeTab === tab ? 'border-primary-blue' : 'border-transparent'}
|
||||
color={activeTab === tab ? 'text-primary-blue' : 'text-gray-400'}
|
||||
>
|
||||
<SharedText size="sm" weight="medium" className="uppercase">
|
||||
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
|
||||
</SharedText>
|
||||
</SharedBox>
|
||||
))}
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<Grid cols={2} gap={6}>
|
||||
<Card>
|
||||
<Box mb={4}>
|
||||
<Heading level={2} icon={<Icon icon={Trophy} size={5} color="#3b82f6" />}>
|
||||
League Information
|
||||
</Heading>
|
||||
</Box>
|
||||
<Stack gap={3}>
|
||||
<InfoRow label="Platform" value={league.game} />
|
||||
<InfoRow label="Season" value={league.season} />
|
||||
<InfoRow label="Duration" value="Oct 2025 - Feb 2026" />
|
||||
<InfoRow label="Drivers" value={league.drivers} />
|
||||
<InfoRow label="Races" value={league.races} last />
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Box mb={4}>
|
||||
<Heading level={2} icon={<Icon icon={TrendingUp} size={5} color="#10b981" />}>
|
||||
Sponsorship Value
|
||||
</Heading>
|
||||
</Box>
|
||||
<Stack gap={3}>
|
||||
<InfoRow label="Total Season Views" value={league.formattedTotalImpressions} />
|
||||
<InfoRow label="Projected Total" value={league.formattedProjectedTotal} />
|
||||
<InfoRow label="Main Sponsor CPM" value={league.formattedMainSponsorCpm} color="text-performance-green" />
|
||||
<InfoRow label="Engagement Rate" value={`${league.engagement}%`} />
|
||||
<InfoRow label="League Rating" value={`${league.rating}/5.0`} last />
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{league.nextRace && (
|
||||
<GridItem colSpan={2}>
|
||||
<Card>
|
||||
<Box mb={4}>
|
||||
<Heading level={2} icon={<Icon icon={Flag} size={5} color="#f59e0b" />}>
|
||||
Next Race
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<Grid cols={2} gap={6}>
|
||||
<SharedCard>
|
||||
<SharedBox mb={4}>
|
||||
<SharedStack direction="row" align="center" gap={3}>
|
||||
<SharedIcon icon={Trophy} size={5} color="#3b82f6" />
|
||||
<Heading level={2}>
|
||||
League Information
|
||||
</Heading>
|
||||
</Box>
|
||||
<Surface variant="muted" rounded="lg" border padding={4} bg="bg-warning-amber/5" borderColor="border-warning-amber/20">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="lg" padding={3} bg="bg-warning-amber/10">
|
||||
<Icon icon={Flag} size={6} color="#f59e0b" />
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
<SharedStack gap={3}>
|
||||
<InfoRow label="Platform" value={league.game} />
|
||||
<InfoRow label="Season" value={league.season} />
|
||||
<InfoRow label="Duration" value="Oct 2025 - Feb 2026" />
|
||||
<InfoRow label="Drivers" value={league.drivers} />
|
||||
<InfoRow label="Races" value={league.races} last />
|
||||
</SharedStack>
|
||||
</SharedCard>
|
||||
|
||||
<SharedCard>
|
||||
<SharedBox mb={4}>
|
||||
<Heading level={2} icon={<SharedIcon icon={TrendingUp} size={5} color="#10b981" />}>
|
||||
Sponsorship Value
|
||||
</Heading>
|
||||
</SharedBox>
|
||||
<SharedStack gap={3}>
|
||||
<InfoRow label="Total Season Views" value={league.formattedTotalImpressions} />
|
||||
<InfoRow label="Projected Total" value={league.formattedProjectedTotal} />
|
||||
<InfoRow label="Main Sponsor CPM" value={league.formattedMainSponsorCpm} color="text-performance-green" />
|
||||
<InfoRow label="Engagement Rate" value={`${league.engagement}%`} />
|
||||
<InfoRow label="League Rating" value={`${league.rating}/5.0`} last />
|
||||
</SharedStack>
|
||||
</SharedCard>
|
||||
|
||||
{league.nextRace && (
|
||||
<GridItem colSpan={2}>
|
||||
<SharedCard>
|
||||
<SharedBox mb={4}>
|
||||
<Heading level={2} icon={<SharedIcon icon={Flag} size={5} color="#f59e0b" />}>
|
||||
Next Race
|
||||
</Heading>
|
||||
</SharedBox>
|
||||
<Surface variant="muted" rounded="lg" border padding={4} style={{ background: 'rgba(245, 158, 11, 0.05)', borderColor: 'rgba(245, 158, 11, 0.2)' }}>
|
||||
<SharedStack direction="row" align="center" justify="between">
|
||||
<SharedStack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="lg" padding={3} style={{ background: 'rgba(245, 158, 11, 0.1)' }}>
|
||||
<SharedIcon icon={Flag} size={6} color="#f59e0b" />
|
||||
</Surface>
|
||||
<SharedBox>
|
||||
<SharedText size="lg" weight="semibold" color="text-white" block>{league.nextRace.name}</SharedText>
|
||||
<SharedText size="sm" color="text-gray-400" block mt={1}>{league.nextRace.date}</SharedText>
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
<SharedButton variant="secondary">
|
||||
View Schedule
|
||||
</SharedButton>
|
||||
</SharedStack>
|
||||
</Surface>
|
||||
</SharedCard>
|
||||
</GridItem>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{activeTab === 'drivers' && (
|
||||
<SharedCard p={0}>
|
||||
<SharedBox p={4} borderBottom borderColor="border-neutral-800">
|
||||
<Heading level={2}>Championship Standings</Heading>
|
||||
<SharedText size="sm" color="text-gray-400" block mt={1}>Top drivers carrying sponsor branding</SharedText>
|
||||
</SharedBox>
|
||||
<SharedStack gap={0}>
|
||||
{viewData.drivers.map((driver, index) => (
|
||||
<SharedBox key={driver.id} p={4} borderBottom={index < viewData.drivers.length - 1} borderColor="border-neutral-800/50">
|
||||
<SharedStack direction="row" align="center" justify="between">
|
||||
<SharedStack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="full" padding={1} style={{ width: '2.5rem', height: '2.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#262626' }}>
|
||||
<SharedText weight="bold" color="text-white">{driver.position}</SharedText>
|
||||
</Surface>
|
||||
<Box>
|
||||
<Text size="lg" weight="semibold" color="text-white" block>{league.nextRace.name}</Text>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>{league.nextRace.date}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Button variant="secondary">
|
||||
View Schedule
|
||||
</Button>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Card>
|
||||
</GridItem>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
<SharedBox>
|
||||
<SharedText weight="medium" color="text-white" block>{driver.name}</SharedText>
|
||||
<SharedText size="sm" color="text-gray-500" block mt={1}>{driver.team} • {driver.country}</SharedText>
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
<SharedStack direction="row" align="center" gap={8}>
|
||||
<SharedBox textAlign="right">
|
||||
<SharedText weight="medium" color="text-white" block>{driver.races}</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500">races</SharedText>
|
||||
</SharedBox>
|
||||
<SharedBox textAlign="right">
|
||||
<SharedText weight="semibold" color="text-white" block>{driver.formattedImpressions}</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500">views</SharedText>
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
))}
|
||||
</SharedStack>
|
||||
</SharedCard>
|
||||
)}
|
||||
|
||||
{activeTab === 'drivers' && (
|
||||
<Card p={0}>
|
||||
<Box p={4} borderBottom borderColor="border-neutral-800">
|
||||
<Heading level={2}>Championship Standings</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>Top drivers carrying sponsor branding</Text>
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
{viewData.drivers.map((driver, index) => (
|
||||
<Box key={driver.id} p={4} borderBottom={index < viewData.drivers.length - 1} borderColor="border-neutral-800/50">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Surface variant="muted" rounded="full" padding={1} w="10" h="10" display="flex" alignItems="center" justifyContent="center" bg="bg-neutral-800">
|
||||
<Text weight="bold" color="text-white">{driver.position}</Text>
|
||||
</Surface>
|
||||
<Box>
|
||||
<Text weight="medium" color="text-white" block>{driver.name}</Text>
|
||||
<Text size="sm" color="text-gray-500" block mt={1}>{driver.team} • {driver.country}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={8}>
|
||||
<Box textAlign="right">
|
||||
<Text weight="medium" color="text-white" block>{driver.races}</Text>
|
||||
<Text size="xs" color="text-gray-500">races</Text>
|
||||
</Box>
|
||||
<Box textAlign="right">
|
||||
<Text weight="semibold" color="text-white" block>{driver.formattedImpressions}</Text>
|
||||
<Text size="xs" color="text-gray-500">views</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
{activeTab === 'races' && (
|
||||
<SharedCard p={0}>
|
||||
<SharedBox p={4} borderBottom borderColor="border-neutral-800">
|
||||
<Heading level={2}>Race Calendar</Heading>
|
||||
<SharedText size="sm" color="text-gray-400" block mt={1}>Season schedule with view statistics</SharedText>
|
||||
</SharedBox>
|
||||
<SharedStack gap={0}>
|
||||
{viewData.races.map((race, index) => (
|
||||
<SharedBox key={race.id} p={4} borderBottom={index < viewData.races.length - 1} borderColor="border-neutral-800/50">
|
||||
<SharedStack direction="row" align="center" justify="between">
|
||||
<SharedStack direction="row" align="center" gap={4}>
|
||||
<SharedBox w="3" h="3" rounded="full" bg={race.status === 'completed' ? 'bg-performance-green' : 'bg-warning-amber'} />
|
||||
<SharedBox>
|
||||
<SharedText weight="medium" color="text-white" block>{race.name}</SharedText>
|
||||
<SharedText size="sm" color="text-gray-500" block mt={1}>{race.formattedDate}</SharedText>
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
<SharedBox>
|
||||
{race.status === 'completed' ? (
|
||||
<SharedBox textAlign="right">
|
||||
<SharedText weight="semibold" color="text-white" block>{race.views.toLocaleString()}</SharedText>
|
||||
<SharedText size="xs" color="text-gray-500">views</SharedText>
|
||||
</SharedBox>
|
||||
) : (
|
||||
<SponsorStatusChip status="pending" label="Upcoming" />
|
||||
)}
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
))}
|
||||
</SharedStack>
|
||||
</SharedCard>
|
||||
)}
|
||||
|
||||
{activeTab === 'races' && (
|
||||
<Card p={0}>
|
||||
<Box p={4} borderBottom borderColor="border-neutral-800">
|
||||
<Heading level={2}>Race Calendar</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>Season schedule with view statistics</Text>
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
{viewData.races.map((race, index) => (
|
||||
<Box key={race.id} p={4} borderBottom={index < viewData.races.length - 1} borderColor="border-neutral-800/50">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box w="3" h="3" rounded="full" bg={race.status === 'completed' ? 'bg-performance-green' : 'bg-warning-amber'} />
|
||||
<Box>
|
||||
<Text weight="medium" color="text-white" block>{race.name}</Text>
|
||||
<Text size="sm" color="text-gray-500" block mt={1}>{race.formattedDate}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box>
|
||||
{race.status === 'completed' ? (
|
||||
<Box textAlign="right">
|
||||
<Text weight="semibold" color="text-white" block>{race.views.toLocaleString()}</Text>
|
||||
<Text size="xs" color="text-gray-500">views</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<SponsorStatusChip status="pending" label="Upcoming" />
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'sponsor' && (
|
||||
<Grid cols={12} gap={6}>
|
||||
<GridItem colSpan={12} lgSpan={8}>
|
||||
<PricingTableShell
|
||||
title="Sponsorship Tiers"
|
||||
tiers={pricingTiers}
|
||||
selectedId={selectedTier}
|
||||
onSelect={(id) => setSelectedTier(id as 'main' | 'secondary')}
|
||||
/>
|
||||
</GridItem>
|
||||
|
||||
<GridItem colSpan={12} lgSpan={4}>
|
||||
<Stack gap={6}>
|
||||
<SponsorBrandingPreview
|
||||
name="Your Brand"
|
||||
{activeTab === 'sponsor' && (
|
||||
<Grid cols={12} gap={6}>
|
||||
<GridItem colSpan={{ base: 12, lg: 8 }}>
|
||||
<PricingTableShell
|
||||
title="Sponsorship Tiers"
|
||||
tiers={pricingTiers}
|
||||
selectedId={selectedTier}
|
||||
onSelect={(id) => setSelectedTier(id as 'main' | 'secondary')}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Box mb={4}>
|
||||
<Heading level={2} icon={<Icon icon={CreditCard} size={5} color="#3b82f6" />}>
|
||||
Sponsorship Summary
|
||||
</Heading>
|
||||
</Box>
|
||||
</GridItem>
|
||||
|
||||
<GridItem colSpan={{ base: 12, lg: 4 }}>
|
||||
<SharedStack gap={6}>
|
||||
<SponsorBrandingPreview
|
||||
name="Your Brand"
|
||||
/>
|
||||
|
||||
<Stack gap={3} mb={6}>
|
||||
<InfoRow label="Selected Tier" value={`${selectedTier.charAt(0).toUpperCase() + selectedTier.slice(1)} Sponsor`} />
|
||||
<InfoRow label="Season Price" value={`$${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}`} />
|
||||
<InfoRow label={`Platform Fee (${siteConfig.fees.platformFeePercent}%)`} value={`$${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}`} />
|
||||
<Box pt={4} borderTop borderColor="border-neutral-800">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Text weight="semibold" color="text-white">Total (excl. VAT)</Text>
|
||||
<Text size="xl" weight="bold" color="text-white">
|
||||
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
<SharedCard>
|
||||
<SharedBox mb={4}>
|
||||
<Heading level={2} icon={<SharedIcon icon={CreditCard} size={5} color="#3b82f6" />}>
|
||||
Sponsorship Summary
|
||||
</Heading>
|
||||
</SharedBox>
|
||||
|
||||
<SharedStack gap={3} mb={6}>
|
||||
<InfoRow label="Selected Tier" value={`${selectedTier.charAt(0).toUpperCase() + selectedTier.slice(1)} Sponsor`} />
|
||||
<InfoRow label="Season Price" value={`$${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}`} />
|
||||
<InfoRow label={`Platform Fee (${siteConfig.fees.platformFeePercent}%)`} value={`$${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}`} />
|
||||
<SharedBox pt={4} borderTop borderColor="border-neutral-800">
|
||||
<SharedStack direction="row" align="center" justify="between">
|
||||
<SharedText weight="semibold" color="text-white">Total (excl. VAT)</SharedText>
|
||||
<SharedText size="xl" weight="bold" color="text-white">
|
||||
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
|
||||
</SharedText>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
|
||||
<Text size="xs" color="text-gray-500" block mb={4}>
|
||||
{siteConfig.vat.notice}
|
||||
</Text>
|
||||
<SharedText size="xs" color="text-gray-500" block mb={4}>
|
||||
{siteConfig.vat.notice}
|
||||
</SharedText>
|
||||
|
||||
<Stack direction="row" gap={3}>
|
||||
<Button variant="primary" fullWidth icon={<Icon icon={Megaphone} size={4} />}>
|
||||
Request Sponsorship
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<Icon icon={FileText} size={4} />}>
|
||||
Download Info Pack
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
<SharedStack direction="row" gap={3}>
|
||||
<SharedButton variant="primary" fullWidth icon={<SharedIcon icon={Megaphone} size={4} />}>
|
||||
Request Sponsorship
|
||||
</SharedButton>
|
||||
<SharedButton variant="secondary" icon={<SharedIcon icon={FileText} size={4} />}>
|
||||
Download Info Pack
|
||||
</SharedButton>
|
||||
</SharedStack>
|
||||
</SharedCard>
|
||||
</SharedStack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
)}
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
</SharedContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, color = 'text-white', last }: { label: string, value: string | number, color?: string, last?: boolean }) {
|
||||
return (
|
||||
<Box py={2} borderBottom={!last} borderColor="border-neutral-800/50">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Text color="text-gray-400">{label}</Text>
|
||||
<Text weight="medium" color={color}>{value}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
<SharedBox py={2} borderBottom={!last} borderColor="border-neutral-800/50">
|
||||
<SharedStack direction="row" align="center" justify="between">
|
||||
<SharedText color="text-gray-400">{label}</SharedText>
|
||||
<SharedText weight="medium" color={color}>{value}</SharedText>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
);
|
||||
}
|
||||
|
||||
146
apps/website/templates/StewardingTemplate.tsx
Normal file
146
apps/website/templates/StewardingTemplate.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList';
|
||||
import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
|
||||
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
||||
import { StewardingQueuePanel } from '@/components/leagues/StewardingQueuePanel';
|
||||
import { StewardingStats } from '@/components/leagues/StewardingStats';
|
||||
import { PenaltyFAB } from '@/components/races/PenaltyFAB';
|
||||
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
|
||||
import {
|
||||
SharedBox,
|
||||
SharedButton,
|
||||
SharedStack,
|
||||
SharedText,
|
||||
SharedCard
|
||||
} from '@/components/shared/UIComponents';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface StewardingTemplateProps extends TemplateProps<StewardingViewData> {
|
||||
activeTab: 'pending' | 'history';
|
||||
onTabChange: (tab: 'pending' | 'history') => void;
|
||||
selectedProtest: any;
|
||||
onReviewProtest: (id: string) => void;
|
||||
onCloseProtestModal: () => void;
|
||||
onAcceptProtest: (protestId: string, penaltyType: string, penaltyValue: number, stewardNotes: string) => Promise<void>;
|
||||
onRejectProtest: (protestId: string, stewardNotes: string) => Promise<void>;
|
||||
showQuickPenaltyModal: boolean;
|
||||
setShowQuickPenaltyModal: (show: boolean) => void;
|
||||
allPendingProtests: any[];
|
||||
allResolvedProtests: any[];
|
||||
racesMap: any;
|
||||
driverMap: any;
|
||||
currentDriverId: string;
|
||||
}
|
||||
|
||||
export function StewardingTemplate({
|
||||
viewData,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
selectedProtest,
|
||||
onReviewProtest,
|
||||
onCloseProtestModal,
|
||||
onAcceptProtest,
|
||||
onRejectProtest,
|
||||
showQuickPenaltyModal,
|
||||
setShowQuickPenaltyModal,
|
||||
allPendingProtests,
|
||||
allResolvedProtests,
|
||||
racesMap,
|
||||
driverMap,
|
||||
currentDriverId,
|
||||
}: StewardingTemplateProps) {
|
||||
return (
|
||||
<SharedStack gap={6}>
|
||||
<StewardingStats
|
||||
totalPending={viewData.totalPending}
|
||||
totalResolved={viewData.totalResolved}
|
||||
totalPenalties={viewData.totalPenalties}
|
||||
/>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<SharedBox borderBottom borderColor="border-charcoal-outline">
|
||||
<SharedStack direction="row" gap={4}>
|
||||
<SharedBox
|
||||
borderBottom={activeTab === 'pending'}
|
||||
borderColor={activeTab === 'pending' ? 'border-primary-blue' : undefined}
|
||||
>
|
||||
<SharedButton
|
||||
variant="ghost"
|
||||
onClick={() => onTabChange('pending')}
|
||||
rounded={false}
|
||||
>
|
||||
<SharedStack direction="row" align="center" gap={2}>
|
||||
<SharedText weight="medium" color={activeTab === 'pending' ? 'text-primary-blue' : undefined}>Pending Protests</SharedText>
|
||||
{viewData.totalPending > 0 && (
|
||||
<SharedBox px={2} py={0.5} fontSize="0.75rem" bg="bg-warning-amber/20" color="text-warning-amber" rounded="full">
|
||||
{viewData.totalPending}
|
||||
</SharedBox>
|
||||
)}
|
||||
</SharedStack>
|
||||
</SharedButton>
|
||||
</SharedBox>
|
||||
<SharedBox
|
||||
borderBottom={activeTab === 'history'}
|
||||
borderColor={activeTab === 'history' ? 'border-primary-blue' : undefined}
|
||||
>
|
||||
<SharedButton
|
||||
variant="ghost"
|
||||
onClick={() => onTabChange('history')}
|
||||
rounded={false}
|
||||
>
|
||||
<SharedText weight="medium" color={activeTab === 'history' ? 'text-primary-blue' : undefined}>History</SharedText>
|
||||
</SharedButton>
|
||||
</SharedBox>
|
||||
</SharedStack>
|
||||
</SharedBox>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'pending' ? (
|
||||
<StewardingQueuePanel
|
||||
protests={allPendingProtests}
|
||||
onReview={onReviewProtest}
|
||||
/>
|
||||
) : (
|
||||
<SharedCard>
|
||||
<SharedBox p={6}>
|
||||
<PenaltyHistoryList
|
||||
protests={allResolvedProtests}
|
||||
races={racesMap}
|
||||
drivers={driverMap}
|
||||
/>
|
||||
</SharedBox>
|
||||
</SharedCard>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
||||
)}
|
||||
|
||||
{selectedProtest && (
|
||||
<ReviewProtestModal
|
||||
protest={selectedProtest}
|
||||
onClose={onCloseProtestModal}
|
||||
onAccept={onAcceptProtest}
|
||||
onReject={onRejectProtest}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showQuickPenaltyModal && (
|
||||
<QuickPenaltyModal
|
||||
drivers={viewData.drivers.map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
iracingId: '',
|
||||
country: '',
|
||||
joinedAt: '',
|
||||
avatarUrl: null,
|
||||
}) as any)}
|
||||
onClose={() => setShowQuickPenaltyModal(false)}
|
||||
adminId={currentDriverId || ''}
|
||||
races={viewData.races.map(r => ({ id: r.id, track: r.track, scheduledAt: new Date(r.scheduledAt) }))}
|
||||
/>
|
||||
)}
|
||||
</SharedStack>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { TeamCard } from '@/components/teams/TeamCardWrapper';
|
||||
import { TeamGrid } from '@/components/teams/TeamGrid';
|
||||
import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreviewWrapper';
|
||||
import { TeamsDirectoryHeader } from '@/components/teams/TeamsDirectoryHeader';
|
||||
import { TeamsDirectory, TeamsDirectorySection } from '@/components/teams/TeamsDirectory';
|
||||
import { SharedEmptyState } from '@/components/shared/SharedEmptyState';
|
||||
import type { TeamsViewData } from '@/lib/view-data/TeamsViewData';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Users } from 'lucide-react';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
|
||||
interface TeamsTemplateProps {
|
||||
viewData: TeamsViewData;
|
||||
interface TeamsTemplateProps extends TemplateProps<TeamsViewData> {
|
||||
onTeamClick?: (teamId: string) => void;
|
||||
onViewFullLeaderboard: () => void;
|
||||
onCreateTeam: () => void;
|
||||
@@ -23,58 +20,44 @@ export function TeamsTemplate({ viewData, onTeamClick, onViewFullLeaderboard, on
|
||||
const { teams } = viewData;
|
||||
|
||||
return (
|
||||
<Box as="main" bg="base-black" minH="screen">
|
||||
<Container size="lg" py={12}>
|
||||
<Stack gap={10}>
|
||||
<TeamsDirectoryHeader onCreateTeam={onCreateTeam} />
|
||||
<TeamsDirectory>
|
||||
<TeamsDirectoryHeader onCreateTeam={onCreateTeam} />
|
||||
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={2} mb={6}>
|
||||
<Box w="2" h="2" bg="primary-accent" />
|
||||
<Text size="xs" weight="bold" color="text-gray-400" uppercase>Active Rosters</Text>
|
||||
</Stack>
|
||||
|
||||
{teams.length > 0 ? (
|
||||
<TeamGrid>
|
||||
{teams.map((team) => (
|
||||
<TeamCard
|
||||
key={team.teamId}
|
||||
id={team.teamId}
|
||||
name={team.teamName}
|
||||
memberCount={team.memberCount}
|
||||
logo={team.logoUrl}
|
||||
onClick={() => onTeamClick?.(team.teamId)}
|
||||
/>
|
||||
))}
|
||||
</TeamGrid>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No teams yet"
|
||||
description="Get started by creating your first racing team"
|
||||
action={{
|
||||
label: 'Create Team',
|
||||
onClick: onCreateTeam,
|
||||
variant: 'primary'
|
||||
}}
|
||||
<TeamsDirectorySection title="Active Rosters" accentColor="primary-accent">
|
||||
{teams.length > 0 ? (
|
||||
<TeamGrid>
|
||||
{teams.map((team) => (
|
||||
<TeamCard
|
||||
key={team.teamId}
|
||||
id={team.teamId}
|
||||
name={team.teamName}
|
||||
memberCount={team.memberCount}
|
||||
logo={team.logoUrl}
|
||||
onClick={() => onTeamClick?.(team.teamId)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</TeamGrid>
|
||||
) : (
|
||||
<SharedEmptyState
|
||||
icon={Users}
|
||||
title="No teams yet"
|
||||
description="Get started by creating your first racing team"
|
||||
action={{
|
||||
label: 'Create Team',
|
||||
onClick: onCreateTeam,
|
||||
variant: 'primary'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TeamsDirectorySection>
|
||||
|
||||
{/* Team Leaderboard Preview */}
|
||||
<Box pt={10} borderTop borderColor="outline-steel" borderOpacity={0.3}>
|
||||
<Stack direction="row" align="center" gap={2} mb={6}>
|
||||
<Box w="2" h="2" bg="telemetry-aqua" />
|
||||
<Text size="xs" weight="bold" color="text-gray-400" uppercase>Global Standings</Text>
|
||||
</Stack>
|
||||
<TeamLeaderboardPreview
|
||||
topTeams={[]}
|
||||
onTeamClick={(id) => onTeamClick?.(id)}
|
||||
onViewFullLeaderboard={onViewFullLeaderboard}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
<TeamsDirectorySection title="Global Standings" accentColor="telemetry-aqua">
|
||||
<TeamLeaderboardPreview
|
||||
topTeams={[]}
|
||||
onTeamClick={(id) => onTeamClick?.(id)}
|
||||
onViewFullLeaderboard={onViewFullLeaderboard}
|
||||
/>
|
||||
</TeamsDirectorySection>
|
||||
</TeamsDirectory>
|
||||
);
|
||||
}
|
||||
|
||||
9
apps/website/templates/auth/LoginLoadingTemplate.tsx
Normal file
9
apps/website/templates/auth/LoginLoadingTemplate.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { AuthLoading } from '@/components/auth/AuthLoading';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
export function LoginLoadingTemplate({ viewData }: TemplateProps<ViewData>) {
|
||||
return <AuthLoading />;
|
||||
}
|
||||
37
apps/website/templates/shared/StatusTemplates.tsx
Normal file
37
apps/website/templates/shared/StatusTemplates.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { SharedContainer, SharedStack, SharedText } from '@/components/shared/UIComponents';
|
||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||
import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
|
||||
interface ErrorTemplateProps extends TemplateProps<ViewData> {
|
||||
message?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function ErrorTemplate({ message = "An error occurred", description = "Please try again later" }: ErrorTemplateProps) {
|
||||
return (
|
||||
<SharedContainer size="lg">
|
||||
<SharedStack align="center" gap={4} py={12}>
|
||||
<SharedText color="text-red-400">{message}</SharedText>
|
||||
<SharedText color="text-gray-400">{description}</SharedText>
|
||||
</SharedStack>
|
||||
</SharedContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyTemplateProps extends TemplateProps<ViewData> {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function EmptyTemplate({ title, description }: EmptyTemplateProps) {
|
||||
return (
|
||||
<SharedContainer size="lg">
|
||||
<SharedStack align="center" gap={2} py={12}>
|
||||
<SharedText size="xl" weight="semibold" color="text-white">{title}</SharedText>
|
||||
<SharedText color="text-gray-400">{description}</SharedText>
|
||||
</SharedStack>
|
||||
</SharedContainer>
|
||||
);
|
||||
}
|
||||
35
apps/website/ui/GridItem.tsx
Normal file
35
apps/website/ui/GridItem.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { ReactNode, ElementType, forwardRef, ForwardedRef } from 'react';
|
||||
import { Box, BoxProps, ResponsiveValue } from './Box';
|
||||
|
||||
export interface GridItemProps<T extends ElementType> extends BoxProps<T> {
|
||||
as?: T;
|
||||
children?: ReactNode;
|
||||
colSpan?: number | ResponsiveValue<number>;
|
||||
rowSpan?: number | ResponsiveValue<number>;
|
||||
}
|
||||
|
||||
export const GridItem = forwardRef(<T extends ElementType = 'div'>(
|
||||
{
|
||||
children,
|
||||
colSpan,
|
||||
rowSpan,
|
||||
as,
|
||||
...props
|
||||
}: GridItemProps<T>,
|
||||
ref: ForwardedRef<HTMLElement>
|
||||
) => {
|
||||
return (
|
||||
<Box
|
||||
as={as}
|
||||
ref={ref}
|
||||
colSpan={colSpan}
|
||||
// rowSpan is not directly supported by Box yet, but we can add it if needed
|
||||
// or use style
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
GridItem.displayName = 'GridItem';
|
||||
@@ -21,7 +21,7 @@ export const StatusIndicator = ({
|
||||
subLabel,
|
||||
size = 'md',
|
||||
icon
|
||||
}:) => {
|
||||
}: StatusIndicatorProps) => {
|
||||
const activeStatus = (status || variant || 'pending') as any;
|
||||
|
||||
const configMap: any = {
|
||||
|
||||
Reference in New Issue
Block a user