di usage in website

This commit is contained in:
2026-01-06 19:36:03 +01:00
parent 589b55a87e
commit e589c30bf8
191 changed files with 6367 additions and 4253 deletions

View File

@@ -4,7 +4,7 @@ import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import { useServices } from '@/lib/services/ServiceProvider';
import { useCreateDriver } from '@/hooks/driver/useCreateDriver';
interface FormErrors {
name?: string;
@@ -16,8 +16,7 @@ interface FormErrors {
export default function CreateDriverForm() {
const router = useRouter();
const { driverService } = useServices();
const [loading, setLoading] = useState(false);
const createDriverMutation = useCreateDriver();
const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState({
@@ -50,37 +49,37 @@ export default function CreateDriverForm() {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
if (createDriverMutation.isPending) return;
const isValid = await validateForm();
if (!isValid) return;
setLoading(true);
try {
const bio = formData.bio.trim();
const bio = formData.bio.trim();
const displayName = formData.name.trim();
const parts = displayName.split(' ').filter(Boolean);
const firstName = parts[0] ?? displayName;
const lastName = parts.slice(1).join(' ') || 'Driver';
const displayName = formData.name.trim();
const parts = displayName.split(' ').filter(Boolean);
const firstName = parts[0] ?? displayName;
const lastName = parts.slice(1).join(' ') || 'Driver';
await driverService.completeDriverOnboarding({
createDriverMutation.mutate(
{
firstName,
lastName,
displayName,
country: formData.country.trim().toUpperCase(),
...(bio ? { bio } : {}),
});
router.push('/profile');
router.refresh();
} catch (error) {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create profile'
});
setLoading(false);
}
},
{
onSuccess: () => {
router.push('/profile');
router.refresh();
},
onError: (error) => {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create profile'
});
},
}
);
};
return (
@@ -98,7 +97,7 @@ export default function CreateDriverForm() {
error={!!errors.name}
errorMessage={errors.name}
placeholder="Alex Vermeer"
disabled={loading}
disabled={createDriverMutation.isPending}
/>
</div>
@@ -114,7 +113,7 @@ export default function CreateDriverForm() {
error={!!errors.name}
errorMessage={errors.name}
placeholder="Alex Vermeer"
disabled={loading}
disabled={createDriverMutation.isPending}
/>
</div>
@@ -131,7 +130,7 @@ export default function CreateDriverForm() {
errorMessage={errors.country}
placeholder="NL"
maxLength={3}
disabled={loading}
disabled={createDriverMutation.isPending}
/>
<p className="mt-1 text-xs text-gray-500">Use ISO 3166-1 alpha-2 or alpha-3 code</p>
</div>
@@ -147,7 +146,7 @@ export default function CreateDriverForm() {
placeholder="Tell us about yourself..."
maxLength={500}
rows={4}
disabled={loading}
disabled={createDriverMutation.isPending}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
@@ -167,10 +166,10 @@ export default function CreateDriverForm() {
<Button
type="submit"
variant="primary"
disabled={loading}
disabled={createDriverMutation.isPending}
className="w-full"
>
{loading ? 'Creating Profile...' : 'Create Profile'}
{createDriverMutation.isPending ? 'Creating Profile...' : 'Create Profile'}
</Button>
</form>
</>

View File

@@ -8,8 +8,7 @@ import ProfileStats from './ProfileStats';
import CareerHighlights from './CareerHighlights';
import DriverRankings from './DriverRankings';
import PerformanceMetrics from './PerformanceMetrics';
import { useEffect, useState } from 'react';
import { useServices } from '@/lib/services/ServiceProvider';
import { useDriverProfile } from '@/hooks/driver/useDriverProfile';
interface DriverProfileProps {
driver: DriverViewModel;
@@ -25,42 +24,29 @@ interface DriverTeamViewModel {
}
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
const { driverService } = useServices();
const [profileData, setProfileData] = useState<DriverProfileStatsViewModel | null>(null);
const [teamData, setTeamData] = useState<DriverTeamViewModel | null>(null);
const { data: profileData, isLoading } = useDriverProfile(driver.id);
useEffect(() => {
const load = async () => {
try {
// Load driver profile
const profile = await driverService.getDriverProfile(driver.id);
// Extract stats from profile
if (profile.stats) {
setProfileData(profile.stats);
}
// Load team data if available
if (profile.teamMemberships && profile.teamMemberships.length > 0) {
const currentTeam = profile.teamMemberships.find(m => m.isCurrent) || profile.teamMemberships[0];
if (currentTeam) {
setTeamData({
team: {
name: currentTeam.teamName,
tag: currentTeam.teamTag ?? ''
}
});
}
}
} catch (error) {
console.error('Failed to load driver profile data:', error);
// Extract team data from profile
const teamData: DriverTeamViewModel | null = (() => {
if (!profileData?.teamMemberships || profileData.teamMemberships.length === 0) {
return null;
}
const currentTeam = profileData.teamMemberships.find(m => m.isCurrent) || profileData.teamMemberships[0];
if (!currentTeam) {
return null;
}
return {
team: {
name: currentTeam.teamName,
tag: currentTeam.teamTag ?? ''
}
};
void load();
}, [driver.id, driverService]);
})();
const driverStats = profileData;
const globalRank = profileData?.overallRank ?? null;
const driverStats = profileData?.stats ?? null;
const globalRank = driverStats?.overallRank ?? null;
const totalDrivers = 1000; // Placeholder
const performanceStats = driverStats ? {

View File

@@ -1,9 +1,9 @@
'use client';
import { useDriverProfile } from '@/hooks/driver';
import { useMemo } from 'react';
import Card from '../ui/Card';
import RankBadge from './RankBadge';
import { useMemo } from 'react';
import { useDriverProfile } from '@/hooks/useDriverService';
interface ProfileStatsProps {
driverId?: string;
@@ -206,35 +206,4 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
);
}
function PerformanceRow({ label, races, wins, podiums, avgFinish }: {
label: string;
races: number;
wins: number;
podiums: number;
avgFinish: number;
}) {
const winRate = ((wins / races) * 100).toFixed(0);
return (
<div className="flex items-center justify-between p-3 rounded bg-deep-graphite border border-charcoal-outline">
<div className="flex-1">
<div className="text-white font-medium">{label}</div>
<div className="text-gray-500 text-xs">{races} races</div>
</div>
<div className="flex items-center gap-6 text-xs">
<div>
<div className="text-gray-500">Wins</div>
<div className="text-green-400 font-medium">{wins} ({winRate}%)</div>
</div>
<div>
<div className="text-gray-500">Podiums</div>
<div className="text-warning-amber font-medium">{podiums}</div>
</div>
<div>
<div className="text-gray-500">Avg</div>
<div className="text-white font-medium">{avgFinish.toFixed(1)}</div>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,9 @@
'use client';
import { useRef } from 'react';
import Container from '@/components/ui/Container';
import Heading from '@/components/ui/Heading';
import { useParallax } from '../../hooks/useScrollProgress';
import { useParallax } from '@/hooks/useScrollProgress';
import { useRef } from 'react';
interface AlternatingSectionProps {
heading: string;

View File

@@ -2,7 +2,8 @@
import { useState, FormEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useServices } from '@/lib/services/ServiceProvider';
import { useInject } from '@/lib/di/hooks/useInject';
import { LANDING_SERVICE_TOKEN } from '@/lib/di/tokens';
type FeedbackState =
| { type: 'idle' }
@@ -14,7 +15,7 @@ type FeedbackState =
export default function EmailCapture() {
const [email, setEmail] = useState('');
const [feedback, setFeedback] = useState<FeedbackState>({ type: 'idle' });
const { landingService } = useServices();
const landingService = useInject(LANDING_SERVICE_TOKEN);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();

View File

@@ -4,8 +4,10 @@ import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import { useServices } from '@/lib/services/ServiceProvider';
import { useCreateLeague } from '@/hooks/league/useCreateLeague';
import { useAuth } from '@/lib/auth/AuthContext';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
interface FormErrors {
name?: string;
@@ -17,7 +19,6 @@ interface FormErrors {
export default function CreateLeagueForm() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [formData, setFormData] = useState({
@@ -51,12 +52,13 @@ export default function CreateLeagueForm() {
};
const { session } = useAuth();
const { driverService, leagueService } = useServices();
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const createLeagueMutation = useCreateLeague();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
if (createLeagueMutation.isPending) return;
if (!validateForm()) return;
@@ -65,15 +67,12 @@ export default function CreateLeagueForm() {
return;
}
setLoading(true);
try {
// Get current driver
const currentDriver = await driverService.getDriverProfile(session.user.userId);
if (!currentDriver) {
setErrors({ submit: 'No driver profile found. Please create a profile first.' });
setLoading(false);
return;
}
@@ -85,14 +84,13 @@ export default function CreateLeagueForm() {
ownerId: session.user.userId,
};
const result = await leagueService.createLeague(input);
const result = await createLeagueMutation.mutateAsync(input);
router.push(`/leagues/${result.leagueId}`);
router.refresh();
} catch (error) {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create league'
});
setLoading(false);
}
};
@@ -112,7 +110,7 @@ export default function CreateLeagueForm() {
errorMessage={errors.name}
placeholder="European GT Championship"
maxLength={100}
disabled={loading}
disabled={createLeagueMutation.isPending}
/>
<p className="mt-1 text-xs text-gray-500 text-right">
{formData.name.length}/100
@@ -130,7 +128,7 @@ export default function CreateLeagueForm() {
placeholder="Weekly GT3 racing with professional drivers"
maxLength={500}
rows={4}
disabled={loading}
disabled={createLeagueMutation.isPending}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none"
/>
<p className="mt-1 text-xs text-gray-500 text-right">
@@ -149,7 +147,7 @@ export default function CreateLeagueForm() {
id="pointsSystem"
value={formData.pointsSystem}
onChange={(e) => setFormData({ ...formData, pointsSystem: e.target.value as 'f1-2024' | 'indycar' })}
disabled={loading}
disabled={createLeagueMutation.isPending}
className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6"
>
<option value="f1-2024">F1 2024</option>
@@ -170,7 +168,7 @@ export default function CreateLeagueForm() {
errorMessage={errors.sessionDuration}
min={1}
max={240}
disabled={loading}
disabled={createLeagueMutation.isPending}
/>
</div>
@@ -183,10 +181,10 @@ export default function CreateLeagueForm() {
<Button
type="submit"
variant="primary"
disabled={loading}
disabled={createLeagueMutation.isPending}
className="w-full"
>
{loading ? 'Creating League...' : 'Create League'}
{createLeagueMutation.isPending ? 'Creating League...' : 'Create League'}
</Button>
</form>
</>

View File

@@ -3,7 +3,7 @@
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { getMembership } from '@/lib/leagueMembership';
import { useState } from 'react';
import { useServices } from '@/lib/services/ServiceProvider';
import { useLeagueMembershipMutation } from '@/hooks/league/useLeagueMembershipMutation';
import Button from '../ui/Button';
interface JoinLeagueButtonProps {
@@ -18,16 +18,16 @@ export default function JoinLeagueButton({
onMembershipChange,
}: JoinLeagueButtonProps) {
const currentDriverId = useEffectiveDriverId();
const membership = getMembership(leagueId, currentDriverId);
const { leagueMembershipService } = useServices();
const membership = currentDriverId ? getMembership(leagueId, currentDriverId) : null;
const { joinLeague, leaveLeague } = useLeagueMembershipMutation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [dialogAction, setDialogAction] = useState<'join' | 'leave' | 'request'>('join');
const handleJoin = async () => {
setLoading(true);
if (!currentDriverId) return;
setError(null);
try {
if (isInviteOnly) {
@@ -36,33 +36,30 @@ export default function JoinLeagueButton({
);
}
await leagueMembershipService.joinLeague(leagueId, currentDriverId);
await joinLeague.mutateAsync({ leagueId, driverId: currentDriverId });
onMembershipChange?.();
setShowConfirmDialog(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join league');
} finally {
setLoading(false);
}
};
const handleLeave = async () => {
setLoading(true);
if (!currentDriverId) return;
setError(null);
try {
if (membership?.role === 'owner') {
throw new Error('League owner cannot leave the league');
}
await leagueMembershipService.leaveLeague(leagueId, currentDriverId);
await leaveLeague.mutateAsync({ leagueId, driverId: currentDriverId });
onMembershipChange?.();
setShowConfirmDialog(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to leave league');
} finally {
setLoading(false);
}
};
@@ -93,7 +90,7 @@ export default function JoinLeagueButton({
return 'danger';
};
const isDisabled = membership?.role === 'owner' || loading;
const isDisabled = membership?.role === 'owner' || joinLeague.isPending || leaveLeague.isPending;
return (
<>
@@ -109,7 +106,7 @@ export default function JoinLeagueButton({
disabled={isDisabled}
className="w-full"
>
{loading ? 'Processing...' : getButtonText()}
{(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : getButtonText()}
</Button>
{error && (
@@ -142,15 +139,15 @@ export default function JoinLeagueButton({
<Button
variant={dialogAction === 'leave' ? 'danger' : 'primary'}
onClick={dialogAction === 'leave' ? handleLeave : handleJoin}
disabled={loading}
disabled={joinLeague.isPending || leaveLeague.isPending}
className="flex-1"
>
{loading ? 'Processing...' : 'Confirm'}
{(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : 'Confirm'}
</Button>
<Button
variant="secondary"
onClick={closeDialog}
disabled={loading}
disabled={joinLeague.isPending || leaveLeague.isPending}
className="flex-1"
>
Cancel

View File

@@ -1,11 +1,9 @@
'use client';
import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useServices } from '@/lib/services/ServiceProvider';
import type { RaceListItemViewModel } from '@/lib/view-models/RaceListItemViewModel';
import { useLeagueRaces } from '@/hooks/league/useLeagueRaces';
export type LeagueActivity =
export type LeagueActivity =
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
| { type: 'race_scheduled'; raceId: string; raceName: string; timestamp: Date }
| { type: 'penalty_applied'; penaltyId: string; driverName: string; reason: string; points: number; timestamp: Date }
@@ -32,60 +30,45 @@ function timeAgo(timestamp: Date): string {
}
export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) {
const { raceService, driverService } = useServices();
const [activities, setActivities] = useState<LeagueActivity[]>([]);
const [loading, setLoading] = useState(true);
const { data: raceList = [], isLoading } = useLeagueRaces(leagueId);
useEffect(() => {
async function loadActivities() {
try {
const raceList = await raceService.findByLeagueId(leagueId);
const activities: LeagueActivity[] = [];
if (!isLoading && raceList.length > 0) {
const completedRaces = raceList
.filter((r) => r.status === 'completed')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 5);
const completedRaces = raceList
.filter((r) => r.status === 'completed')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 5);
const upcomingRaces = raceList
.filter((r) => r.status === 'scheduled')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 3);
const upcomingRaces = raceList
.filter((r) => r.status === 'scheduled')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 3);
const activityList: LeagueActivity[] = [];
for (const race of completedRaces) {
activityList.push({
type: 'race_completed',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: new Date(race.scheduledAt),
});
}
for (const race of upcomingRaces) {
activityList.push({
type: 'race_scheduled',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: new Date(new Date(race.scheduledAt).getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement
});
}
// Sort all activities by timestamp
activityList.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
setActivities(activityList.slice(0, limit));
} catch (err) {
console.error('Failed to load activities:', err);
} finally {
setLoading(false);
}
for (const race of completedRaces) {
activities.push({
type: 'race_completed',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: new Date(race.scheduledAt),
});
}
loadActivities();
}, [leagueId, limit, raceService, driverService]);
for (const race of upcomingRaces) {
activities.push({
type: 'race_scheduled',
raceId: race.id,
raceName: `${race.track} - ${race.car}`,
timestamp: new Date(new Date(race.scheduledAt).getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement
});
}
if (loading) {
// Sort all activities by timestamp
activities.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
activities.splice(limit); // Limit results
}
if (isLoading) {
return (
<div className="text-center text-gray-400 py-8">
Loading activities...

View File

@@ -2,13 +2,14 @@
import DriverIdentity from '../drivers/DriverIdentity';
import { useEffectiveDriverId } from '../../hooks/useEffectiveDriverId';
import { useServices } from '../../lib/services/ServiceProvider';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { useCallback, useEffect, useState } from 'react';
// Migrated to useServices-based website services; legacy EntityMapper removed.
// Migrated to useInject-based DI; legacy EntityMapper removed.
interface LeagueMembersProps {
leagueId: string;
@@ -28,7 +29,8 @@ export default function LeagueMembers({
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<'role' | 'name' | 'date' | 'rating' | 'points' | 'wins'>('rating');
const currentDriverId = useEffectiveDriverId();
const { leagueMembershipService, driverService } = useServices();
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const loadMembers = useCallback(async () => {
setLoading(true);

View File

@@ -1,16 +1,16 @@
'use client';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useRegisterForRace, useWithdrawFromRace } from '@/hooks/useRaceService';
import { useRegisterForRace } from '@/hooks/race/useRegisterForRace';
import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { EmptyState } from '@/components/shared/state/EmptyState';
import { useServices } from '@/lib/services/ServiceProvider';
import { useLeagueSchedule } from '@/hooks/league/useLeagueSchedule';
import { Calendar } from 'lucide-react';
interface LeagueScheduleProps {
@@ -22,12 +22,8 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
const currentDriverId = useEffectiveDriverId();
const { leagueService } = useServices();
const { data: schedule, isLoading, error, retry } = useDataFetching({
queryKey: ['leagueSchedule', leagueId],
queryFn: () => leagueService.getLeagueSchedule(leagueId),
});
const { data: schedule, isLoading, error, retry } = useLeagueSchedule(leagueId);
const registerMutation = useRegisterForRace();
const withdrawMutation = useWithdrawFromRace();

View File

@@ -1,13 +1,16 @@
'use client';
import { Award, DollarSign, Star, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useState } from 'react';
import PendingSponsorshipRequests, { type PendingRequestDTO } from '../sponsors/PendingSponsorshipRequests';
import Button from '../ui/Button';
import Input from '../ui/Input';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider';
import { useLeagueSeasons } from '@/hooks/league/useLeagueSeasons';
import { useSponsorshipRequests } from '@/hooks/league/useSponsorshipRequests';
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSORSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
interface SponsorshipSlot {
tier: 'main' | 'secondary';
@@ -29,7 +32,8 @@ export function LeagueSponsorshipsSection({
readOnly = false
}: LeagueSponsorshipsSectionProps) {
const currentDriverId = useEffectiveDriverId();
const { sponsorshipService, leagueService } = useServices();
const sponsorshipService = useInject(SPONSORSHIP_SERVICE_TOKEN);
const [slots, setSlots] = useState<SponsorshipSlot[]>([
{ tier: 'main', price: 500, isOccupied: false },
{ tier: 'secondary', price: 200, isOccupied: false },
@@ -37,73 +41,21 @@ export function LeagueSponsorshipsSection({
]);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [tempPrice, setTempPrice] = useState<string>('');
const [pendingRequests, setPendingRequests] = useState<PendingRequestDTO[]>([]);
const [requestsLoading, setRequestsLoading] = useState(false);
const [seasonId, setSeasonId] = useState<string | undefined>(propSeasonId);
// Load season ID if not provided
useEffect(() => {
async function loadSeasonId() {
if (propSeasonId) {
setSeasonId(propSeasonId);
return;
}
try {
const seasons = await leagueService.getLeagueSeasons(leagueId);
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
if (activeSeason) setSeasonId(activeSeason.seasonId);
} catch (err) {
console.error('Failed to load season:', err);
}
}
loadSeasonId();
}, [leagueId, propSeasonId, leagueService]);
const { data: seasons = [], isLoading: seasonsLoading } = useLeagueSeasons(leagueId);
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
const seasonId = propSeasonId || activeSeason?.seasonId;
// Load pending sponsorship requests
const loadPendingRequests = useCallback(async () => {
if (!seasonId) return;
setRequestsLoading(true);
try {
const requests = await sponsorshipService.getPendingSponsorshipRequests({
entityType: 'season',
entityId: seasonId,
});
// Convert service view-models to component DTO type (UI-only)
setPendingRequests(
requests.map(
(r): PendingRequestDTO => ({
id: r.id,
sponsorId: r.sponsorId,
sponsorName: r.sponsorName,
sponsorLogo: r.sponsorLogo,
tier: r.tier,
offeredAmount: r.offeredAmount,
currency: r.currency,
formattedAmount: r.formattedAmount,
message: r.message,
createdAt: r.createdAt,
platformFee: r.platformFee,
netAmount: r.netAmount,
}),
),
);
} catch (err) {
console.error('Failed to load pending requests:', err);
} finally {
setRequestsLoading(false);
}
}, [seasonId, sponsorshipService]);
useEffect(() => {
loadPendingRequests();
}, [loadPendingRequests]);
const { data: pendingRequests = [], isLoading: requestsLoading, refetch: refetchRequests } = useSponsorshipRequests('season', seasonId || '');
const handleAcceptRequest = async (requestId: string) => {
if (!currentDriverId) return;
try {
await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId);
await loadPendingRequests();
await refetchRequests();
} catch (err) {
console.error('Failed to accept request:', err);
alert(err instanceof Error ? err.message : 'Failed to accept request');
@@ -111,9 +63,11 @@ export function LeagueSponsorshipsSection({
};
const handleRejectRequest = async (requestId: string, reason?: string) => {
if (!currentDriverId) return;
try {
await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason);
await loadPendingRequests();
await refetchRequests();
} catch (err) {
console.error('Failed to reject request:', err);
alert(err instanceof Error ? err.message : 'Failed to reject request');

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Button from '@/components/ui/Button';
import { useServices } from '@/lib/services/ServiceProvider';
import { usePenaltyMutation } from '@/hooks/league/usePenaltyMutation';
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
interface DriverOption {
@@ -41,16 +41,14 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte
const [infractionType, setInfractionType] = useState<string>('');
const [severity, setSeverity] = useState<string>('');
const [notes, setNotes] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const { penaltyService } = useServices();
const penaltyMutation = usePenaltyMutation();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedRaceId || !selectedDriver || !infractionType || !severity) return;
setLoading(true);
setError(null);
try {
@@ -64,15 +62,14 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte
if (notes.trim()) {
command.notes = notes.trim();
}
await penaltyService.applyPenalty(command);
await penaltyMutation.mutateAsync(command);
// Refresh the page to show updated results
router.refresh();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to apply penalty');
} finally {
setLoading(false);
}
};
@@ -206,7 +203,7 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte
variant="secondary"
onClick={onClose}
className="flex-1"
disabled={loading}
disabled={penaltyMutation.isPending}
>
Cancel
</Button>
@@ -214,9 +211,9 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte
type="submit"
variant="primary"
className="flex-1"
disabled={loading || !selectedRaceId || !selectedDriver || !infractionType || !severity}
disabled={penaltyMutation.isPending || !selectedRaceId || !selectedDriver || !infractionType || !severity}
>
{loading ? 'Applying...' : 'Apply Penalty'}
{penaltyMutation.isPending ? 'Applying...' : 'Apply Penalty'}
</Button>
</div>
</form>

View File

@@ -1,10 +1,10 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Button from '../ui/Button';
import Input from '../ui/Input';
import { useServices } from '@/lib/services/ServiceProvider';
import { useAllLeagues } from '@/hooks/league/useAllLeagues';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
interface ScheduleRaceFormData {
@@ -35,10 +35,7 @@ export default function ScheduleRaceForm({
onCancel
}: ScheduleRaceFormProps) {
const router = useRouter();
const { leagueService, raceService } = useServices();
const [leagues, setLeagues] = useState<LeagueSummaryViewModel[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: leagues = [], isLoading, error } = useAllLeagues();
const [formData, setFormData] = useState<ScheduleRaceFormData>({
leagueId: preSelectedLeagueId || '',
@@ -51,18 +48,6 @@ export default function ScheduleRaceForm({
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
useEffect(() => {
const loadLeagues = async () => {
try {
const allLeagues = await leagueService.getAllLeagues();
setLeagues(allLeagues);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load leagues');
}
};
void loadLeagues();
}, [leagueService]);
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
@@ -107,9 +92,6 @@ export default function ScheduleRaceForm({
return;
}
setLoading(true);
setError(null);
try {
// Create race using the race service
// Note: This assumes the race service has a create method
@@ -137,9 +119,8 @@ export default function ScheduleRaceForm({
router.push(`/races/${createdRace.id}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create race');
} finally {
setLoading(false);
// Error handling is now done through the component state
console.error('Failed to create race:', err);
}
};
@@ -160,7 +141,7 @@ export default function ScheduleRaceForm({
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400">
{error}
{error.message}
</div>
)}
@@ -310,10 +291,10 @@ export default function ScheduleRaceForm({
<Button
type="submit"
variant="primary"
disabled={loading}
disabled={isLoading}
className="flex-1"
>
{loading ? 'Creating...' : 'Schedule Race'}
{isLoading ? 'Creating...' : 'Schedule Race'}
</Button>
{onCancel && (
@@ -321,7 +302,7 @@ export default function ScheduleRaceForm({
type="button"
variant="secondary"
onClick={onCancel}
disabled={loading}
disabled={isLoading}
>
Cancel
</Button>

View File

@@ -22,7 +22,10 @@ import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import CountrySelect from '@/components/ui/CountrySelect';
import { useServices } from '@/lib/services/ServiceProvider';
import { useAuth } from '@/lib/auth/AuthContext';
import { useCompleteOnboarding } from '@/hooks/onboarding/useCompleteOnboarding';
import { useGenerateAvatars } from '@/hooks/onboarding/useGenerateAvatars';
import { useValidateFacePhoto } from '@/hooks/onboarding/useValidateFacePhoto';
// ============================================================================
// TYPES
@@ -163,9 +166,8 @@ function StepIndicator({ currentStep }: { currentStep: number }) {
export default function OnboardingWizard() {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const { onboardingService, sessionService } = useServices();
const { session } = useAuth();
const [step, setStep] = useState<OnboardingStep>(1);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
// Form state
@@ -270,6 +272,19 @@ export default function OnboardingWizard() {
reader.readAsDataURL(file);
};
const validateFacePhotoMutation = useValidateFacePhoto({
onSuccess: () => {
setAvatarInfo(prev => ({ ...prev, isValidating: false }));
},
onError: (error) => {
setErrors(prev => ({
...prev,
facePhoto: error.message || 'Face validation failed'
}));
setAvatarInfo(prev => ({ ...prev, facePhoto: null, isValidating: false }));
},
});
const validateFacePhoto = async (photoData: string) => {
setAvatarInfo(prev => ({ ...prev, isValidating: true }));
setErrors(prev => {
@@ -278,7 +293,7 @@ export default function OnboardingWizard() {
});
try {
const result = await onboardingService.validateFacePhoto(photoData);
const result = await validateFacePhotoMutation.mutateAsync(photoData);
if (!result.isValid) {
setErrors(prev => ({
@@ -286,8 +301,6 @@ export default function OnboardingWizard() {
facePhoto: result.errorMessage || 'Face validation failed'
}));
setAvatarInfo(prev => ({ ...prev, facePhoto: null, isValidating: false }));
} else {
setAvatarInfo(prev => ({ ...prev, isValidating: false }));
}
} catch (error) {
// For now, just accept the photo if validation fails
@@ -295,31 +308,8 @@ export default function OnboardingWizard() {
}
};
const generateAvatars = async () => {
if (!avatarInfo.facePhoto) {
setErrors({ ...errors, facePhoto: 'Please upload a photo first' });
return;
}
setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null }));
setErrors(prev => {
const { avatar, ...rest } = prev;
return rest;
});
try {
// Get current user ID from session
const session = await sessionService.getSession();
if (!session?.user?.userId) {
throw new Error('User not authenticated');
}
const result = await onboardingService.generateAvatars(
session.user.userId,
avatarInfo.facePhoto,
avatarInfo.suitColor
);
const generateAvatarsMutation = useGenerateAvatars({
onSuccess: (result) => {
if (result.success && result.avatarUrls) {
setAvatarInfo(prev => ({
...prev,
@@ -330,15 +320,56 @@ export default function OnboardingWizard() {
setErrors(prev => ({ ...prev, avatar: result.errorMessage || 'Failed to generate avatars' }));
setAvatarInfo(prev => ({ ...prev, isGenerating: false }));
}
} catch (error) {
},
onError: () => {
setErrors(prev => ({ ...prev, avatar: 'Failed to generate avatars. Please try again.' }));
setAvatarInfo(prev => ({ ...prev, isGenerating: false }));
},
});
const generateAvatars = async () => {
if (!avatarInfo.facePhoto) {
setErrors({ ...errors, facePhoto: 'Please upload a photo first' });
return;
}
if (!session?.user?.userId) {
setErrors({ ...errors, submit: 'User not authenticated' });
return;
}
setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null }));
setErrors(prev => {
const { avatar, ...rest } = prev;
return rest;
});
try {
await generateAvatarsMutation.mutateAsync({
userId: session.user.userId,
facePhotoData: avatarInfo.facePhoto,
suitColor: avatarInfo.suitColor,
});
} catch (error) {
// Error handling is done in the mutation's onError callback
}
};
const completeOnboardingMutation = useCompleteOnboarding({
onSuccess: () => {
// TODO: Handle avatar assignment separately if needed
router.push('/dashboard');
router.refresh();
},
onError: (error) => {
setErrors({
submit: error.message || 'Failed to create profile',
});
},
});
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
// Validate step 2 - must have selected an avatar
if (!validateStep(2)) {
@@ -350,35 +381,26 @@ export default function OnboardingWizard() {
return;
}
setLoading(true);
setErrors({});
try {
// Note: The current API doesn't support avatarUrl in onboarding
// This would need to be handled separately or the API would need to be updated
const result = await onboardingService.completeOnboarding({
await completeOnboardingMutation.mutateAsync({
firstName: personalInfo.firstName.trim(),
lastName: personalInfo.lastName.trim(),
displayName: personalInfo.displayName.trim(),
country: personalInfo.country,
timezone: personalInfo.timezone || undefined,
});
if (result.success) {
// TODO: Handle avatar assignment separately if needed
router.push('/dashboard');
router.refresh();
} else {
throw new Error(result.errorMessage || 'Failed to create profile');
}
} catch (error) {
setErrors({
submit: error instanceof Error ? error.message : 'Failed to create profile',
});
setLoading(false);
// Error handling is done in the mutation's onError callback
}
};
// Loading state comes from the mutations
const loading = completeOnboardingMutation.isPending ||
generateAvatarsMutation.isPending ||
validateFacePhotoMutation.isPending;
const getCountryFlag = (countryCode: string): string => {
const code = countryCode.toUpperCase();
if (code.length === 2) {

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import UserPill from './UserPill';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
// Mock useAuth to control session state
vi.mock('@/lib/auth/AuthContext', () => {
@@ -19,21 +19,21 @@ vi.mock('@/hooks/useEffectiveDriverId', () => {
};
});
// Mock services hook to inject stub driverService
// Mock the new DI hooks
const mockFindById = vi.fn();
let mockDriverData: any = null;
vi.mock('@/lib/services/ServiceProvider', () => {
return {
useServices: () => ({
driverService: {
findById: mockFindById,
},
mediaService: {
getDriverAvatar: vi.fn(),
},
}),
};
});
vi.mock('@/hooks/driver/useFindDriverById', () => ({
useFindDriverById: (driverId: string) => {
return {
data: mockDriverData,
isLoading: false,
isError: false,
isSuccess: !!mockDriverData,
refetch: vi.fn(),
};
},
}));
interface MockSessionUser {
id: string;
@@ -64,6 +64,7 @@ describe('UserPill', () => {
beforeEach(() => {
mockedAuthValue = { session: null };
mockedDriverId = null;
mockDriverData = null;
mockFindById.mockReset();
});
@@ -93,18 +94,20 @@ describe('UserPill', () => {
});
it('loads driver via driverService and uses driver avatarUrl', async () => {
const driver: DriverDTO = {
const driver = {
id: 'driver-1',
iracingId: 'ir-123',
name: 'Test Driver',
country: 'DE',
joinedAt: '2023-01-01',
avatarUrl: '/api/media/avatar/driver-1',
};
mockedAuthValue = { session: { user: { id: 'user-1' } } };
mockedDriverId = driver.id;
mockFindById.mockResolvedValue(driver);
// Set the mock data that the hook will return
mockDriverData = driver;
render(<UserPill />);
@@ -112,6 +115,6 @@ describe('UserPill', () => {
expect(screen.getByText('Test Driver')).toBeInTheDocument();
});
expect(mockFindById).toHaveBeenCalledWith('driver-1');
expect(mockFindById).not.toHaveBeenCalled(); // Hook is mocked, not called directly
});
});

View File

@@ -11,13 +11,14 @@ import { CapabilityGate } from '@/components/shared/CapabilityGate';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/DriverViewModel';
import { useServices } from '@/lib/services/ServiceProvider';
import { useFindDriverById } from '@/hooks/driver/useFindDriverById';
// Hook to detect demo user mode based on session
function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } {
const { session } = useAuth();
const [demoMode, setDemoMode] = useState({ isDemo: false, demoRole: null as string | null });
// Check if this is a demo user
useEffect(() => {
if (!session?.user) {
setDemoMode({ isDemo: false, demoRole: null });
@@ -81,12 +82,12 @@ function useHasAdminAccess(): boolean {
}
// Sponsor Pill Component - matches the style of DriverSummaryPill
function SponsorSummaryPill({
onClick,
function SponsorSummaryPill({
onClick,
companyName = 'Acme Racing Co.',
activeSponsors = 7,
impressions = 127,
}: {
}: {
onClick: () => void;
companyName?: string;
activeSponsors?: number;
@@ -136,38 +137,22 @@ function SponsorSummaryPill({
export default function UserPill() {
const { session } = useAuth();
const { driverService, mediaService } = useServices();
const [driver, setDriver] = useState<DriverViewModel | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { isDemo, demoRole } = useDemoUserMode();
const shouldReduceMotion = useReducedMotion();
const primaryDriverId = useEffectiveDriverId();
// Load driver data only for non-demo users
useEffect(() => {
let cancelled = false;
// Use React-Query hook for driver data (only for non-demo users)
const { data: driverDto } = useFindDriverById(primaryDriverId || '', {
enabled: !!primaryDriverId && !isDemo,
});
async function loadDriver() {
if (!primaryDriverId || isDemo) {
if (!cancelled) {
setDriver(null);
}
return;
}
const dto = await driverService.findById(primaryDriverId);
if (!cancelled) {
setDriver(dto ? new DriverViewModelClass({ ...dto, avatarUrl: (dto as any).avatarUrl ?? null }) : null);
}
}
void loadDriver();
return () => {
cancelled = true;
};
}, [primaryDriverId, driverService, isDemo]);
// Transform DTO to ViewModel
const driver = useMemo(() => {
if (!driverDto) return null;
return new DriverViewModelClass({ ...driverDto, avatarUrl: (driverDto as any).avatarUrl ?? null });
}, [driverDto]);
const data = useMemo(() => {
if (!session?.user) {

View File

@@ -5,7 +5,7 @@ import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO';
import type { ProtestIncidentDTO } from '@/lib/types/generated/ProtestIncidentDTO';
import { useServices } from '@/lib/services/ServiceProvider';
import { useFileProtest } from '@/hooks/race/useFileProtest';
import {
AlertTriangle,
Video,
@@ -39,8 +39,7 @@ export default function FileProtestModal({
protestingDriverId,
participants,
}: FileProtestModalProps) {
const { raceService } = useServices();
const [step, setStep] = useState<'form' | 'submitting' | 'success' | 'error'>('form');
const fileProtestMutation = useFileProtest();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Form state
@@ -68,37 +67,41 @@ export default function FileProtestModal({
return;
}
setStep('submitting');
setErrorMessage(null);
try {
const incident: ProtestIncidentDTO = {
lap: parseInt(lap, 10),
description: description.trim(),
...(timeInRace ? { timeInRace: parseInt(timeInRace, 10) } : {}),
};
const incident: ProtestIncidentDTO = {
lap: parseInt(lap, 10),
description: description.trim(),
...(timeInRace ? { timeInRace: parseInt(timeInRace, 10) } : {}),
};
const command = {
raceId,
protestingDriverId,
accusedDriverId,
incident,
...(comment.trim() ? { comment: comment.trim() } : {}),
...(proofVideoUrl.trim() ? { proofVideoUrl: proofVideoUrl.trim() } : {}),
} satisfies FileProtestCommandDTO;
const command = {
raceId,
protestingDriverId,
accusedDriverId,
incident,
...(comment.trim() ? { comment: comment.trim() } : {}),
...(proofVideoUrl.trim() ? { proofVideoUrl: proofVideoUrl.trim() } : {}),
} satisfies FileProtestCommandDTO;
await raceService.fileProtest(command);
setStep('success');
} catch (err) {
setStep('error');
setErrorMessage(err instanceof Error ? err.message : 'Failed to file protest');
}
fileProtestMutation.mutate(command, {
onSuccess: () => {
// Reset form state on success
setAccusedDriverId('');
setLap('');
setTimeInRace('');
setDescription('');
setComment('');
setProofVideoUrl('');
},
onError: (error) => {
setErrorMessage(error.message || 'Failed to file protest');
},
});
};
const handleClose = () => {
// Reset form state
setStep('form');
setErrorMessage(null);
setAccusedDriverId('');
setLap('');
@@ -106,10 +109,12 @@ export default function FileProtestModal({
setDescription('');
setComment('');
setProofVideoUrl('');
fileProtestMutation.reset();
onClose();
};
if (step === 'success') {
// Show success state when mutation is successful
if (fileProtestMutation.isSuccess) {
return (
<Modal
isOpen={isOpen}
@@ -122,7 +127,7 @@ export default function FileProtestModal({
</div>
<p className="text-white font-medium mb-2">Your protest has been submitted</p>
<p className="text-sm text-gray-400 mb-6">
The stewards will review your protest and make a decision.
The stewards will review your protest and make a decision.
You'll be notified of the outcome.
</p>
<Button variant="primary" onClick={handleClose}>
@@ -157,7 +162,7 @@ export default function FileProtestModal({
<select
value={accusedDriverId}
onChange={(e) => setAccusedDriverId(e.target.value)}
disabled={step === 'submitting'}
disabled={fileProtestMutation.isPending}
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50"
>
<option value="">Select driver...</option>
@@ -181,7 +186,7 @@ export default function FileProtestModal({
min="0"
value={lap}
onChange={(e) => setLap(e.target.value)}
disabled={step === 'submitting'}
disabled={fileProtestMutation.isPending}
placeholder="e.g. 5"
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50"
/>
@@ -196,13 +201,13 @@ export default function FileProtestModal({
min="0"
value={timeInRace}
onChange={(e) => setTimeInRace(e.target.value)}
disabled={step === 'submitting'}
disabled={fileProtestMutation.isPending}
placeholder="Optional"
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50"
/>
</div>
</div>
{/* Incident Description */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
@@ -212,13 +217,13 @@ export default function FileProtestModal({
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={step === 'submitting'}
disabled={fileProtestMutation.isPending}
placeholder="Describe the incident clearly and objectively..."
rows={3}
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50 resize-none"
/>
</div>
{/* Additional Comment */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
@@ -228,13 +233,13 @@ export default function FileProtestModal({
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={step === 'submitting'}
disabled={fileProtestMutation.isPending}
placeholder="Any additional context for the stewards..."
rows={2}
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50 resize-none"
/>
</div>
{/* Video Proof */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
@@ -245,7 +250,7 @@ export default function FileProtestModal({
type="url"
value={proofVideoUrl}
onChange={(e) => setProofVideoUrl(e.target.value)}
disabled={step === 'submitting'}
disabled={fileProtestMutation.isPending}
placeholder="https://youtube.com/... or https://streamable.com/..."
className="w-full px-3 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue/50 focus:border-primary-blue disabled:opacity-50"
/>
@@ -253,22 +258,22 @@ export default function FileProtestModal({
Providing video evidence significantly helps the stewards review your protest.
</p>
</div>
{/* Info Box */}
<div className="p-3 bg-iron-gray rounded-lg border border-charcoal-outline">
<p className="text-xs text-gray-400">
<strong className="text-gray-300">Note:</strong> Filing a protest does not guarantee action.
The stewards will review the incident and may apply penalties ranging from time penalties
<strong className="text-gray-300">Note:</strong> Filing a protest does not guarantee action.
The stewards will review the incident and may apply penalties ranging from time penalties
to grid penalties for future races, depending on the severity.
</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
variant="secondary"
onClick={handleClose}
disabled={step === 'submitting'}
disabled={fileProtestMutation.isPending}
className="flex-1"
>
Cancel
@@ -276,10 +281,10 @@ export default function FileProtestModal({
<Button
variant="primary"
onClick={handleSubmit}
disabled={step === 'submitting'}
disabled={fileProtestMutation.isPending}
className="flex-1"
>
{step === 'submitting' ? 'Submitting...' : 'Submit Protest'}
{fileProtestMutation.isPending ? 'Submitting...' : 'Submit Protest'}
</Button>
</div>
</div>

View File

@@ -1,8 +1,7 @@
'use client';
import { ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useServices } from '@/lib/services/ServiceProvider';
import { useCapability } from '@/hooks/useCapability';
type CapabilityGateProps = {
capabilityKey: string;
@@ -17,26 +16,17 @@ export function CapabilityGate({
fallback = null,
comingSoon = null,
}: CapabilityGateProps) {
const { policyService } = useServices();
const { isLoading, isError, capabilityState } = useCapability(capabilityKey);
const { data, isLoading, isError } = useQuery({
queryKey: ['policySnapshot'],
queryFn: () => policyService.getSnapshot(),
staleTime: 60_000,
gcTime: 5 * 60_000,
});
if (isLoading || isError || !data) {
if (isLoading || isError || !capabilityState) {
return <>{fallback}</>;
}
const state = policyService.getCapabilityState(data, capabilityKey);
if (state === 'enabled') {
if (capabilityState === 'enabled') {
return <>{children}</>;
}
if (state === 'coming_soon') {
if (capabilityState === 'coming_soon') {
return <>{comingSoon ?? fallback}</>;
}

View File

@@ -1,39 +0,0 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import Button from '@/components/ui/Button';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
className?: string;
}
export const EmptyState = ({
icon: Icon,
title,
description,
action,
className = ''
}: EmptyStateProps) => (
<div className={`text-center py-12 ${className}`}>
<div className="max-w-md mx-auto">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-iron-gray/60 border border-charcoal-outline/50 mb-6">
<Icon className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-xl font-semibold text-white mb-3">{title}</h3>
{description && (
<p className="text-gray-400 mb-8">{description}</p>
)}
{action && (
<Button variant="primary" onClick={action.onClick} className="mx-auto">
{action.label}
</Button>
)}
</div>
</div>
);

View File

@@ -1,15 +0,0 @@
import React from 'react';
interface LoadingStateProps {
message?: string;
className?: string;
}
export const LoadingState = ({ message = 'Loading...', className = '' }: LoadingStateProps) => (
<div className={`flex items-center justify-center min-h-[200px] ${className}`}>
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">{message}</p>
</div>
</div>
);

View File

@@ -1,374 +0,0 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { UseDataFetchingOptions, UseDataFetchingResult } from '../types/state.types';
import { delay, retryWithBackoff } from '@/lib/utils/errorUtils';
/**
* useDataFetching Hook
*
* Unified data fetching hook with built-in state management, error handling,
* retry logic, and caching support.
*
* Features:
* - Automatic loading state management
* - Error classification and handling
* - Built-in retry with exponential backoff
* - Cache and stale time support
* - Refetch capability
* - Success/error callbacks
* - Auto-retry on mount for recoverable errors
*
* Usage Example:
* ```typescript
* const { data, isLoading, error, retry, refetch } = useDataFetching({
* queryKey: ['dashboardOverview'],
* queryFn: () => dashboardService.getDashboardOverview(),
* retryOnMount: true,
* cacheTime: 5 * 60 * 1000,
* onSuccess: (data) => console.log('Loaded:', data),
* onError: (error) => console.error('Error:', error),
* });
* ```
*/
export function useDataFetching<T>(
options: UseDataFetchingOptions<T>
): UseDataFetchingResult<T> {
const {
queryKey,
queryFn,
enabled = true,
retryOnMount = false,
cacheTime = 5 * 60 * 1000, // 5 minutes
staleTime = 1 * 60 * 1000, // 1 minute
maxRetries = 3,
retryDelay = 1000,
onSuccess,
onError,
} = options;
// State management
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isFetching, setIsFetching] = useState<boolean>(false);
const [error, setError] = useState<ApiError | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [isStale, setIsStale] = useState<boolean>(true);
// Refs for caching and retry logic
const cacheRef = useRef<{
data: T | null;
timestamp: number;
isStale: boolean;
} | null>(null);
const retryCountRef = useRef<number>(0);
const isMountedRef = useRef<boolean>(true);
// Check if cache is valid
const isCacheValid = useCallback((): boolean => {
if (!cacheRef.current) return false;
const now = Date.now();
const age = now - cacheRef.current.timestamp;
// Cache is valid if within cacheTime and not stale
return age < cacheTime && !cacheRef.current.isStale;
}, [cacheTime]);
// Update cache
const updateCache = useCallback((newData: T | null, isStale: boolean = false) => {
cacheRef.current = {
data: newData,
timestamp: Date.now(),
isStale,
};
}, []);
// Main fetch function
const fetch = useCallback(async (isRetry: boolean = false): Promise<T | null> => {
if (!enabled) {
return null;
}
// Check cache first
if (!isRetry && isCacheValid() && cacheRef.current && cacheRef.current.data !== null) {
setData(cacheRef.current.data);
setIsLoading(false);
setIsFetching(false);
setError(null);
setLastUpdated(new Date(cacheRef.current.timestamp));
setIsStale(false);
return cacheRef.current.data;
}
setIsFetching(true);
if (!isRetry) {
setIsLoading(true);
}
setError(null);
try {
// Execute the fetch with retry logic
const result = await retryWithBackoff(
async () => {
retryCountRef.current++;
return await queryFn();
},
maxRetries,
retryDelay
);
if (!isMountedRef.current) {
return null;
}
// Success - update state and cache
setData(result);
setLastUpdated(new Date());
setIsStale(false);
updateCache(result, false);
retryCountRef.current = 0; // Reset retry count on success
if (onSuccess) {
onSuccess(result);
}
return result;
} catch (err) {
if (!isMountedRef.current) {
return null;
}
// Convert to ApiError if needed
const apiError = err instanceof ApiError ? err : new ApiError(
err instanceof Error ? err.message : 'An unexpected error occurred',
'UNKNOWN_ERROR',
{
timestamp: new Date().toISOString(),
retryCount: retryCountRef.current,
wasRetry: isRetry,
},
err instanceof Error ? err : undefined
);
setError(apiError);
if (onError) {
onError(apiError);
}
// Mark cache as stale on error
if (cacheRef.current) {
cacheRef.current.isStale = true;
setIsStale(true);
}
throw apiError;
} finally {
setIsLoading(false);
setIsFetching(false);
}
}, [enabled, isCacheValid, queryFn, maxRetries, retryDelay, updateCache, onSuccess, onError]);
// Retry function
const retry = useCallback(async () => {
return await fetch(true);
}, [fetch]);
// Refetch function
const refetch = useCallback(async () => {
// Force bypass cache
cacheRef.current = null;
return await fetch(false);
}, [fetch]);
// Initial fetch and auto-retry on mount
useEffect(() => {
isMountedRef.current = true;
const initialize = async () => {
if (!enabled) return;
// Check if we should auto-retry on mount
const shouldRetryOnMount = retryOnMount && error && error.isRetryable();
if (shouldRetryOnMount) {
try {
await retry();
} catch (err) {
// Error already set by retry
}
} else if (!data && !error) {
// Initial fetch
try {
await fetch(false);
} catch (err) {
// Error already set by fetch
}
}
};
initialize();
return () => {
isMountedRef.current = false;
};
}, [enabled, retryOnMount]); // eslint-disable-line react-hooks/exhaustive-deps
// Effect to check staleness
useEffect(() => {
if (!lastUpdated) return;
const checkStale = () => {
if (!lastUpdated) return;
const now = Date.now();
const age = now - lastUpdated.getTime();
if (age > staleTime) {
setIsStale(true);
if (cacheRef.current) {
cacheRef.current.isStale = true;
}
}
};
const interval = setInterval(checkStale, 30000); // Check every 30 seconds
return () => clearInterval(interval);
}, [lastUpdated, staleTime]);
// Effect to update cache staleness
useEffect(() => {
if (isStale && cacheRef.current) {
cacheRef.current.isStale = true;
}
}, [isStale]);
// Clear cache function (useful for manual cache invalidation)
const clearCache = useCallback(() => {
cacheRef.current = null;
setIsStale(true);
}, []);
// Reset function (clears everything)
const reset = useCallback(() => {
setData(null);
setIsLoading(false);
setIsFetching(false);
setError(null);
setLastUpdated(null);
setIsStale(true);
cacheRef.current = null;
retryCountRef.current = 0;
}, []);
return {
data,
isLoading,
isFetching,
error,
retry,
refetch,
lastUpdated,
isStale,
// Additional utility functions (not part of standard interface but useful)
_clearCache: clearCache,
_reset: reset,
} as UseDataFetchingResult<T>;
}
/**
* useDataFetchingWithPagination Hook
*
* Extension of useDataFetching for paginated data
*/
export function useDataFetchingWithPagination<T>(
options: UseDataFetchingOptions<T[]> & {
initialPage?: number;
pageSize?: number;
}
) {
const {
initialPage = 1,
pageSize = 10,
queryFn,
...restOptions
} = options;
const [page, setPage] = useState<number>(initialPage);
const [hasMore, setHasMore] = useState<boolean>(true);
const paginatedQueryFn = useCallback(async () => {
const result = await queryFn();
// Check if there's more data
if (Array.isArray(result)) {
setHasMore(result.length === pageSize);
}
return result;
}, [queryFn, pageSize]);
const result = useDataFetching<T[]>({
...restOptions,
queryFn: paginatedQueryFn,
});
const loadMore = useCallback(async () => {
if (!hasMore) return;
const nextPage = page + 1;
setPage(nextPage);
// This would need to be integrated with the actual API
// For now, we'll just refetch which may not be ideal
await result.refetch();
}, [page, hasMore, result]);
const resetPagination = useCallback(() => {
setPage(initialPage);
setHasMore(true);
if (result._reset) {
result._reset();
}
}, [initialPage, result]);
return {
...result,
page,
hasMore,
loadMore,
resetPagination,
};
}
/**
* useDataFetchingWithRefresh Hook
*
* Extension with automatic refresh capability
*/
export function useDataFetchingWithRefresh<T>(
options: UseDataFetchingOptions<T> & {
refreshInterval?: number; // milliseconds
}
) {
const { refreshInterval, ...restOptions } = options;
const result = useDataFetching<T>(restOptions);
useEffect(() => {
if (!refreshInterval) return;
const interval = setInterval(() => {
if (!result.isLoading && !result.isFetching) {
result.refetch();
}
}, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval, result]);
return result;
}

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { EmptyStateProps } from '../types/state.types';
import { EmptyStateProps } from './types';
import Button from '@/components/ui/Button';
// Illustration components (simple SVG representations)

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Wifi, RefreshCw, ArrowLeft, Home, X, Info } from 'lucide-react';
import { ErrorDisplayProps } from '../types/state.types';
import { ErrorDisplayProps } from './types';
import Button from '@/components/ui/Button';
/**
@@ -70,12 +70,6 @@ export function ErrorDisplay({
// Icon based on error type
const ErrorIcon = isConnectivity ? Wifi : AlertTriangle;
// Common button styles
const buttonBase = 'flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50';
const primaryButton = `${buttonBase} bg-red-500 hover:bg-red-600 text-white`;
const secondaryButton = `${buttonBase} bg-iron-gray hover:bg-charcoal-outline text-gray-300 border border-charcoal-outline`;
const ghostButton = `${buttonBase} hover:bg-iron-gray/50 text-gray-300`;
// Render different variants
switch (variant) {
case 'full-screen':
@@ -125,11 +119,11 @@ export function ErrorDisplay({
{/* Action Buttons */}
<div className="flex flex-col gap-2 pt-2">
{isRetryable && onRetry && (
<button
<Button
variant="danger"
onClick={handleRetry}
disabled={isRetrying}
className={primaryButton}
aria-label={isRetrying ? 'Retrying...' : 'Try again'}
className="w-full"
>
{isRetrying ? (
<>
@@ -142,55 +136,46 @@ export function ErrorDisplay({
Try Again
</>
)}
</button>
</Button>
)}
{showNavigation && (
<div className="flex gap-2">
<button
<Button
variant="secondary"
onClick={handleGoBack}
className={`${secondaryButton} flex-1`}
aria-label="Go back to previous page"
className="flex-1"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</button>
</Button>
<button
<Button
variant="secondary"
onClick={handleGoHome}
className={`${secondaryButton} flex-1`}
aria-label="Go to home page"
className="flex-1"
>
<Home className="w-4 h-4" />
Home
</button>
</Button>
</div>
)}
{/* Custom Actions */}
{actions.length > 0 && (
<div className="flex flex-col gap-2 pt-2 border-t border-charcoal-outline/50">
{actions.map((action, index) => {
const variantClasses = {
primary: 'bg-primary-blue hover:bg-blue-600 text-white',
secondary: 'bg-iron-gray hover:bg-charcoal-outline text-gray-300 border border-charcoal-outline',
danger: 'bg-red-600 hover:bg-red-700 text-white',
ghost: 'hover:bg-iron-gray/50 text-gray-300',
}[action.variant || 'secondary'];
return (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className={`${buttonBase} ${variantClasses} ${action.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
aria-label={action.label}
>
{action.icon && <action.icon className="w-4 h-4" />}
{action.label}
</button>
);
})}
{actions.map((action, index) => (
<Button
key={index}
variant={action.variant || 'secondary'}
onClick={action.onClick}
disabled={action.disabled}
className="w-full"
>
{action.icon && <action.icon className="w-4 h-4" />}
{action.label}
</Button>
))}
</div>
)}
</div>

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { LoadingWrapperProps } from '../types/state.types';
import { LoadingWrapperProps } from './types';
/**
* LoadingWrapper Component

View File

@@ -1,7 +1,7 @@
'use client';
import React, { ReactNode } from 'react';
import { StateContainerProps, StateContainerConfig } from '../types/state.types';
import { StateContainerProps, StateContainerConfig } from './types';
import { LoadingWrapper } from './LoadingWrapper';
import { ErrorDisplay } from './ErrorDisplay';
import { EmptyState } from './EmptyState';
@@ -52,7 +52,7 @@ export function StateContainer<T>({
isEmpty,
}: StateContainerProps<T>) {
// Determine if data is empty
const isDataEmpty = (data: T | null): boolean => {
const isDataEmpty = (data: T | null | undefined): boolean => {
if (data === null || data === undefined) return true;
if (isEmpty) return isEmpty(data);
@@ -156,7 +156,7 @@ export function StateContainer<T>({
);
}
// At this point, data is guaranteed to be non-null
// At this point, data is guaranteed to be non-null and non-undefined
return <>{children(data as T)}</>;
}

View File

@@ -1,59 +0,0 @@
/**
* Basic test file to verify state components are properly exported and typed
*/
import { LoadingWrapper } from '../LoadingWrapper';
import { ErrorDisplay } from '../ErrorDisplay';
import { EmptyState } from '../EmptyState';
import { StateContainer } from '../StateContainer';
import { useDataFetching } from '../../hooks/useDataFetching';
import { ApiError } from '@/lib/api/base/ApiError';
// This file just verifies that all components can be imported and are properly typed
// Full testing would be done in separate test files
describe('State Components - Basic Type Checking', () => {
it('should export all components', () => {
expect(LoadingWrapper).toBeDefined();
expect(ErrorDisplay).toBeDefined();
expect(EmptyState).toBeDefined();
expect(StateContainer).toBeDefined();
expect(useDataFetching).toBeDefined();
});
it('should have proper component signatures', () => {
// LoadingWrapper accepts props
const loadingProps = {
variant: 'spinner' as const,
message: 'Loading...',
size: 'md' as const,
};
expect(loadingProps).toBeDefined();
// ErrorDisplay accepts ApiError
const mockError = new ApiError(
'Test error',
'NETWORK_ERROR',
{ timestamp: new Date().toISOString() }
);
expect(mockError).toBeDefined();
expect(mockError.isRetryable()).toBe(true);
// EmptyState accepts icon and title
const emptyProps = {
icon: require('lucide-react').Activity,
title: 'No data',
};
expect(emptyProps).toBeDefined();
// StateContainer accepts data and state
const stateProps = {
data: null,
isLoading: false,
error: null,
retry: async () => {},
children: (data: any) => <div>{JSON.stringify(data)}</div>,
};
expect(stateProps).toBeDefined();
});
});

View File

@@ -0,0 +1,116 @@
'use client';
import { LucideIcon } from 'lucide-react';
import { ReactNode } from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
// ==================== EMPTY STATE TYPES ====================
export interface EmptyStateAction {
label: string;
onClick: () => void;
icon?: LucideIcon;
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
}
export interface EmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
action?: EmptyStateAction;
variant?: 'default' | 'minimal' | 'full-page';
className?: string;
illustration?: 'racing' | 'league' | 'team' | 'sponsor' | 'driver';
ariaLabel?: string;
}
// ==================== LOADING STATE TYPES ====================
export interface LoadingCardConfig {
count?: number;
height?: string;
className?: string;
}
export interface LoadingWrapperProps {
variant?: 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
message?: string;
className?: string;
size?: 'sm' | 'md' | 'lg';
skeletonCount?: number;
cardConfig?: LoadingCardConfig;
ariaLabel?: string;
}
// ==================== ERROR STATE TYPES ====================
export interface ErrorAction {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
icon?: LucideIcon;
disabled?: boolean;
}
export interface ErrorDisplayProps {
error: ApiError;
onRetry?: () => void;
variant?: 'full-screen' | 'inline' | 'card' | 'toast';
showRetry?: boolean;
showNavigation?: boolean;
actions?: ErrorAction[];
className?: string;
hideTechnicalDetails?: boolean;
ariaLabel?: string;
}
// ==================== STATE CONTAINER TYPES ====================
export interface StateContainerConfig<T> {
loading?: {
variant?: LoadingWrapperProps['variant'];
message?: string;
size?: LoadingWrapperProps['size'];
skeletonCount?: number;
};
error?: {
variant?: ErrorDisplayProps['variant'];
showRetry?: boolean;
showNavigation?: boolean;
hideTechnicalDetails?: boolean;
actions?: ErrorAction[];
};
empty?: {
icon: LucideIcon;
title: string;
description?: string;
action?: EmptyStateAction;
illustration?: EmptyStateProps['illustration'];
};
customRender?: {
loading?: () => ReactNode;
error?: (error: ApiError) => ReactNode;
empty?: () => ReactNode;
};
}
export interface StateContainerProps<T> {
data: T | null | undefined;
isLoading: boolean;
error: ApiError | null;
retry: () => void;
children: (data: T) => ReactNode;
config?: StateContainerConfig<T>;
className?: string;
showEmpty?: boolean;
isEmpty?: (data: T) => boolean;
}
// ==================== CONVENIENCE PROP TYPES ====================
// For components that only need specific subsets of props
export type MinimalEmptyStateProps = Omit<EmptyStateProps, 'variant'>;
export type MinimalLoadingProps = Pick<LoadingWrapperProps, 'message' | 'className'>;
export type InlineLoadingProps = Pick<LoadingWrapperProps, 'message' | 'size' | 'className'>;
export type SkeletonLoadingProps = Pick<LoadingWrapperProps, 'skeletonCount' | 'className'>;
export type CardLoadingProps = Pick<LoadingWrapperProps, 'cardConfig' | 'className'>;

View File

@@ -1,386 +0,0 @@
/**
* TypeScript Interfaces for State Management Components
*
* Provides comprehensive type definitions for loading, error, and empty states
* across the GridPilot website application.
*/
import { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
import { ApiError } from '@/lib/api/base/ApiError';
// ============================================================================
// Core State Interfaces
// ============================================================================
/**
* Basic state for any data fetching operation
*/
export interface PageState<T> {
data: T | null;
isLoading: boolean;
error: ApiError | null;
retry: () => Promise<void>;
}
/**
* Extended state with metadata for advanced use cases
*/
export interface PageStateWithMeta<T> extends PageState<T> {
isFetching: boolean;
refetch: () => Promise<void>;
lastUpdated: Date | null;
isStale: boolean;
}
// ============================================================================
// Hook Interfaces
// ============================================================================
/**
* Options for useDataFetching hook
*/
export interface UseDataFetchingOptions<T> {
/** Unique key for caching and invalidation */
queryKey: string[];
/** Function to fetch data */
queryFn: () => Promise<T>;
/** Enable/disable the query */
enabled?: boolean;
/** Auto-retry on mount for recoverable errors */
retryOnMount?: boolean;
/** Cache time in milliseconds */
cacheTime?: number;
/** Stale time in milliseconds */
staleTime?: number;
/** Maximum retry attempts */
maxRetries?: number;
/** Delay between retries in milliseconds */
retryDelay?: number;
/** Success callback */
onSuccess?: (data: T) => void;
/** Error callback */
onError?: (error: ApiError) => void;
}
/**
* Result from useDataFetching hook
*/
export interface UseDataFetchingResult<T> {
data: T | null;
isLoading: boolean;
isFetching: boolean;
error: ApiError | null;
retry: () => Promise<void>;
refetch: () => Promise<void>;
lastUpdated: Date | null;
isStale: boolean;
// Internal methods (not part of public API but needed for extensions)
_clearCache?: () => void;
_reset?: () => void;
}
// ============================================================================
// LoadingWrapper Component
// ============================================================================
export type LoadingVariant = 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
export type LoadingSize = 'sm' | 'md' | 'lg';
export interface LoadingWrapperProps {
/** Visual variant of loading state */
variant?: LoadingVariant;
/** Custom message to display */
message?: string;
/** Additional CSS classes */
className?: string;
/** Size of loading indicator */
size?: LoadingSize;
/** For skeleton variant - number of skeleton items to show */
skeletonCount?: number;
/** For card variant - card layout configuration */
cardConfig?: {
height?: number;
count?: number;
className?: string;
};
/** ARIA label for accessibility */
ariaLabel?: string;
}
// ============================================================================
// ErrorDisplay Component
// ============================================================================
export type ErrorVariant = 'full-screen' | 'inline' | 'card' | 'toast';
export interface ErrorAction {
/** Button label */
label: string;
/** Click handler */
onClick: () => void;
/** Visual variant */
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
/** Optional icon */
icon?: LucideIcon;
/** Disabled state */
disabled?: boolean;
}
export interface ErrorDisplayProps {
/** The error to display */
error: ApiError;
/** Retry callback */
onRetry?: () => void;
/** Visual variant */
variant?: ErrorVariant;
/** Show retry button (auto-detected from error.isRetryable()) */
showRetry?: boolean;
/** Show navigation buttons */
showNavigation?: boolean;
/** Additional custom actions */
actions?: ErrorAction[];
/** Additional CSS classes */
className?: string;
/** Hide technical details in production */
hideTechnicalDetails?: boolean;
/** ARIA label for accessibility */
ariaLabel?: string;
}
// ============================================================================
// EmptyState Component
// ============================================================================
export type EmptyVariant = 'default' | 'minimal' | 'full-page';
export type EmptyIllustration = 'racing' | 'league' | 'team' | 'sponsor' | 'driver';
export interface EmptyStateProps {
/** Icon to display */
icon: LucideIcon;
/** Title text */
title: string;
/** Description text */
description?: string;
/** Primary action */
action?: {
label: string;
onClick: () => void;
icon?: LucideIcon;
variant?: 'primary' | 'secondary';
};
/** Visual variant */
variant?: EmptyVariant;
/** Additional CSS classes */
className?: string;
/** Illustration instead of icon */
illustration?: EmptyIllustration;
/** ARIA label for accessibility */
ariaLabel?: string;
}
// ============================================================================
// StateContainer Component
// ============================================================================
export interface StateContainerConfig<T> {
/** Loading state configuration */
loading?: {
variant?: LoadingVariant;
message?: string;
size?: LoadingSize;
skeletonCount?: number;
};
/** Error state configuration */
error?: {
variant?: ErrorVariant;
actions?: ErrorAction[];
showRetry?: boolean;
showNavigation?: boolean;
hideTechnicalDetails?: boolean;
};
/** Empty state configuration */
empty?: {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
/** Custom render functions for advanced use cases */
customRender?: {
loading?: () => ReactNode;
error?: (error: ApiError) => ReactNode;
empty?: () => ReactNode;
};
}
export interface StateContainerProps<T> {
/** Current data */
data: T | null;
/** Loading state */
isLoading: boolean;
/** Error state */
error: ApiError | null;
/** Retry function */
retry: () => Promise<void>;
/** Child render function */
children: (data: T) => ReactNode;
/** Configuration for all states */
config?: StateContainerConfig<T>;
/** Additional CSS classes */
className?: string;
/** Whether to show empty state (default: true) */
showEmpty?: boolean;
/** Custom function to determine if data is empty */
isEmpty?: (data: T) => boolean;
}
// ============================================================================
// Retry Configuration
// ============================================================================
export interface RetryConfig {
/** Maximum retry attempts */
maxAttempts?: number;
/** Base delay in milliseconds */
baseDelay?: number;
/** Backoff multiplier */
backoffMultiplier?: number;
/** Auto-retry on mount */
retryOnMount?: boolean;
}
// ============================================================================
// Notification Configuration
// ============================================================================
export interface NotificationConfig {
/** Show toast on success */
showToastOnSuccess?: boolean;
/** Show toast on error */
showToastOnError?: boolean;
/** Custom success message */
successMessage?: string;
/** Custom error message */
errorMessage?: string;
/** Auto-dismiss delay in milliseconds */
autoDismissDelay?: number;
}
// ============================================================================
// Analytics Configuration
// ============================================================================
export interface StateAnalytics {
/** Called when state changes */
onStateChange?: (from: string, to: string, data?: unknown) => void;
/** Called on error */
onError?: (error: ApiError, context: string) => void;
/** Called on retry */
onRetry?: (attempt: number, maxAttempts: number) => void;
}
// ============================================================================
// Performance Metrics
// ============================================================================
export interface PerformanceMetrics {
/** Time to first render in milliseconds */
timeToFirstRender?: number;
/** Time to data load in milliseconds */
timeToDataLoad?: number;
/** Number of retry attempts */
retryCount?: number;
/** Whether cache was hit */
cacheHit?: boolean;
}
// ============================================================================
// Advanced Configuration
// ============================================================================
export interface AdvancedStateConfig<T> extends StateContainerConfig<T> {
retry?: RetryConfig;
notifications?: NotificationConfig;
analytics?: StateAnalytics;
performance?: PerformanceMetrics;
}
// ============================================================================
// Page Template Interfaces
// ============================================================================
/**
* Generic page template props
*/
export interface PageTemplateProps<T> {
data: T | null;
isLoading: boolean;
error: ApiError | null;
retry: () => Promise<void>;
refetch: () => Promise<void>;
title?: string;
description?: string;
children: (data: T) => ReactNode;
config?: StateContainerConfig<T>;
}
/**
* List page template props
*/
export interface ListPageTemplateProps<T> extends PageTemplateProps<T[]> {
emptyConfig?: {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
showSkeleton?: boolean;
skeletonCount?: number;
}
/**
* Detail page template props
*/
export interface DetailPageTemplateProps<T> extends PageTemplateProps<T> {
onBack?: () => void;
onRefresh?: () => void;
}
// ============================================================================
// Default Configuration
// ============================================================================
export const DEFAULT_CONFIG = {
loading: {
variant: 'spinner' as LoadingVariant,
message: 'Loading...',
size: 'md' as LoadingSize,
},
error: {
variant: 'full-screen' as ErrorVariant,
showRetry: true,
showNavigation: true,
},
empty: {
title: 'No data available',
description: 'There is nothing to display here',
},
retry: {
maxAttempts: 3,
baseDelay: 1000,
backoffMultiplier: 2,
retryOnMount: true,
},
notifications: {
showToastOnSuccess: false,
showToastOnError: true,
autoDismissDelay: 5000,
},
} as const;

View File

@@ -1,28 +1,27 @@
'use client';
import React, { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { useServices } from '@/lib/services/ServiceProvider';
import Card from '@/components/ui/Card';
import { useAuth } from '@/lib/auth/AuthContext';
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSORSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import {
Activity,
Calendar,
Check,
Eye,
TrendingUp,
Users,
Loader2,
MessageCircle,
Shield,
Star,
Target,
DollarSign,
Calendar,
TrendingUp,
Trophy,
Zap,
ExternalLink,
MessageCircle,
Activity,
Shield,
Check,
Loader2,
Users,
Zap
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import React, { useCallback, useState } from 'react';
// ============================================================================
// TYPES
@@ -155,8 +154,9 @@ export default function SponsorInsightsCard({
currentSponsorId,
onSponsorshipRequested,
}: SponsorInsightsProps) {
// TODO components should not fetch any data
const router = useRouter();
const { sponsorshipService } = useServices();
const sponsorshipService = useInject(SPONSORSHIP_SERVICE_TOKEN);
const tierStyles = getTierStyles(tier);
const EntityIcon = getEntityIcon(entityType);

View File

@@ -3,7 +3,7 @@
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider';
import { useCreateTeam } from '@/hooks/team';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
@@ -14,14 +14,13 @@ interface CreateTeamFormProps {
export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormProps) {
const router = useRouter();
const { teamService } = useServices();
const createTeamMutation = useCreateTeam();
const [formData, setFormData] = useState({
name: '',
tag: '',
description: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const currentDriverId = useEffectiveDriverId();
const validateForm = () => {
@@ -56,26 +55,26 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
return;
}
setSubmitting(true);
try {
const result = await teamService.createTeam({
createTeamMutation.mutate(
{
name: formData.name,
tag: formData.tag.toUpperCase(),
description: formData.description,
});
const teamId = result.id;
if (onSuccess) {
onSuccess(teamId);
} else {
router.push(`/teams/${teamId}`);
},
{
onSuccess: (result) => {
const teamId = result.id;
if (onSuccess) {
onSuccess(teamId);
} else {
router.push(`/teams/${teamId}`);
}
},
onError: (error) => {
alert(error instanceof Error ? error.message : 'Failed to create team');
},
}
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to create team');
setSubmitting(false);
}
);
};
return (
@@ -89,7 +88,7 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Enter team name..."
disabled={submitting}
disabled={createTeamMutation.isPending}
/>
{errors.name && (
<p className="text-danger-red text-xs mt-1">{errors.name}</p>
@@ -106,7 +105,7 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
onChange={(e) => setFormData({ ...formData, tag: e.target.value.toUpperCase() })}
placeholder="e.g., APEX"
maxLength={4}
disabled={submitting}
disabled={createTeamMutation.isPending}
/>
<p className="text-xs text-gray-500 mt-1">Max 4 characters</p>
{errors.tag && (
@@ -124,7 +123,7 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe your team's goals and racing style..."
disabled={submitting}
disabled={createTeamMutation.isPending}
/>
{errors.description && (
<p className="text-danger-red text-xs mt-1">{errors.description}</p>
@@ -150,17 +149,17 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
<Button
type="submit"
variant="primary"
disabled={submitting}
disabled={createTeamMutation.isPending}
className="flex-1"
>
{submitting ? 'Creating Team...' : 'Create Team'}
{createTeamMutation.isPending ? 'Creating Team...' : 'Create Team'}
</Button>
{onCancel && (
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={submitting}
disabled={createTeamMutation.isPending}
>
Cancel
</Button>
@@ -168,4 +167,4 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
</div>
</form>
);
}
}

View File

@@ -2,18 +2,8 @@
import Button from '@/components/ui/Button';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useEffect, useState } from 'react';
import { useServices } from '@/lib/services/ServiceProvider';
type TeamMembershipStatus = 'active' | 'pending' | 'inactive';
interface TeamMembership {
teamId: string;
driverId: string;
role: 'owner' | 'manager' | 'driver';
status: TeamMembershipStatus;
joinedAt: Date | string;
}
import { useTeamMembership, useJoinTeam, useLeaveTeam } from '@/hooks/team';
import { useState } from 'react';
interface JoinTeamButtonProps {
teamId: string;
@@ -26,76 +16,63 @@ export default function JoinTeamButton({
requiresApproval = false,
onUpdate,
}: JoinTeamButtonProps) {
const [loading, setLoading] = useState(false);
const currentDriverId = useEffectiveDriverId();
const [membership, setMembership] = useState<TeamMembership | null>(null);
const { teamService, teamJoinService } = useServices();
const [showConfirmation, setShowConfirmation] = useState(false);
useEffect(() => {
const load = async () => {
try {
const m = await teamService.getMembership(teamId, currentDriverId);
setMembership(m as TeamMembership | null);
} catch (error) {
console.error('Failed to load membership:', error);
}
};
void load();
}, [teamId, currentDriverId, teamService]);
// Use hooks for data fetching
const { data: membership, isLoading: loadingMembership } = useTeamMembership(teamId, currentDriverId || '');
const handleJoin = async () => {
setLoading(true);
try {
if (requiresApproval) {
const existing = await teamService.getMembership(teamId, currentDriverId);
if (existing) {
throw new Error('Already a member or have a pending request');
}
// Note: Team join request functionality would need to be added to teamService
// For now, we'll use a placeholder
console.log('Saving join request:', {
id: `team-request-${Date.now()}`,
teamId,
driverId: currentDriverId,
requestedAt: new Date(),
});
alert('Join request sent! Wait for team approval.');
} else {
// Note: Team join functionality would need to be added to teamService
// For now, we'll use a placeholder
console.log('Joining team:', { teamId, driverId: currentDriverId });
alert('Successfully joined team!');
}
// Use hooks for mutations
const joinTeamMutation = useJoinTeam({
onSuccess: () => {
onUpdate?.();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to join team');
} finally {
setLoading(false);
},
});
const leaveTeamMutation = useLeaveTeam({
onSuccess: () => {
onUpdate?.();
setShowConfirmation(false);
},
});
const handleJoin = () => {
if (!currentDriverId) {
alert('Please log in to join a team');
return;
}
joinTeamMutation.mutate({
teamId,
driverId: currentDriverId,
requiresApproval,
});
};
const handleLeave = async () => {
const handleLeave = () => {
if (!currentDriverId) {
alert('Please log in to leave a team');
return;
}
if (!confirm('Are you sure you want to leave this team?')) {
return;
}
setLoading(true);
try {
// Note: Leave team functionality would need to be added to teamService
// For now, we'll use a placeholder
console.log('Leaving team:', { teamId, driverId: currentDriverId });
alert('Successfully left team');
onUpdate?.();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to leave team');
} finally {
setLoading(false);
}
leaveTeamMutation.mutate({
teamId,
driverId: currentDriverId,
});
};
// Loading state
if (loadingMembership) {
return (
<Button variant="primary" disabled>
Loading...
</Button>
);
}
// Already a member
if (membership && membership.status === 'active') {
if (membership && membership.isActive) {
if (membership.role === 'owner') {
return (
<Button variant="secondary" disabled>
@@ -108,9 +85,9 @@ export default function JoinTeamButton({
<Button
variant="danger"
onClick={handleLeave}
disabled={loading}
disabled={leaveTeamMutation.isPending}
>
{loading ? 'Leaving...' : 'Leave Team'}
{leaveTeamMutation.isPending ? 'Leaving...' : 'Leave Team'}
</Button>
);
}
@@ -120,9 +97,9 @@ export default function JoinTeamButton({
<Button
variant="primary"
onClick={handleJoin}
disabled={loading}
disabled={joinTeamMutation.isPending || !currentDriverId}
>
{loading
{joinTeamMutation.isPending
? 'Processing...'
: requiresApproval
? 'Request to Join'

View File

@@ -1,14 +1,12 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { useServices } from '@/lib/services/ServiceProvider';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { useTeamJoinRequests, useUpdateTeam, useApproveJoinRequest, useRejectJoinRequest } from '@/hooks/team';
import type { TeamJoinRequestViewModel } from '@/lib/view-models/TeamJoinRequestViewModel';
import type { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
import type { UpdateTeamViewModel } from '@/lib/view-models/UpdateTeamViewModel';
interface TeamAdminProps {
team: Pick<TeamDetailsViewModel, 'id' | 'name' | 'tag' | 'description' | 'ownerId'>;
@@ -16,10 +14,6 @@ interface TeamAdminProps {
}
export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
const { teamJoinService, teamService } = useServices();
const [joinRequests, setJoinRequests] = useState<TeamJoinRequestViewModel[]>([]);
const [requestDrivers, setRequestDrivers] = useState<Record<string, DriverViewModel>>({});
const [loading, setLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
const [editedTeam, setEditedTeam] = useState({
name: team.name,
@@ -27,60 +21,63 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
description: team.description,
});
useEffect(() => {
const load = async () => {
setLoading(true);
try {
// Current build only supports read-only join requests. Driver hydration is
// not provided by the API response, so we only display driverId.
const currentUserId = team.ownerId;
const isOwner = true;
const requests = await teamJoinService.getJoinRequests(team.id, currentUserId, isOwner);
setJoinRequests(requests);
setRequestDrivers({});
} finally {
setLoading(false);
}
};
// Use hooks for data fetching
const { data: joinRequests = [], isLoading: loading } = useTeamJoinRequests(
team.id,
team.ownerId,
true
);
void load();
}, [team.id, team.name, team.tag, team.description, team.ownerId]);
// Use hooks for mutations
const updateTeamMutation = useUpdateTeam({
onSuccess: () => {
setEditMode(false);
onUpdate();
},
onError: (error) => {
alert(error instanceof Error ? error.message : 'Failed to update team');
},
});
const handleApprove = async (requestId: string) => {
try {
void requestId;
await teamJoinService.approveJoinRequest();
} catch (error) {
const approveJoinRequestMutation = useApproveJoinRequest({
onSuccess: () => {
onUpdate();
},
onError: (error) => {
alert(error instanceof Error ? error.message : 'Failed to approve request');
}
};
},
});
const handleReject = async (requestId: string) => {
try {
void requestId;
await teamJoinService.rejectJoinRequest();
} catch (error) {
const rejectJoinRequestMutation = useRejectJoinRequest({
onSuccess: () => {
onUpdate();
},
onError: (error) => {
alert(error instanceof Error ? error.message : 'Failed to reject request');
}
},
});
const handleApprove = (requestId: string) => {
// Note: The current API doesn't support approving specific requests
// This would need the requestId to be passed to the service
approveJoinRequestMutation.mutate();
};
const handleSaveChanges = async () => {
try {
const result: UpdateTeamViewModel = await teamService.updateTeam(team.id, {
const handleReject = (requestId: string) => {
// Note: The current API doesn't support rejecting specific requests
// This would need the requestId to be passed to the service
rejectJoinRequestMutation.mutate();
};
const handleSaveChanges = () => {
updateTeamMutation.mutate({
teamId: team.id,
input: {
name: editedTeam.name,
tag: editedTeam.tag,
description: editedTeam.description,
});
if (!result.success) {
throw new Error(result.successMessage);
}
setEditMode(false);
onUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to update team');
}
},
});
};
return (
@@ -134,8 +131,8 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
</div>
<div className="flex gap-2">
<Button variant="primary" onClick={handleSaveChanges}>
Save Changes
<Button variant="primary" onClick={handleSaveChanges} disabled={updateTeamMutation.isPending}>
{updateTeamMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
<Button
variant="secondary"
@@ -177,9 +174,9 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
<div className="text-center py-8 text-gray-400">Loading requests...</div>
) : joinRequests.length > 0 ? (
<div className="space-y-3">
{joinRequests.map((request) => {
const driver = requestDrivers[request.driverId] ?? null;
{joinRequests.map((request: TeamJoinRequestViewModel) => {
// Note: Driver hydration is not provided by the API response
// so we only display driverId
return (
<div
key={request.requestId}
@@ -187,30 +184,29 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
>
<div className="flex items-center gap-4 flex-1">
<div className="w-12 h-12 rounded-full bg-primary-blue/20 flex items-center justify-center text-lg font-bold text-white">
{(driver?.name ?? request.driverId).charAt(0)}
{request.driverId.charAt(0)}
</div>
<div className="flex-1">
<h4 className="text-white font-medium">{driver?.name ?? request.driverId}</h4>
<h4 className="text-white font-medium">{request.driverId}</h4>
<p className="text-sm text-gray-400">
{driver?.country ?? 'Unknown'} Requested {new Date(request.requestedAt).toLocaleDateString()}
Requested {new Date(request.requestedAt).toLocaleDateString()}
</p>
{/* Request message is not part of current API contract */}
</div>
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => handleApprove(request.requestId)}
disabled
disabled={approveJoinRequestMutation.isPending}
>
Approve
{approveJoinRequestMutation.isPending ? 'Approving...' : 'Approve'}
</Button>
<Button
variant="danger"
onClick={() => handleReject(request.requestId)}
disabled
disabled={rejectJoinRequestMutation.isPending}
>
Reject
{rejectJoinRequestMutation.isPending ? 'Rejecting...' : 'Reject'}
</Button>
</div>
</div>
@@ -240,4 +236,4 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
</Card>
</div>
);
}
}

View File

@@ -1,18 +1,17 @@
'use client';
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import { useServices } from '@/lib/services/ServiceProvider';
import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
import { useTeamRoster } from '@/hooks/team';
import { useState } from 'react';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
type TeamRole = 'owner' | 'admin' | 'member';
type TeamMembershipSummary = Pick<TeamMemberViewModel, 'driverId' | 'role' | 'joinedAt'>;
type TeamMemberRole = 'owner' | 'manager' | 'member';
interface TeamRosterProps {
teamId: string;
memberships: TeamMembershipSummary[];
memberships: any[];
isAdmin: boolean;
onRemoveMember?: (driverId: string) => void;
onChangeRole?: (driverId: string, newRole: TeamRole) => void;
@@ -25,38 +24,10 @@ export default function TeamRoster({
onRemoveMember,
onChangeRole,
}: TeamRosterProps) {
const { teamService, driverService } = useServices();
const [teamMembers, setTeamMembers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<'role' | 'rating' | 'name'>('rating');
useEffect(() => {
const load = async () => {
setLoading(true);
try {
// Get driver details for each membership
const membersWithDetails = await Promise.all(
memberships.map(async (m) => {
const driver = await driverService.findById(m.driverId);
return {
driver: driver || { id: m.driverId, name: 'Unknown Driver', country: 'Unknown', position: 'N/A', races: '0', impressions: '0', team: 'None' },
role: m.role,
joinedAt: m.joinedAt,
rating: null, // DriverDTO doesn't include rating
overallRank: null, // DriverDTO doesn't include overallRank
};
})
);
setTeamMembers(membersWithDetails);
} catch (error) {
console.error('Failed to load team roster:', error);
} finally {
setLoading(false);
}
};
void load();
}, [memberships, teamService, driverService]);
// Use hook for data fetching
const { data: teamMembers = [], isLoading: loading } = useTeamRoster(memberships);
const getRoleBadgeColor = (role: TeamRole) => {
switch (role) {
@@ -69,15 +40,17 @@ export default function TeamRoster({
}
};
const getRoleLabel = (role: TeamRole) => {
return role.charAt(0).toUpperCase() + role.slice(1);
const getRoleLabel = (role: TeamRole | TeamMemberRole) => {
// Convert manager to admin for display
const displayRole = role === 'manager' ? 'admin' : role;
return displayRole.charAt(0).toUpperCase() + displayRole.slice(1);
};
function getRoleOrder(role: TeamRole): number {
function getRoleOrder(role: TeamMemberRole): number {
switch (role) {
case 'owner':
return 0;
case 'admin':
case 'manager':
return 1;
case 'member':
return 2;
@@ -145,6 +118,8 @@ export default function TeamRoster({
{sortedMembers.map((member) => {
const { driver, role, joinedAt, rating, overallRank } = member;
// Convert manager to admin for display purposes
const displayRole: TeamRole = role === 'manager' ? 'admin' : (role as TeamRole);
const canManageMembership = isAdmin && role !== 'owner';
return (
@@ -153,7 +128,7 @@ export default function TeamRoster({
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-charcoal-outline/60 transition-colors"
>
<DriverIdentity
driver={driver}
driver={driver as DriverViewModel}
href={`/drivers/${driver.id}?from=team&teamId=${teamId}`}
contextLabel={getRoleLabel(role)}
meta={
@@ -185,7 +160,7 @@ export default function TeamRoster({
<div className="flex items-center gap-2">
<select
className="px-3 py-2 bg-iron-gray border-0 rounded text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={role}
value={displayRole}
onChange={(e) =>
onChangeRole?.(driver.id, e.target.value as TeamRole)
}
@@ -212,4 +187,4 @@ export default function TeamRoster({
)}
</Card>
);
}
}

View File

@@ -1,8 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import { useServices } from '@/lib/services/ServiceProvider';
import { useTeamStandings } from '@/hooks/team';
interface TeamStandingsProps {
teamId: string;
@@ -10,32 +9,7 @@ interface TeamStandingsProps {
}
export default function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
const { leagueService } = useServices();
const [standings, setStandings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
try {
// For demo purposes, create mock standings
const mockStandings = leagues.map(leagueId => ({
leagueId,
leagueName: `League ${leagueId}`,
position: Math.floor(Math.random() * 10) + 1,
points: Math.floor(Math.random() * 100),
wins: Math.floor(Math.random() * 5),
racesCompleted: Math.floor(Math.random() * 10),
}));
setStandings(mockStandings);
} catch (error) {
console.error('Failed to load standings:', error);
} finally {
setLoading(false);
}
};
void load();
}, [teamId, leagues]);
const { data: standings = [], isLoading: loading } = useTeamStandings(teamId, leagues);
if (loading) {
return (
@@ -50,7 +24,7 @@ export default function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
<h3 className="text-xl font-semibold text-white mb-6">League Standings</h3>
<div className="space-y-4">
{standings.map((standing) => (
{standings.map((standing: any) => (
<div
key={standing.leagueId}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"