website refactor

This commit is contained in:
2026-01-18 23:43:58 +01:00
parent 7c1cf62d4e
commit c0559d8b48
76 changed files with 39 additions and 89 deletions

View File

@@ -0,0 +1,32 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { AdminDashboardTemplate } from '@/templates/AdminDashboardTemplate';
import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
interface AdminDashboardWrapperProps {
initialViewData: AdminDashboardViewData;
}
export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapperProps) {
const router = useRouter();
// UI state (not business logic)
const [loading, setLoading] = useState(false);
const handleRefresh = useCallback(() => {
setLoading(true);
router.refresh();
// Reset loading after a short delay to show the spinner
setTimeout(() => setLoading(false), 1000);
}, [router]);
return (
<AdminDashboardTemplate
viewData={initialViewData}
onRefresh={handleRefresh}
isLoading={loading}
/>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
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';
interface AdminUsersWrapperProps {
initialViewData: AdminUsersViewData;
}
export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
const router = useRouter();
const searchParams = useSearchParams();
// UI state (not business logic)
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deletingUser, setDeletingUser] = useState<string | null>(null);
const [userToDelete, setUserToDelete] = useState<string | null>(null);
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
// Current filter values from URL
const search = searchParams.get('search') || '';
const roleFilter = searchParams.get('role') || '';
const statusFilter = searchParams.get('status') || '';
// Selection handlers
const handleSelectUser = useCallback((userId: string) => {
setSelectedUserIds(prev =>
prev.includes(userId)
? prev.filter(id => id !== userId)
: [...prev, userId]
);
}, []);
const handleSelectAll = useCallback(() => {
if (selectedUserIds.length === initialViewData.users.length) {
setSelectedUserIds([]);
} else {
setSelectedUserIds(initialViewData.users.map(u => u.id));
}
}, [selectedUserIds.length, initialViewData.users]);
const handleClearSelection = useCallback(() => {
setSelectedUserIds([]);
}, []);
// Callbacks that update URL (triggers RSC re-render)
const handleSearch = useCallback((newSearch: string) => {
const params = new URLSearchParams(searchParams);
if (newSearch) params.set('search', newSearch);
else params.delete('search');
params.delete('page'); // Reset to page 1
router.push(`${routes.admin.users}?${params.toString()}`);
}, [router, searchParams]);
const handleFilterRole = useCallback((role: string) => {
const params = new URLSearchParams(searchParams);
if (role) params.set('role', role);
else params.delete('role');
params.delete('page');
router.push(`${routes.admin.users}?${params.toString()}`);
}, [router, searchParams]);
const handleFilterStatus = useCallback((status: string) => {
const params = new URLSearchParams(searchParams);
if (status) params.set('status', status);
else params.delete('status');
params.delete('page');
router.push(`${routes.admin.users}?${params.toString()}`);
}, [router, searchParams]);
const handleClearFilters = useCallback(() => {
router.push(routes.admin.users);
}, [router]);
const handleRefresh = useCallback(() => {
router.refresh();
}, [router]);
// Mutation callbacks (call Server Actions)
const handleUpdateStatus = useCallback(async (userId: string, newStatus: string) => {
try {
setLoading(true);
const result = await updateUserStatus(userId, newStatus);
if (result.isErr()) {
setError(result.getError());
return;
}
// Revalidate data
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update status');
} finally {
setLoading(false);
}
}, [router]);
const handleDeleteUser = useCallback(async (userId: string) => {
setUserToDelete(userId);
}, []);
const confirmDeleteUser = useCallback(async () => {
if (!userToDelete) return;
try {
setDeletingUser(userToDelete);
setError(null);
const result = await deleteUser(userToDelete);
if (result.isErr()) {
setError(result.getError());
return;
}
// Revalidate data
router.refresh();
setUserToDelete(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
} finally {
setDeletingUser(null);
}
}, [router, userToDelete]);
return (
<>
<AdminUsersTemplate
viewData={initialViewData}
onRefresh={handleRefresh}
onSearch={handleSearch}
onFilterRole={handleFilterRole}
onFilterStatus={handleFilterStatus}
onClearFilters={handleClearFilters}
onUpdateStatus={handleUpdateStatus}
onDeleteUser={handleDeleteUser}
search={search}
roleFilter={roleFilter}
statusFilter={statusFilter}
loading={loading}
error={error}
deletingUser={deletingUser}
selectedUserIds={selectedUserIds}
onSelectUser={handleSelectUser}
onSelectAll={handleSelectAll}
onClearSelection={handleClearSelection}
/>
<ConfirmDialog
isOpen={!!userToDelete}
onClose={() => setUserToDelete(null)}
onConfirm={confirmDeleteUser}
title="Delete User"
description="Are you sure you want to delete this user? This action cannot be undone and will permanently remove the user's access."
confirmLabel="Delete User"
variant="danger"
isLoading={!!deletingUser}
/>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
'use client';
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 { useRouter } from 'next/navigation';
import { useState } from 'react';
interface DriverProfilePageClientProps {
viewData: DriverProfileViewData | null;
error?: string;
empty?: {
title: string;
description: string;
};
}
/**
* 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();
// UI State (UI-only concerns)
const [activeTab, setActiveTab] = useState<ProfileTab>('overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
// Event handlers (UI-only concerns)
const handleAddFriend = () => {
setFriendRequestSent(true);
};
const handleBackClick = () => {
router.push('/drivers');
};
// 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>
);
}
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>
);
}
return null;
}
// Pass ViewData directly to template
return (
<DriverProfileTemplate
viewData={viewData}
onBackClick={handleBackClick}
onAddFriend={handleAddFriend}
friendRequestSent={friendRequestSent}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate';
import { routes } from '@/lib/routing/RouteConfig';
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
interface DriverRankingsPageClientProps {
viewData: DriverRankingsViewData;
}
export function DriverRankingsPageClient({ viewData }: DriverRankingsPageClientProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const handleDriverClick = (id: string) => {
router.push(routes.driver.detail(id));
};
const handleBackToLeaderboards = () => {
router.push(routes.leaderboards.root);
};
const filteredDrivers = viewData.drivers.filter(driver =>
driver.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<DriverRankingsTemplate
viewData={{
...viewData,
drivers: filteredDrivers
}}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>
);
}

View File

@@ -0,0 +1,89 @@
'use client';
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 { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { routes } from '@/lib/routing/RouteConfig';
interface DriversPageClientProps {
viewData: DriversViewData | null;
error?: string;
empty?: {
title: string;
description: string;
};
}
/**
* 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();
const filteredDrivers = useMemo(() => {
if (!viewData) return [];
if (!searchQuery) return viewData.drivers;
const query = searchQuery.toLowerCase();
return viewData.drivers.filter(driver =>
driver.name.toLowerCase().includes(query) ||
driver.nationality.toLowerCase().includes(query)
);
}, [viewData, searchQuery]);
const handleDriverClick = (id: string) => {
router.push(routes.driver.detail(id));
};
const handleViewLeaderboard = () => {
router.push(routes.leaderboards.drivers);
};
// 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>
);
}
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>
);
}
return null;
}
return (
<DriversTemplate
viewData={viewData}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filteredDrivers={filteredDrivers}
onDriverClick={handleDriverClick}
onViewLeaderboard={handleViewLeaderboard}
/>
);
}

View File

@@ -0,0 +1,130 @@
/**
* Forgot Password Client Component
*
* Handles client-side forgot password flow.
*/
'use client';
import { useState } from 'react';
import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
import { ForgotPasswordTemplate } from '@/templates/auth/ForgotPasswordTemplate';
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
import { ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation';
interface ForgotPasswordClientProps {
viewData: ForgotPasswordViewData;
}
export function ForgotPasswordClient({ viewData }: ForgotPasswordClientProps) {
// Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<ForgotPasswordViewModel>(() =>
ForgotPasswordViewModelBuilder.build(viewData)
);
// Handle form field changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setViewModel(prev => {
const newFormState = {
...prev.formState,
fields: {
...prev.formState.fields,
[name]: {
...prev.formState.fields[name as keyof typeof prev.formState.fields],
value,
touched: true,
error: undefined,
},
},
};
return prev.withFormState(newFormState);
});
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = {
email: viewModel.formState.fields.email.value as string,
};
// Validate form
const validationErrors = ForgotPasswordFormValidation.validateForm(formData);
if (validationErrors.length > 0) {
setViewModel(prev => {
const newFormState = {
...prev.formState,
isValid: false,
submitCount: prev.formState.submitCount + 1,
fields: {
...prev.formState.fields,
email: {
...prev.formState.fields.email,
error: validationErrors.find(e => e.field === 'email')?.message,
touched: true,
},
},
};
return prev.withFormState(newFormState);
});
return;
}
// Update submitting state
setViewModel(prev => prev.withMutationState(true, null));
try {
// Execute forgot password mutation
const mutation = new ForgotPasswordMutation();
const result = await mutation.execute(formData);
if (result.isErr()) {
const error = result.getError();
setViewModel(prev => prev.withMutationState(false, error));
return;
}
// Success
const data = result.unwrap();
setViewModel(prev => prev.withSuccess(data.message, data.magicLink || null));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to send reset link';
setViewModel(prev => prev.withMutationState(false, errorMessage));
}
};
// Build viewData for template
const templateViewData: ForgotPasswordViewData = {
...viewData,
showSuccess: viewModel.showSuccess,
successMessage: viewModel.successMessage || undefined,
magicLink: viewModel.magicLink || undefined,
formState: viewModel.formState,
isSubmitting: viewModel.isSubmitting,
submitError: viewModel.submitError,
};
return (
<ForgotPasswordTemplate
viewData={templateViewData}
formActions={{
handleChange,
handleSubmit,
setShowSuccess: (show) => {
if (!show) {
// Reset to initial state
setViewModel(() => ForgotPasswordViewModelBuilder.build(viewData));
}
},
}}
mutationState={{
isPending: viewModel.mutationPending,
error: viewModel.mutationError,
}}
/>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { LeaderboardsTemplate } from '@/templates/LeaderboardsTemplate';
import { routes } from '@/lib/routing/RouteConfig';
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
interface LeaderboardsPageClientProps {
viewData: LeaderboardsViewData;
}
export function LeaderboardsPageClient({ viewData }: LeaderboardsPageClientProps) {
const router = useRouter();
const handleDriverClick = (id: string) => {
router.push(routes.driver.detail(id));
};
const handleTeamClick = (id: string) => {
router.push(routes.team.detail(id));
};
const handleNavigateToDrivers = () => {
router.push(routes.leaderboards.drivers);
};
const handleNavigateToTeams = () => {
router.push(routes.team.leaderboard);
};
return (
<LeaderboardsTemplate
viewData={viewData}
onDriverClick={handleDriverClick}
onTeamClick={handleTeamClick}
onNavigateToDrivers={handleNavigateToDrivers}
onNavigateToTeams={handleNavigateToTeams}
/>
);
}

View File

@@ -0,0 +1,255 @@
'use client';
import {
createRaceAction,
deleteRaceAction,
publishScheduleAction,
unpublishScheduleAction,
updateRaceAction
} from '@/app/actions/leagueScheduleActions';
import { PageWrapper } from '@/ui/PageWrapper';
import { ConfirmDialog } from '@/ui/ConfirmDialog';
import {
useLeagueAdminSchedule,
useLeagueAdminStatus,
useLeagueSeasons
} from "@/hooks/league/useLeagueScheduleAdminPageData";
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';
export function LeagueAdminSchedulePageClient() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
const router = useRouter();
// Form state
const [seasonId, setSeasonId] = useState<string>('');
const [form, setForm] = useState(() => new RaceScheduleCommandModel());
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
// Action state
const [isPublishing, setIsPublishing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [deletingRaceId, setDeletingRaceId] = useState<string | null>(null);
const [raceToDelete, setRaceToDelete] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Check admin status using domain hook
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
// Load seasons using domain hook
const { data: seasonsData, isLoading: seasonsLoading } = useLeagueSeasons(leagueId, !!isAdmin);
// Auto-select season
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId || ''
: '');
// Load schedule using domain hook
const { data: schedule, isLoading: scheduleLoading } = useLeagueAdminSchedule(leagueId, selectedSeasonId, !!isAdmin);
// Handlers
const handleSeasonChange = (newSeasonId: string) => {
setSeasonId(newSeasonId);
setEditingRaceId(null);
setForm(new RaceScheduleCommandModel());
};
const handlePublishToggle = async () => {
if (!schedule || !selectedSeasonId) return;
setIsPublishing(true);
setError(null);
try {
const result = schedule.published
? await unpublishScheduleAction(leagueId, selectedSeasonId)
: await publishScheduleAction(leagueId, selectedSeasonId);
if (result.isOk()) {
router.refresh();
} else {
setError(result.getError());
}
} finally {
setIsPublishing(false);
}
};
const handleAddOrSave = async () => {
if (!selectedSeasonId) return;
const validationErrors = form.validate();
if (Object.keys(validationErrors).length > 0) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = !editingRaceId
? await createRaceAction(leagueId, selectedSeasonId, form.toCommand())
: await updateRaceAction(leagueId, selectedSeasonId, editingRaceId, form.toCommand());
if (result.isOk()) {
// Reset form
setForm(new RaceScheduleCommandModel());
setEditingRaceId(null);
router.refresh();
} else {
setError(result.getError());
}
} finally {
setIsSaving(false);
}
};
const handleEdit = (raceId: string) => {
if (!schedule) return;
const race = schedule.races.find((r) => r.id === raceId);
if (!race) return;
setEditingRaceId(raceId);
setForm(new RaceScheduleCommandModel({
track: race.track || '',
car: race.car || '',
scheduledAtIso: race.scheduledAt.toISOString(),
}));
};
const handleDelete = (raceId: string) => {
setRaceToDelete(raceId);
};
const confirmDelete = async () => {
if (!selectedSeasonId || !raceToDelete) return;
setDeletingRaceId(raceToDelete);
setError(null);
try {
const result = await deleteRaceAction(leagueId, selectedSeasonId, raceToDelete);
if (result.isOk()) {
router.refresh();
setRaceToDelete(null);
} else {
setError(result.getError());
}
} finally {
setDeletingRaceId(null);
}
};
const handleCancelEdit = () => {
setEditingRaceId(null);
setForm(new RaceScheduleCommandModel());
};
// Derived states
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
// Prepare template data
const templateData = schedule && seasonsData && selectedSeasonId
? {
published: schedule.published,
races: schedule.races.map(r => ({
id: r.id,
name: r.name,
track: r.track || '',
car: r.car || '',
scheduledAt: r.scheduledAt.toISOString(),
})),
seasons: seasonsData.map(s => ({
seasonId: s.seasonId,
name: s.name,
})),
seasonId: selectedSeasonId,
}
: undefined;
// Render admin access required if not admin
if (!isLoading && !isAdmin) {
return (
<Stack gap={6}>
<Card>
<Box 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>
);
}
// 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
data={templateData}
isLoading={isLoading}
error={null}
Template={TemplateWrapper}
loading={{ variant: 'full-screen', message: 'Loading schedule admin...' }}
empty={{
title: 'No schedule data available',
description: 'Unable to load schedule administration data',
}}
/>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
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';
interface LeagueRulebookPageClientProps {
viewData: LeagueRulebookViewData;
}
export function LeagueRulebookPageClient({ viewData }: LeagueRulebookPageClientProps) {
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
return (
<LeagueRulebookTemplate
viewData={viewData}
activeSection={activeSection}
onSectionChange={setActiveSection}
/>
);
}

View File

@@ -0,0 +1,66 @@
'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';
interface WalletTemplateProps {
viewData: LeagueWalletViewData;
onWithdraw?: (amount: number) => void;
onExport?: () => void;
mutationLoading?: boolean;
}
export function LeagueWalletPageClient({ viewData, onExport }: WalletTemplateProps) {
// Map transactions to the format expected by WalletSummaryPanel
const transactions = viewData.transactions.map(t => ({
id: t.id,
type: t.type === 'withdrawal' ? 'debit' : 'credit' as 'credit' | 'debit',
amount: parseFloat(t.formattedAmount.replace(/[^0-9.-]+/g, '')),
description: t.description,
date: t.formattedDate,
}));
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&apos;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>
);
}

View File

@@ -0,0 +1,280 @@
'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
// ============================================================================
const CATEGORIES: Category[] = [
{
id: 'all',
label: 'All',
icon: Globe,
description: 'Browse all available leagues',
filter: () => true,
},
{
id: 'popular',
label: 'Popular',
icon: Flame,
description: 'Most active leagues right now',
filter: (league) => {
const fillRate = (league.usedDriverSlots ?? 0) / (league.maxDrivers ?? 1);
return fillRate > 0.7;
},
color: 'text-orange-400',
},
{
id: 'new',
label: 'New',
icon: Sparkles,
description: 'Fresh leagues looking for members',
filter: (league) => {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return new Date(league.createdAt) > oneWeekAgo;
},
color: 'text-green-500',
},
{
id: 'openSlots',
label: 'Open Slots',
icon: Target,
description: 'Leagues with available spots',
filter: (league) => {
if (league.maxTeams && league.maxTeams > 0) {
const usedTeams = league.usedTeamSlots ?? 0;
return usedTeams < league.maxTeams;
}
const used = league.usedDriverSlots ?? 0;
const max = league.maxDrivers ?? 0;
return max > 0 && used < max;
},
color: 'text-cyan-400',
},
{
id: 'driver',
label: 'Driver',
icon: Trophy,
description: 'Compete as an individual',
filter: (league) => league.scoring?.primaryChampionshipType === 'driver',
},
{
id: 'team',
label: 'Team',
icon: Users,
description: 'Race together as a team',
filter: (league) => league.scoring?.primaryChampionshipType === 'team',
},
{
id: 'nations',
label: 'Nations',
icon: Flag,
description: 'Represent your country',
filter: (league) => league.scoring?.primaryChampionshipType === 'nations',
},
{
id: 'trophy',
label: 'Trophy',
icon: Award,
description: 'Special championship events',
filter: (league) => league.scoring?.primaryChampionshipType === 'trophy',
},
{
id: 'endurance',
label: 'Endurance',
icon: Timer,
description: 'Long-distance racing',
filter: (league) =>
league.scoring?.scoringPresetId?.includes('endurance') ??
league.timingSummary?.includes('h Race') ??
false,
},
{
id: 'sprint',
label: 'Sprint',
icon: Clock,
description: 'Quick, intense races',
filter: (league) =>
(league.scoring?.scoringPresetId?.includes('sprint') ?? false) &&
!(league.scoring?.scoringPresetId?.includes('endurance') ?? false),
},
];
export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
const filteredLeagues = viewData.leagues.filter((league) => {
const matchesSearch = !searchQuery ||
league.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(league.description ?? '').toLowerCase().includes(searchQuery.toLowerCase());
const category = CATEGORIES.find(c => c.id === activeCategory);
const matchesCategory = !category || category.filter(league);
return matchesSearch && matchesCategory;
});
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>
);
}

View File

@@ -0,0 +1,267 @@
/**
* Login Client Component
*
* Handles client-side login flow using the LoginFlowController.
* Deterministic state machine per docs/architecture/website/LOGIN_FLOW_STATE_MACHINE.md
*/
'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';
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 { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
interface LoginClientProps {
viewData: LoginViewData;
}
export function LoginClient({ viewData }: LoginClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession, session } = useAuth();
// Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<LoginViewModel>(() =>
LoginViewModelBuilder.build(viewData)
);
// Login flow controller
const controller = useMemo(() => {
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
return new LoginFlowController(session, returnTo);
}, [session, searchParams]);
// Check controller state on mount and session changes
useEffect(() => {
const action = controller.getNextAction();
if (action.type === 'REDIRECT') {
router.replace(action.path);
}
}, [controller, router]);
// Handle form field changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type } = e.target;
const checked = 'checked' in e.target ? e.target.checked : false;
const fieldValue = type === 'checkbox' ? checked : value;
setViewModel(prev => {
const newFormState = {
...prev.formState,
fields: {
...prev.formState.fields,
[name]: {
...prev.formState.fields[name as keyof typeof prev.formState.fields],
value: fieldValue,
touched: true,
error: undefined,
},
},
};
return prev.withFormState(newFormState);
});
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const values: LoginFormValues = {
email: viewModel.formState.fields.email.value as string,
password: viewModel.formState.fields.password.value as string,
rememberMe: viewModel.formState.fields.rememberMe.value as boolean,
};
// Validate form
const errors = validateLoginForm(values);
const hasErrors = Object.keys(errors).length > 0;
if (hasErrors) {
setViewModel(prev => {
const newFormState = {
...prev.formState,
isValid: false,
submitCount: prev.formState.submitCount + 1,
fields: {
...prev.formState.fields,
email: {
...prev.formState.fields.email,
error: errors.email,
touched: true,
},
password: {
...prev.formState.fields.password,
error: errors.password,
touched: true,
},
},
};
return prev.withFormState(newFormState);
});
return;
}
// Update submitting state
setViewModel(prev => prev.withMutationState(true, null));
try {
// Execute login mutation
const mutation = new LoginMutation();
const result = await mutation.execute({
email: values.email,
password: values.password,
rememberMe: values.rememberMe,
});
if (result.isErr()) {
const error = result.getError();
setViewModel(prev => {
const newFormState = {
...prev.formState,
isSubmitting: false,
submitError: error,
};
return prev.withFormState(newFormState).withMutationState(false, error);
});
if (process.env.NODE_ENV === 'development') {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showErrorDetails: true,
}));
}
return;
}
// Success - refresh session and transition
await refreshSession();
// Transition to post-auth state
controller.transitionToPostAuth();
const action = controller.getNextAction();
if (action.type === 'REDIRECT') {
router.push(action.path);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Login failed';
setViewModel(prev => {
const newFormState = {
...prev.formState,
isSubmitting: false,
submitError: errorMessage,
};
return prev.withFormState(newFormState).withMutationState(false, errorMessage);
});
if (process.env.NODE_ENV === 'development') {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showErrorDetails: true,
}));
}
}
};
// Toggle password visibility
const togglePassword = () => {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showPassword: !prev.uiState.showPassword,
}));
};
// Get current state from controller
const state = controller.getState();
// If user is authenticated with permissions, show loading
if (state === LoginState.AUTHENTICATED_WITH_PERMISSIONS) {
return <AuthLoading />;
}
// If user has insufficient permissions, show permission error
if (state === LoginState.AUTHENTICATED_WITHOUT_PERMISSIONS) {
return (
<LoginTemplate
viewData={{
...viewData,
hasInsufficientPermissions: true,
showPassword: viewModel.showPassword,
showErrorDetails: viewModel.showErrorDetails,
formState: viewModel.formState,
isSubmitting: viewModel.isSubmitting,
submitError: viewModel.submitError,
}}
formActions={{
handleChange,
handleSubmit,
setFormState: (state) => {
if (typeof state === 'function') {
const newState = state(viewModel.formState);
setViewModel(prev => prev.withFormState(newState));
} else {
setViewModel(prev => prev.withFormState(state));
}
},
setShowPassword: togglePassword,
setShowErrorDetails: (show) => {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showErrorDetails: show,
}));
},
}}
mutationState={{
isPending: viewModel.mutationPending,
error: viewModel.mutationError,
}}
/>
);
}
// Show login form
return (
<LoginTemplate
viewData={{
...viewData,
showPassword: viewModel.showPassword,
showErrorDetails: viewModel.showErrorDetails,
formState: viewModel.formState,
isSubmitting: viewModel.isSubmitting,
submitError: viewModel.submitError,
}}
formActions={{
handleChange,
handleSubmit,
setFormState: (state) => {
if (typeof state === 'function') {
const newState = state(viewModel.formState);
setViewModel(prev => prev.withFormState(newState));
} else {
setViewModel(prev => prev.withFormState(state));
}
},
setShowPassword: togglePassword,
setShowErrorDetails: (show) => {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showErrorDetails: show,
}));
},
}}
mutationState={{
isPending: viewModel.mutationPending,
error: viewModel.mutationError,
}}
/>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import React from 'react';
import { MediaTemplate } from '@/templates/MediaTemplate';
import { MediaAsset } from '@/components/media/MediaGallery';
export interface MediaPageClientProps {
initialAssets: MediaAsset[];
categories: { label: string; value: string }[];
}
export function MediaPageClient({
initialAssets,
categories,
}: MediaPageClientProps) {
return (
<MediaTemplate
assets={initialAssets}
categories={categories}
title="Media Library"
description="Manage and view all racing assets, telemetry captures, and brand identities."
/>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { NotFoundTemplate, type NotFoundViewData } from '@/templates/NotFoundTemplate';
/**
* NotFoundPageClient
*
* Client-side entry point for the 404 page.
* Manages navigation logic and wires it to the template.
*/
export function NotFoundPageClient() {
const router = useRouter();
const handleHomeClick = () => {
router.push(routes.public.home);
};
const viewData: NotFoundViewData = {
errorCode: 'Error 404',
title: 'OFF TRACK',
message: 'The requested sector does not exist. You have been returned to the pits.',
actionLabel: 'Return to Pits'
};
return <NotFoundTemplate viewData={viewData} onHomeClick={handleHomeClick} />;
}

View File

@@ -0,0 +1,123 @@
'use client';
import { OnboardingTemplate } from '@/templates/onboarding/OnboardingTemplate';
import { routes } from '@/lib/routing/RouteConfig';
import { completeOnboardingAction } from '@/app/actions/completeOnboardingAction';
import { generateAvatarsAction } from '@/app/actions/generateAvatarsAction';
import { useAuth } from '@/components/auth/AuthContext';
import { useState } from 'react';
import { PersonalInfo } from '@/components/onboarding/PersonalInfoStep';
import { AvatarInfo } from '@/components/onboarding/AvatarStep';
type OnboardingStep = 1 | 2;
interface FormErrors {
[key: string]: string | undefined;
firstName?: string;
lastName?: string;
displayName?: string;
country?: string;
facePhoto?: string;
avatar?: string;
submit?: string;
}
export function OnboardingWizardClient() {
const { session } = useAuth();
const [isProcessing, setIsProcessing] = useState(false);
const [step, setStep] = useState<OnboardingStep>(1);
const [errors, setErrors] = useState<FormErrors>({});
// Form state
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>({
firstName: '',
lastName: '',
displayName: '',
country: '',
timezone: '',
});
const [avatarInfo, setAvatarInfo] = useState<AvatarInfo>({
facePhoto: null,
suitColor: 'blue',
generatedAvatars: [],
selectedAvatarIndex: null,
isGenerating: false,
isValidating: false,
});
const handleCompleteOnboarding = async (input: {
firstName: string;
lastName: string;
displayName: string;
country: string;
timezone?: string;
}) => {
setIsProcessing(true);
try {
const result = await completeOnboardingAction(input);
if (result.isErr()) {
setIsProcessing(false);
return { success: false, error: result.getError() };
}
window.location.href = routes.protected.dashboard;
return { success: true };
} catch (error) {
setIsProcessing(false);
return { success: false, error: 'Failed to complete onboarding' };
}
};
const handleGenerateAvatars = async (params: {
facePhotoData: string;
suitColor: string;
}) => {
if (!session?.user?.userId) {
return { success: false, error: 'Not authenticated' };
}
setIsProcessing(true);
try {
const result = await generateAvatarsAction({
userId: session.user.userId,
facePhotoData: params.facePhotoData,
suitColor: params.suitColor,
});
if (result.isErr()) {
setIsProcessing(false);
return { success: false, error: result.getError() };
}
const data = result.unwrap();
setIsProcessing(false);
return { success: true, data };
} catch (error) {
setIsProcessing(false);
return { success: false, error: 'Failed to generate avatars' };
}
};
return (
<OnboardingTemplate
viewData={{
onCompleted: () => {
window.location.href = routes.protected.dashboard;
},
onCompleteOnboarding: handleCompleteOnboarding,
onGenerateAvatars: handleGenerateAvatars,
isProcessing: isProcessing,
step,
setStep,
errors,
setErrors,
personalInfo,
setPersonalInfo,
avatarInfo,
setAvatarInfo,
}}
/>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import { UploadDropzone } from '@/components/media/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';
export function ProfileLiveryUploadPageClient() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFilesSelected = (files: File[]) => {
if (files.length > 0) {
const file = files[0];
setSelectedFile(file);
const url = URL.createObjectURL(file);
setPreviewUrl(url);
} else {
setSelectedFile(null);
setPreviewUrl(null);
}
};
const handleUpload = async () => {
if (!selectedFile) return;
setIsUploading(true);
// Mock upload delay
await new Promise(resolve => setTimeout(resolve, 2000));
setIsUploading(false);
alert('Livery uploaded successfully! (Mock)');
};
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
src={previewUrl}
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>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
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';
interface ProfilePageClientProps {
viewData: ProfileViewData;
mode: 'profile-exists' | 'needs-profile';
}
export function ProfilePageClient({ viewData, mode }: ProfilePageClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') as ProfileTab | null;
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
if (activeTab === 'overview') {
params.delete('tab');
} else {
params.set('tab', activeTab);
}
const query = params.toString();
const currentQuery = searchParams.toString();
if (query !== currentQuery) {
router.replace(`/profile${query ? `?${query}` : ''}`, { scroll: false });
}
}, [activeTab, searchParams, router]);
useEffect(() => {
const tab = searchParams.get('tab') as ProfileTab | null;
if (tab && tab !== activeTab) {
setActiveTab(tab);
}
}, [searchParams, activeTab]);
return (
<ProfileTemplate
viewData={viewData}
mode={mode}
activeTab={activeTab}
onTabChange={setActiveTab}
friendRequestSent={friendRequestSent}
onFriendRequestSend={() => setFriendRequestSent(true)}
/>
);
}

View File

@@ -0,0 +1,64 @@
'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 { useRouter } from 'next/navigation';
import { useState } from 'react';
interface ProfileSettingsPageClientProps {
viewData: ProfileViewData;
onSave: (updates: { bio?: string; country?: string }) => Promise<Result<void, string>>;
}
export function ProfileSettingsPageClient({ viewData, onSave }: ProfileSettingsPageClientProps) {
const router = useRouter();
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bio, setBio] = useState(viewData.driver.bio || '');
const [country, setCountry] = useState(viewData.driver.countryCode);
const handleSave = async () => {
setIsSaving(true);
setError(null);
try {
const result = await onSave({ bio, country });
if (result.isErr()) {
setError(result.getError());
} else {
router.refresh();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save settings');
} finally {
setIsSaving(false);
}
};
return (
<>
<ProgressLine isLoading={isSaving} />
{error && (
<Box position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
<InlineNotice
variant="error"
title="Update Failed"
message={error}
/>
</Box>
)}
<ProfileSettingsTemplate
viewData={viewData}
bio={bio}
country={country}
onBioChange={setBio}
onCountryChange={setCountry}
onSave={handleSave}
/>
</>
);
}

View File

@@ -0,0 +1,808 @@
'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 '@/ui/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 { GridItem } from '@/ui/GridItem';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
type PenaltyUiConfig = {
label: string;
description: string;
icon: LucideIcon;
color: string;
defaultValue?: number;
};
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
time_penalty: {
label: 'Time Penalty',
description: 'Add seconds to race result',
icon: Clock,
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
defaultValue: 5,
},
grid_penalty: {
label: 'Grid Penalty',
description: 'Grid positions for next race',
icon: Grid3x3,
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
defaultValue: 3,
},
points_deduction: {
label: 'Points Deduction',
description: 'Deduct championship points',
icon: TrendingDown,
color: 'text-red-400 bg-red-500/10 border-red-500/20',
defaultValue: 5,
},
disqualification: {
label: 'Disqualification',
description: 'Disqualify from race',
icon: XCircle,
color: 'text-red-500 bg-red-500/10 border-red-500/20',
defaultValue: 0,
},
warning: {
label: 'Warning',
description: 'Official warning only',
icon: AlertTriangle,
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
defaultValue: 0,
},
license_points: {
label: 'License Points',
description: 'Safety rating penalty',
icon: ShieldAlert,
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
defaultValue: 2,
},
};
export function ProtestDetailPageClient({ initialViewData }: { initialViewData: unknown }) {
const params = useParams();
const router = useRouter();
const leagueId = params.id as string;
const protestId = params.protestId as string;
const currentDriverId = useEffectiveDriverId();
const protestService = useInject(PROTEST_SERVICE_TOKEN);
// Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [newComment, setNewComment] = useState('');
// Check admin status using hook
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
// Load protest detail using hook
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
useEffect(() => {
if (protestDetail?.initialPenaltyType) {
setPenaltyType(protestDetail.initialPenaltyType);
setPenaltyValue(protestDetail.initialPenaltyValue);
}
}, [protestDetail]);
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,
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
};
return {
...ref,
icon: ui.icon,
color: ui.color,
};
});
}, [protestDetail?.penaltyTypes]);
const selectedPenalty = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return penaltyTypes.find((p: any) => p.type === penaltyType);
}, [penaltyTypes, penaltyType]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !protestDetail || !currentDriverId) return;
setSubmitting(true);
try {
const protest = protestDetail.protest || protestDetail;
const defaultUpheldReason = protestDetail.defaultReasons?.upheld;
const defaultDismissedReason = protestDetail.defaultReasons?.dismissed;
if (decision === 'uphold') {
const requiresValue = selectedPenalty?.requiresValue ?? true;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType,
penaltyValue,
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId || protestDetail.race?.id,
protest.accusedDriverId || protestDetail.accusedDriver?.id,
currentDriverId,
protest.id || protestDetail.protestId,
options,
);
const result = await protestService.applyPenalty(penaltyCommand);
if (result.isErr()) {
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;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType: 'warning',
penaltyValue: 0,
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId || protestDetail.race?.id,
protest.accusedDriverId || protestDetail.accusedDriver?.id,
currentDriverId,
protest.id || protestDetail.protestId,
options,
);
const result = await protestService.applyPenalty(penaltyCommand);
if (result.isErr()) {
throw new Error(result.getError().message);
}
}
router.push(routes.league.stewarding(leagueId));
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to submit decision');
} finally {
setSubmitting(false);
}
};
const handleRequestDefense = async () => {
if (!protestDetail || !currentDriverId) return;
try {
// Request defense
const result = await protestService.requestDefense({
protestId: protestDetail.protest?.id || protestDetail.protestId,
stewardId: currentDriverId,
});
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');
}
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return { label: 'Pending Review', bg: 'bg-warning-amber/20', color: 'text-warning-amber', borderColor: 'border-warning-amber/30', icon: Clock };
case 'under_review':
return { label: 'Under Review', bg: 'bg-blue-500/20', color: 'text-blue-400', borderColor: 'border-blue-500/30', icon: Shield };
case 'awaiting_defense':
return { label: 'Awaiting Defense', bg: 'bg-purple-500/20', color: 'text-purple-400', borderColor: 'border-purple-500/30', icon: MessageCircle };
case 'upheld':
return { label: 'Upheld', bg: 'bg-red-500/20', color: 'text-red-400', borderColor: 'border-red-500/30', icon: CheckCircle };
case 'dismissed':
return { label: 'Dismissed', bg: 'bg-gray-500/20', color: 'text-gray-400', borderColor: 'border-gray-500/30', icon: XCircle };
default:
return { label: status, bg: 'bg-gray-500/20', color: 'text-gray-400', borderColor: 'border-gray-500/30', icon: AlertCircle };
}
};
// 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>
<UILink
href={routes.race.detail(race?.id || '')}
block
mb={3}
>
<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>
<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..."
rows={2}
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}>
<Button
variant="ghost"
onClick={() => setDecision('uphold')}
p={3}
border
borderColor={decision === 'uphold' ? 'border-racing-red' : 'border-charcoal-outline'}
bg={decision === 'uphold' ? 'bg-racing-red/10' : 'transparent'}
>
<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>
<Button
variant="ghost"
onClick={() => setDecision('dismiss')}
p={3}
border
borderColor={decision === 'dismiss' ? 'border-gray-500' : 'border-charcoal-outline'}
bg={decision === 'dismiss' ? 'bg-gray-500/10' : 'transparent'}
>
<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>
</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 (
<Button
key={penalty.type}
variant="ghost"
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
p={2}
border
borderColor={isSelected ? undefined : 'border-charcoal-outline'}
bg={isSelected ? undefined : 'bg-iron-gray/30'}
color={isSelected ? penalty.color : undefined}
title={penalty.description}
>
<Stack align="start" gap={0.5}>
<UIIcon icon={Icon} size={3.5} color={isSelected ? undefined : 'text-gray-500'} />
<Text size="xs" weight="medium" fontSize="10px" color={isSelected ? undefined : 'text-gray-500'}>
{penalty.label}
</Text>
</Stack>
</Button>
);
})}
</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..."
rows={4}
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>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import React, { useState, useCallback } from 'react';
import { RaceDetailTemplate, RaceDetailViewData } from '@/templates/RaceDetailTemplate';
import { useRouter } from 'next/navigation';
interface Props {
data: RaceDetailViewData;
}
export function RaceDetailPageClient({ data: viewData }: Props) {
const router = useRouter();
const [animatedRatingChange] = useState(0);
const handleBack = useCallback(() => {
router.back();
}, [router]);
const handleRegister = useCallback(() => {
console.log('Register');
}, []);
const handleWithdraw = useCallback(() => {
console.log('Withdraw');
}, []);
const handleCancel = useCallback(() => {
console.log('Cancel');
}, []);
const handleReopen = useCallback(() => {
console.log('Reopen');
}, []);
const handleEndRace = useCallback(() => {
console.log('End Race');
}, []);
const handleFileProtest = useCallback(() => {
console.log('File Protest');
}, []);
const handleResultsClick = useCallback(() => {
router.push(`/races/${viewData.race.id}/results`);
}, [router, viewData.race.id]);
const handleStewardingClick = useCallback(() => {
router.push(`/races/${viewData.race.id}/stewarding`);
}, [router, viewData.race.id]);
const handleLeagueClick = useCallback((leagueId: string) => {
router.push(`/leagues/${leagueId}`);
}, [router]);
const handleDriverClick = useCallback((driverId: string) => {
router.push(`/drivers/${driverId}`);
}, [router]);
return (
<RaceDetailTemplate
viewData={viewData}
isLoading={false}
error={null}
onBack={handleBack}
onRegister={handleRegister}
onWithdraw={handleWithdraw}
onCancel={handleCancel}
onReopen={handleReopen}
onEndRace={handleEndRace}
onFileProtest={handleFileProtest}
onResultsClick={handleResultsClick}
onStewardingClick={handleStewardingClick}
onLeagueClick={handleLeagueClick}
onDriverClick={handleDriverClick}
animatedRatingChange={animatedRatingChange}
mutationLoading={{}}
/>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import React, { useState, useCallback } from 'react';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
import { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData';
import { useRouter } from 'next/navigation';
interface Props {
data: RaceResultsViewData;
}
export function RaceResultsPageClient({ data: viewData }: Props) {
const router = useRouter();
const [importing, setImporting] = useState(false);
const [importSuccess, setImportSuccess] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [showImportForm, setShowImportForm] = useState(false);
const handleBack = useCallback(() => {
router.back();
}, [router]);
const handleImportResults = useCallback(async () => {
setImporting(true);
setImportError(null);
try {
// Mock import
await new Promise(resolve => setTimeout(resolve, 1000));
setImportSuccess(true);
} catch (err) {
setImportError('Failed to import results');
} finally {
setImporting(false);
}
}, []);
const handlePenaltyClick = useCallback(() => {
console.log('Penalty click');
}, []);
return (
<RaceResultsTemplate
viewData={viewData}
isAdmin={false}
isLoading={false}
error={null}
onBack={handleBack}
onImportResults={handleImportResults}
onPenaltyClick={handlePenaltyClick}
importing={importing}
importSuccess={importSuccess}
importError={importError}
showImportForm={showImportForm}
setShowImportForm={setShowImportForm}
/>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { StatefulPageWrapper } from '@/ui/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';
const ITEMS_PER_PAGE = 10;
export function RacesAllPageClient({ initialViewData }: { initialViewData: RacesViewData | null }) {
const router = useRouter();
// Client-side state for filters and pagination
const [currentPage, setCurrentPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [showFilterModal, setShowFilterModal] = useState(false);
// Use React Query hook
const { data: pageData, isLoading, error, refetch } = useAllRacesPageData(initialViewData);
// Transform data
const races: RaceViewData[] = pageData?.races ?? [];
// Filter and paginate (Note: This should be done by API per contract)
const filteredRaces = races.filter((race: RaceViewData) => {
if (statusFilter !== 'all' && race.status !== statusFilter) {
return false;
}
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
return false;
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesTrack = race.track.toLowerCase().includes(query);
const matchesCar = race.car.toLowerCase().includes(query);
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
if (!matchesTrack && !matchesCar && !matchesLeague) {
return false;
}
}
return true;
});
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
const paginatedRaces = filteredRaces.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
// Actions
const handleRaceClick = (raceId: string) => {
router.push(routes.race.detail(raceId));
};
const handleLeagueClick = (leagueId: string) => {
router.push(routes.league.detail(leagueId));
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return (
<StatefulPageWrapper
data={pageData}
isLoading={isLoading}
error={error as Error | null}
retry={refetch}
Template={() => pageData ? (
<RacesAllTemplate
viewData={pageData}
races={paginatedRaces}
totalFilteredCount={filteredRaces.length}
isLoading={false}
currentPage={currentPage}
totalPages={totalPages}
itemsPerPage={ITEMS_PER_PAGE}
onPageChange={handlePageChange}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
showFilters={showFilters}
setShowFilters={setShowFilters}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
onRaceClick={handleRaceClick}
onLeagueClick={handleLeagueClick}
/>
) : null}
loading={{ variant: 'skeleton', message: 'Loading races...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Flag,
title: 'No races found',
description: 'There are no races available at the moment',
}}
/>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
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';
interface RacesPageClientProps {
viewData: RacesViewData;
}
export function RacesPageClient({ viewData }: RacesPageClientProps) {
const router = useRouter();
const [statusFilter, setStatusFilter] = useState<RaceStatusFilter>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [timeFilter, setTimeFilter] = useState<TimeFilter>('upcoming');
const [showFilterModal, setShowFilterModal] = useState(false);
const filteredRaces = useMemo(() => {
return viewData.races.filter((race) => {
if (statusFilter !== 'all' && race.status !== statusFilter) return false;
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) return false;
if (timeFilter === 'upcoming' && !race.isUpcoming) return false;
if (timeFilter === 'live' && !race.isLive) return false;
if (timeFilter === 'past' && !race.isPast) return false;
return true;
});
}, [viewData.races, statusFilter, leagueFilter, timeFilter]);
const racesByDate = useMemo(() => {
const grouped = new Map<string, typeof filteredRaces[0][]>();
filteredRaces.forEach((race) => {
const dateKey = race.scheduledAt.split('T')[0]!;
if (!grouped.has(dateKey)) {
grouped.set(dateKey, []);
}
grouped.get(dateKey)!.push(race);
});
return Array.from(grouped.entries()).map(([dateKey, dayRaces]) => ({
dateKey,
dateLabel: dayRaces[0]?.scheduledAtLabel || '',
races: dayRaces,
}));
}, [filteredRaces]);
return (
<RacesTemplate
viewData={{
...viewData,
races: filteredRaces,
racesByDate,
}}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
timeFilter={timeFilter}
setTimeFilter={setTimeFilter}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
onRaceClick={(id) => router.push(`/races/${id}`)}
onLeagueClick={(id) => router.push(`/leagues/${id}`)}
onWithdraw={(id) => console.log('Withdraw', id)}
onCancel={(id) => console.log('Cancel', id)}
/>
);
}

View File

@@ -0,0 +1,173 @@
/**
* Reset Password Client Component
*
* Handles client-side reset password flow.
*/
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
import { ResetPasswordTemplate } from '@/templates/auth/ResetPasswordTemplate';
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetPasswordViewModelBuilder';
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
import { ResetPasswordFormValidation } from '@/lib/utilities/authValidation';
import { routes } from '@/lib/routing/RouteConfig';
interface ResetPasswordClientProps {
viewData: ResetPasswordViewData;
}
export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
// Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<ResetPasswordViewModel>(() =>
ResetPasswordViewModelBuilder.build(viewData)
);
// Handle form field changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setViewModel(prev => {
const newFormState = {
...prev.formState,
fields: {
...prev.formState.fields,
[name as keyof typeof prev.formState.fields]: {
...prev.formState.fields[name as keyof typeof prev.formState.fields],
value,
touched: true,
error: undefined,
},
},
};
return prev.withFormState(newFormState);
});
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = {
newPassword: viewModel.formState.fields.newPassword.value as string,
confirmPassword: viewModel.formState.fields.confirmPassword.value as string,
};
// Validate form
const validationErrors = ResetPasswordFormValidation.validateForm(formData);
if (validationErrors.length > 0) {
setViewModel(prev => {
const newFormState = {
...prev.formState,
isValid: false,
submitCount: prev.formState.submitCount + 1,
fields: {
...prev.formState.fields,
...validationErrors.reduce((acc, error) => ({
...acc,
[error.field]: {
...prev.formState.fields[error.field as keyof typeof prev.formState.fields],
error: error.message,
touched: true,
},
}), {}),
},
};
return prev.withFormState(newFormState);
});
return;
}
// Update submitting state
setViewModel(prev => prev.withMutationState(true, null));
try {
const token = searchParams.get('token');
if (!token) {
setViewModel(prev => prev.withMutationState(false, 'Invalid reset link'));
return;
}
// Execute reset password mutation
const mutation = new ResetPasswordMutation();
const result = await mutation.execute({
token,
newPassword: formData.newPassword,
});
if (result.isErr()) {
const error = result.getError();
setViewModel(prev => prev.withMutationState(false, error));
return;
}
// Success
const data = result.unwrap();
setViewModel(prev => prev.withSuccess(data.message));
// Redirect to login after a delay
setTimeout(() => {
router.push(routes.auth.login);
}, 3000);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to reset password';
setViewModel(prev => prev.withMutationState(false, errorMessage));
}
};
// Toggle password visibility
const togglePassword = () => {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showPassword: !prev.uiState.showPassword,
}));
};
const toggleConfirmPassword = () => {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showConfirmPassword: !prev.uiState.showConfirmPassword,
}));
};
// Build viewData for template
const templateViewData: ResetPasswordViewData = {
...viewData,
showSuccess: viewModel.showSuccess,
successMessage: viewModel.successMessage || undefined,
formState: viewModel.formState,
isSubmitting: viewModel.isSubmitting,
submitError: viewModel.submitError,
};
return (
<ResetPasswordTemplate
viewData={templateViewData}
formActions={{
handleChange,
handleSubmit,
setShowSuccess: (show) => {
if (!show) {
// Reset to initial state
setViewModel(() => ResetPasswordViewModelBuilder.build(viewData));
}
},
setShowPassword: togglePassword,
setShowConfirmPassword: toggleConfirmPassword,
}}
uiState={{
showPassword: viewModel.uiState.showPassword,
showConfirmPassword: viewModel.uiState.showConfirmPassword,
}}
mutationState={{
isPending: viewModel.mutationPending,
error: viewModel.mutationError,
}}
/>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import { useParams } from 'next/navigation';
import { useMemo } from 'react';
import {
useLeagueJoinRequests,
useLeagueRosterAdmin,
useApproveJoinRequest,
useRejectJoinRequest,
useUpdateMemberRole,
useRemoveMember,
} from "@/hooks/league/useLeagueRosterAdmin";
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
import type { JoinRequestData, RosterMemberData } from '@/lib/view-data/LeagueRosterAdminViewData';
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
export function RosterAdminPage() {
const params = useParams();
const leagueId = params.id as string;
// Fetch data using React-Query + DI
const {
data: joinRequests = [],
isLoading: loadingJoinRequests,
refetch: refetchJoinRequests,
} = useLeagueJoinRequests(leagueId);
const {
data: members = [],
isLoading: loadingMembers,
refetch: refetchMembers,
} = useLeagueRosterAdmin(leagueId);
const loading = loadingJoinRequests || loadingMembers;
// Mutations
const approveMutation = useApproveJoinRequest({
onSuccess: () => refetchJoinRequests(),
});
const rejectMutation = useRejectJoinRequest({
onSuccess: () => refetchJoinRequests(),
});
const updateRoleMutation = useUpdateMemberRole({
onError: () => refetchMembers(), // Refetch on error to restore state
});
const removeMemberMutation = useRemoveMember({
onSuccess: () => refetchMembers(),
});
const pendingCountLabel = useMemo(() => {
return joinRequests.length === 1 ? '1 request' : `${joinRequests.length} requests`;
}, [joinRequests.length]);
const handleApprove = async (requestId: string) => {
await approveMutation.mutateAsync({ leagueId, requestId });
};
const handleReject = async (requestId: string) => {
await rejectMutation.mutateAsync({ leagueId, requestId });
};
const handleRoleChange = async (driverId: string, newRole: MembershipRole) => {
await updateRoleMutation.mutateAsync({ leagueId, driverId, newRole });
};
const handleRemove = async (driverId: string) => {
await removeMemberMutation.mutateAsync({ leagueId, driverId });
};
const viewData = useMemo(() => ({
leagueId,
joinRequests: joinRequests.map((req: LeagueRosterJoinRequestDTO): JoinRequestData => ({
id: req.id,
driver: req.driver as { id: string; name: string },
requestedAt: req.requestedAt,
message: req.message || undefined,
})),
members: members.map((m: LeagueRosterMemberDTO): RosterMemberData => ({
driverId: m.driverId,
driver: m.driver as { id: string; name: string },
role: m.role,
joinedAt: m.joinedAt,
})),
}), [leagueId, joinRequests, members]);
return (
<RosterAdminTemplate
viewData={viewData}
loading={loading}
pendingCountLabel={pendingCountLabel}
onApprove={handleApprove}
onReject={handleReject}
onRoleChange={handleRoleChange}
onRemove={handleRemove}
roleOptions={ROLE_OPTIONS}
/>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { ServerErrorTemplate, type ServerErrorViewData } from '@/templates/ServerErrorTemplate';
/**
* ServerErrorPageClient
*
* Client-side entry point for the 500 page.
* Manages navigation and retry logic and wires it to the template.
*/
export function ServerErrorPageClient() {
const router = useRouter();
const handleHome = () => {
router.push(routes.public.home);
};
const handleRetry = () => {
window.location.reload();
};
const error = new Error('Internal Server Error') as Error & { digest?: string };
error.digest = 'HTTP_500';
const viewData: ServerErrorViewData = {
error,
incidentId: error.digest
};
return (
<ServerErrorTemplate
viewData={viewData}
onRetry={handleRetry}
onHome={handleHome}
/>
);
}

View File

@@ -0,0 +1,164 @@
/**
* Signup Client Component
*
* Handles client-side signup flow.
*/
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/components/auth/AuthContext';
import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
import { SignupTemplate } from '@/templates/auth/SignupTemplate';
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';
interface SignupClientProps {
viewData: SignupViewData;
}
export function SignupClient({ viewData }: SignupClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession } = useAuth();
// Build ViewModel from ViewData
const [viewModel, setViewModel] = useState<SignupViewModel>(() =>
SignupViewModelBuilder.build(viewData)
);
// Handle form field changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setViewModel(prev => {
const newFormState = {
...prev.formState,
fields: {
...prev.formState.fields,
[name]: {
...prev.formState.fields[name as keyof typeof prev.formState.fields],
value,
touched: true,
error: undefined,
},
},
};
return prev.withFormState(newFormState);
});
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = {
firstName: viewModel.formState.fields.firstName.value as string,
lastName: viewModel.formState.fields.lastName.value as string,
email: viewModel.formState.fields.email.value as string,
password: viewModel.formState.fields.password.value as string,
confirmPassword: viewModel.formState.fields.confirmPassword.value as string,
};
// Validate form
const validationErrors = SignupFormValidation.validateForm(formData);
if (validationErrors.length > 0) {
setViewModel(prev => {
const newFormState = {
...prev.formState,
isValid: false,
submitCount: prev.formState.submitCount + 1,
fields: {
...prev.formState.fields,
...validationErrors.reduce((acc, error) => ({
...acc,
[error.field]: {
...prev.formState.fields[error.field],
error: error.message,
touched: true,
},
}), {}),
},
};
return prev.withFormState(newFormState);
});
return;
}
// Update submitting state
setViewModel(prev => prev.withMutationState(true, null));
try {
// Generate display name
const displayName = SignupFormValidation.generateDisplayName(formData.firstName, formData.lastName);
// Execute signup mutation
const mutation = new SignupMutation();
const result = await mutation.execute({
email: formData.email,
password: formData.password,
displayName,
});
if (result.isErr()) {
const error = result.getError();
setViewModel(prev => prev.withMutationState(false, error));
return;
}
// Success - refresh session and redirect
await refreshSession();
const returnTo = searchParams.get('returnTo') ?? '/onboarding';
router.push(returnTo);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Signup failed';
setViewModel(prev => prev.withMutationState(false, errorMessage));
}
};
// Toggle password visibility
const togglePassword = () => {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showPassword: !prev.uiState.showPassword,
}));
};
const toggleConfirmPassword = () => {
setViewModel(prev => prev.withUIState({
...prev.uiState,
showConfirmPassword: !prev.uiState.showConfirmPassword,
}));
};
// Build viewData for template
const templateViewData: SignupViewData = {
...viewData,
formState: viewModel.formState,
isSubmitting: viewModel.isSubmitting,
submitError: viewModel.submitError,
};
return (
<SignupTemplate
viewData={templateViewData}
formActions={{
handleChange,
handleSubmit,
setShowPassword: togglePassword,
setShowConfirmPassword: toggleConfirmPassword,
}}
uiState={{
showPassword: viewModel.uiState.showPassword,
showConfirmPassword: viewModel.uiState.showConfirmPassword,
}}
mutationState={{
isPending: viewModel.mutationPending,
error: viewModel.mutationError,
}}
/>
);
}

View File

@@ -0,0 +1,19 @@
'use client';
import React, { useState } from 'react';
import { SponsorLeagueDetailTemplate } from '@/templates/SponsorLeagueDetailTemplate';
export function SponsorLeagueDetailPageClient({ data }: { data: any }) {
const [activeTab, setActiveTab] = useState<'overview' | 'drivers' | 'races' | 'sponsor'>('overview');
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
return (
<SponsorLeagueDetailTemplate
viewData={data}
activeTab={activeTab}
setActiveTab={setActiveTab}
selectedTier={selectedTier}
setSelectedTier={setSelectedTier}
/>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import React, { useState, useMemo } from 'react';
import { SponsorLeaguesTemplate, type SortOption, type TierFilter, type AvailabilityFilter } from '@/templates/SponsorLeaguesTemplate';
export function SponsorLeaguesPageClient({ data }: { data: unknown }) {
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;
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;
}
if (tierFilter !== 'all' && league.tier !== tierFilter) {
return false;
}
if (availabilityFilter === 'main' && !league.mainSponsorSlot.available) {
return false;
}
if (availabilityFilter === 'secondary' && league.secondarySlots.available === 0) {
return false;
}
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;
case 'drivers': return b.drivers - a.drivers;
case 'price': return a.mainSponsorSlot.price - b.mainSponsorSlot.price;
case 'views': return b.avgViewsPerRace - a.avgViewsPerRace;
default: return 0;
}
});
}, [data, searchQuery, tierFilter, availabilityFilter, sortBy]);
return (
<SponsorLeaguesTemplate
// eslint-disable-next-line @typescript-eslint/no-explicit-any
viewData={data as any}
filteredLeagues={filteredLeagues}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { InlineNotice } from '@/ui/InlineNotice';
import { ProgressLine } from '@/ui/ProgressLine';
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;
onAccept: (requestId: string) => Promise<Result<void, string>>;
onReject: (requestId: string, reason?: string) => Promise<Result<void, string>>;
}
export function SponsorshipRequestsClient({ viewData, onAccept, onReject }: SponsorshipRequestsClientProps) {
const router = useRouter();
const [isProcessing, setIsProcessing] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleAccept = async (requestId: string) => {
setIsProcessing(requestId);
setError(null);
const result = await onAccept(requestId);
if (result.isErr()) {
setError(result.getError());
setIsProcessing(null);
} else {
router.refresh();
setIsProcessing(null);
}
};
const handleReject = async (requestId: string, reason?: string) => {
setIsProcessing(requestId);
setError(null);
const result = await onReject(requestId, reason);
if (result.isErr()) {
setError(result.getError());
setIsProcessing(null);
} else {
router.refresh();
setIsProcessing(null);
}
};
return (
<>
<ProgressLine isLoading={!!isProcessing} />
{error && (
<Box position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
<InlineNotice
variant="error"
title="Action Failed"
message={error}
/>
</Box>
)}
<SponsorshipRequestsTemplate
viewData={viewData}
onAccept={handleAccept}
onReject={handleReject}
processingId={isProcessing}
/>
</>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import type { Result } from '@/lib/contracts/Result';
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
interface SponsorshipRequestsPageClientProps {
viewData: SponsorshipRequestsViewData;
onAccept: (requestId: string) => Promise<Result<void, string>>;
onReject: (requestId: string, reason?: string) => Promise<Result<void, string>>;
}
export function SponsorshipRequestsPageClient({ viewData, onAccept, onReject }: SponsorshipRequestsPageClientProps) {
return (
<SponsorshipRequestsTemplate
viewData={viewData}
onAccept={async (requestId) => {
await onAccept(requestId);
}}
onReject={async (requestId, reason) => {
await onReject(requestId, reason);
}}
/>
);
}

View File

@@ -0,0 +1,246 @@
'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';
interface StewardingTemplateProps {
data: StewardingViewData;
leagueId: string;
currentDriverId: string;
onRefetch: () => void;
}
export function StewardingPageClient({ data, currentDriverId, onRefetch }: StewardingTemplateProps) {
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<ProtestViewModel | null>(null);
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
// Mutations using domain hook
const { acceptProtestMutation, rejectProtestMutation } = useLeagueStewardingMutations(onRefetch);
// Flatten protests for the specialized list components
const allPendingProtests = useMemo(() => {
return data.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',
description: p.incident.description,
submittedAt: p.filedAt,
status: p.status as 'pending' | 'under_review' | 'resolved' | 'rejected',
})));
}, [data.races, data.drivers]);
const allResolvedProtests = useMemo(() => {
return data.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({
id: p.id,
protestingDriverId: p.protestingDriverId,
accusedDriverId: p.accusedDriverId,
description: p.incident.description,
submittedAt: p.filedAt,
status: p.status,
raceId: r.id,
incident: p.incident,
proofVideoUrl: p.proofVideoUrl,
decisionNotes: p.decisionNotes,
} as never)));
}, [data.races]);
const racesMap = useMemo(() => {
const map: Record<string, RaceViewModel> = {};
data.races.forEach(r => {
map[r.id] = new RaceViewModel({
id: r.id,
name: '',
date: r.scheduledAt,
track: r.track,
} as never);
});
return map;
}, [data.races]);
const driverMap = useMemo(() => {
const map: Record<string, DriverViewModel> = {};
data.drivers.forEach(d => {
map[d.id] = new DriverViewModel({
id: d.id,
name: d.name,
iracingId: '',
country: '',
joinedAt: '',
avatarUrl: null,
});
});
return map;
}, [data.drivers]);
const handleAcceptProtest = async (
protestId: string,
penaltyType: string,
penaltyValue: number,
stewardNotes: string
) => {
// Find the protest to get details for penalty
let foundProtest: { raceId: string; accusedDriverId: string; incident: { description: string } } | undefined;
data.races.forEach((raceData) => {
const p = raceData.pendingProtests.find((pr) => pr.id === protestId) ||
raceData.resolvedProtests.find((pr) => pr.id === protestId);
if (p) foundProtest = {
raceId: raceData.id,
accusedDriverId: p.accusedDriverId,
incident: { description: p.incident.description }
};
});
if (foundProtest) {
acceptProtestMutation.mutate({
protestId,
penaltyType,
penaltyValue,
stewardNotes,
raceId: foundProtest.raceId,
accusedDriverId: foundProtest.accusedDriverId,
reason: foundProtest.incident.description,
});
}
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
rejectProtestMutation.mutate({
protestId,
stewardNotes,
});
};
const handleReviewProtest = (id: string) => {
// Find the protest in the data
let foundProtest: ProtestViewModel | null = null;
data.races.forEach(r => {
const p = r.pendingProtests.find(p => p.id === id);
if (p) {
foundProtest = new ProtestViewModel({
id: p.id,
protestingDriverId: p.protestingDriverId,
accusedDriverId: p.accusedDriverId,
description: p.incident.description,
submittedAt: p.filedAt,
status: p.status,
raceId: r.id,
incident: p.incident,
proofVideoUrl: p.proofVideoUrl,
decisionNotes: p.decisionNotes,
} as never);
}
});
if (foundProtest) setSelectedProtest(foundProtest);
};
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>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData';
import { TeamDetailTemplate } from '@/templates/TeamDetailTemplate';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
interface TeamDetailPageClientProps {
viewData: TeamDetailViewData;
}
export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) {
const router = useRouter();
// UI state only (no business logic)
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [loading] = useState(false);
// Event handlers
const handleTabChange = (tab: Tab) => {
setActiveTab(tab);
};
const handleUpdate = () => {
// Trigger a refresh by reloading the page
router.refresh();
};
const handleRemoveMember = (driverId: string) => {
// This would call an API to remove the member
// For now, just log
console.log('Remove member:', driverId);
// In a real implementation, you'd have a mutation hook here
alert('Remove member functionality would be implemented here');
};
const handleGoBack = () => {
router.back();
};
return (
<TeamDetailTemplate
viewData={viewData}
activeTab={activeTab}
loading={loading}
onTabChange={handleTabChange}
onUpdate={handleUpdate}
onRemoveMember={handleRemoveMember}
onGoBack={handleGoBack}
/>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import { useRouter } from 'next/navigation';
import { TeamLeaderboardTemplate } from '@/templates/TeamLeaderboardTemplate';
import { useState } from 'react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
export function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) {
const router = useRouter();
// Client-side UI state only (no business logic)
const [searchQuery, setSearchQuery] = useState('');
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
const [sortBy, setSortBy] = useState<SortBy>('rating');
if (!data || data.length === 0) {
return null;
}
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
const handleBackToTeams = () => {
router.push('/teams');
};
const viewData = {
teams: data,
searchQuery,
filterLevel,
sortBy,
filteredAndSortedTeams: data,
};
return (
<TeamLeaderboardTemplate
viewData={viewData}
onSearchChange={setSearchQuery}
filterLevelChange={setFilterLevel}
onSortChange={setSortBy}
onTeamClick={handleTeamClick}
onBackToTeams={handleBackToTeams}
/>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { TeamsTemplate } from '@/templates/TeamsTemplate';
import type { TeamsViewData } from '@/lib/view-data/TeamsViewData';
import { routes } from '@/lib/routing/RouteConfig';
interface TeamsPageClientProps {
viewData: TeamsViewData;
}
export function TeamsPageClient({ viewData }: TeamsPageClientProps) {
const router = useRouter();
const handleTeamClick = (teamId: string) => {
router.push(`/teams/${teamId}`);
};
const handleViewFullLeaderboard = () => {
router.push(routes.team.leaderboard);
};
const handleCreateTeam = () => {
router.push(routes.team.detail('create'));
};
return (
<TeamsTemplate
viewData={viewData}
onTeamClick={handleTeamClick}
onViewFullLeaderboard={handleViewFullLeaderboard}
onCreateTeam={handleCreateTeam}
/>
);
}