di usage in website
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { LoadingWrapperProps } from '../types/state.types';
|
||||
import { LoadingWrapperProps } from './types';
|
||||
|
||||
/**
|
||||
* LoadingWrapper Component
|
||||
|
||||
@@ -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)}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
116
apps/website/components/shared/state/types.ts
Normal file
116
apps/website/components/shared/state/types.ts
Normal 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'>;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user