error and load state
This commit is contained in:
@@ -14,16 +14,26 @@ import WhyJoinTeamSection from '@/components/teams/WhyJoinTeamSection';
|
||||
import SkillLevelSection from '@/components/teams/SkillLevelSection';
|
||||
import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting';
|
||||
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
|
||||
import { useAllTeams } from '@/hooks/useTeamService';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
|
||||
const SKILL_LEVELS: SkillLevel[] = ['pro', 'advanced', 'intermediate', 'beginner'];
|
||||
|
||||
export default function TeamsInteractive() {
|
||||
const router = useRouter();
|
||||
const { data: teams = [], isLoading: loading } = useAllTeams();
|
||||
const { teamService } = useServices();
|
||||
|
||||
const { data: teams = [], isLoading: loading, error, retry } = useDataFetching({
|
||||
queryKey: ['allTeams'],
|
||||
queryFn: () => teamService.getAllTeams(),
|
||||
});
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
@@ -35,17 +45,20 @@ export default function TeamsInteractive() {
|
||||
advanced: [],
|
||||
pro: [],
|
||||
};
|
||||
teams.forEach((team) => {
|
||||
const level = team.performanceLevel || 'intermediate';
|
||||
if (byLevel[level]) {
|
||||
byLevel[level].push(team);
|
||||
}
|
||||
});
|
||||
if (teams) {
|
||||
teams.forEach((team) => {
|
||||
const level = team.performanceLevel || 'intermediate';
|
||||
if (byLevel[level]) {
|
||||
byLevel[level].push(team);
|
||||
}
|
||||
});
|
||||
}
|
||||
return byLevel;
|
||||
}, [teams]);
|
||||
|
||||
// Select top teams by rating for the preview section
|
||||
const topTeams = useMemo(() => {
|
||||
if (!teams) return [];
|
||||
const sortedByRating = [...teams].sort((a, b) => {
|
||||
// Rating is not currently part of TeamSummaryViewModel in this build.
|
||||
// Keep deterministic ordering by name until a rating field is exposed.
|
||||
@@ -67,7 +80,7 @@ export default function TeamsInteractive() {
|
||||
};
|
||||
|
||||
// Filter by search query
|
||||
const filteredTeams = teams.filter((team) => {
|
||||
const filteredTeams = teams ? teams.filter((team) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
@@ -76,7 +89,7 @@ export default function TeamsInteractive() {
|
||||
(team.region ?? '').toLowerCase().includes(query) ||
|
||||
(team.languages ?? []).some((lang) => lang.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
}) : [];
|
||||
|
||||
// Group teams by skill level
|
||||
const teamsByLevel = useMemo(() => {
|
||||
@@ -97,7 +110,7 @@ export default function TeamsInteractive() {
|
||||
);
|
||||
}, [groupsBySkillLevel, filteredTeams]);
|
||||
|
||||
const recruitingCount = teams.filter((t) => t.isRecruiting).length;
|
||||
const recruitingCount = teams ? teams.filter((t) => t.isRecruiting).length : 0;
|
||||
|
||||
const handleSkillLevelClick = (level: SkillLevel) => {
|
||||
const element = document.getElementById(`level-${level}`);
|
||||
@@ -126,98 +139,104 @@ export default function TeamsInteractive() {
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-2 border-purple-400 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-gray-400">Loading teams...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
{/* Hero Section */}
|
||||
<TeamHeroSection
|
||||
teams={teams}
|
||||
teamsByLevel={teamsByLevel}
|
||||
recruitingCount={recruitingCount}
|
||||
onShowCreateForm={() => setShowCreateForm(true)}
|
||||
onBrowseTeams={handleBrowseTeams}
|
||||
onSkillLevelClick={handleSkillLevelClick}
|
||||
/>
|
||||
<StateContainer
|
||||
data={teams}
|
||||
isLoading={loading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'spinner', message: 'Loading teams...' },
|
||||
error: { variant: 'full-screen' },
|
||||
empty: {
|
||||
icon: Users,
|
||||
title: 'No teams yet',
|
||||
description: 'Be the first to create a racing team. Gather drivers and compete together in endurance events.',
|
||||
action: { label: 'Create Your First Team', onClick: () => setShowCreateForm(true) }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(teamsData) => (
|
||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
||||
{/* Hero Section */}
|
||||
<TeamHeroSection
|
||||
teams={teamsData}
|
||||
teamsByLevel={teamsByLevel}
|
||||
recruitingCount={recruitingCount}
|
||||
onShowCreateForm={() => setShowCreateForm(true)}
|
||||
onBrowseTeams={handleBrowseTeams}
|
||||
onSkillLevelClick={handleSkillLevelClick}
|
||||
/>
|
||||
|
||||
{/* Search Bar */}
|
||||
<TeamSearchBar searchQuery={searchQuery} onSearchChange={setSearchQuery} />
|
||||
{/* Search Bar */}
|
||||
<TeamSearchBar searchQuery={searchQuery} onSearchChange={setSearchQuery} />
|
||||
|
||||
{/* Why Join Section */}
|
||||
{!searchQuery && <WhyJoinTeamSection />}
|
||||
{/* Why Join Section */}
|
||||
{!searchQuery && <WhyJoinTeamSection />}
|
||||
|
||||
{/* Team Leaderboard Preview */}
|
||||
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={handleTeamClick} />}
|
||||
{/* Team Leaderboard Preview */}
|
||||
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={handleTeamClick} />}
|
||||
|
||||
{/* Featured Recruiting */}
|
||||
{!searchQuery && <FeaturedRecruiting teams={teams} onTeamClick={handleTeamClick} />}
|
||||
{/* Featured Recruiting */}
|
||||
{!searchQuery && <FeaturedRecruiting teams={teamsData} onTeamClick={handleTeamClick} />}
|
||||
|
||||
{/* Teams by Skill Level */}
|
||||
{teams.length === 0 ? (
|
||||
<Card className="text-center py-16">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-purple-500/10 border border-purple-500/20 mb-6">
|
||||
<Users className="w-8 h-8 text-purple-400" />
|
||||
{/* Teams by Skill Level */}
|
||||
{teamsData.length === 0 ? (
|
||||
<Card className="text-center py-16">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-purple-500/10 border border-purple-500/20 mb-6">
|
||||
<Users className="w-8 h-8 text-purple-400" />
|
||||
</div>
|
||||
<Heading level={2} className="text-2xl mb-3">
|
||||
No teams yet
|
||||
</Heading>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Be the first to create a racing team. Gather drivers and compete together in endurance events.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="flex items-center gap-2 mx-auto bg-purple-600 hover:bg-purple-500"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Create Your First Team
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : filteredTeams.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Search className="w-10 h-10 text-gray-600" />
|
||||
<p className="text-gray-400">No teams found matching "{searchQuery}"</p>
|
||||
<Button variant="secondary" onClick={() => setSearchQuery('')}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div>
|
||||
{SKILL_LEVELS.map((level, index) => (
|
||||
<div key={level} id={`level-${level}`} className="scroll-mt-8">
|
||||
<SkillLevelSection
|
||||
level={{
|
||||
id: level,
|
||||
label: level.charAt(0).toUpperCase() + level.slice(1),
|
||||
icon: level === 'pro' ? Crown : level === 'advanced' ? Star : level === 'intermediate' ? TrendingUp : Shield,
|
||||
color: level === 'pro' ? 'text-yellow-400' : level === 'advanced' ? 'text-purple-400' : level === 'intermediate' ? 'text-primary-blue' : 'text-green-400',
|
||||
bgColor: level === 'pro' ? 'bg-yellow-400/10' : level === 'advanced' ? 'bg-purple-400/10' : level === 'intermediate' ? 'bg-primary-blue/10' : 'bg-green-400/10',
|
||||
borderColor: level === 'pro' ? 'border-yellow-400/30' : level === 'advanced' ? 'border-purple-400/30' : level === 'intermediate' ? 'border-primary-blue/30' : 'border-green-400/30',
|
||||
description: level === 'pro' ? 'Elite competition, sponsored teams' : level === 'advanced' ? 'Competitive racing, high consistency' : level === 'intermediate' ? 'Growing skills, regular practice' : 'Learning the basics, friendly environment',
|
||||
}}
|
||||
teams={teamsByLevel[level] ?? []}
|
||||
onTeamClick={handleTeamClick}
|
||||
defaultExpanded={index === 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Heading level={2} className="text-2xl mb-3">
|
||||
No teams yet
|
||||
</Heading>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Be the first to create a racing team. Gather drivers and compete together in endurance events.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="flex items-center gap-2 mx-auto bg-purple-600 hover:bg-purple-500"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Create Your First Team
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : filteredTeams.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Search className="w-10 h-10 text-gray-600" />
|
||||
<p className="text-gray-400">No teams found matching "{searchQuery}"</p>
|
||||
<Button variant="secondary" onClick={() => setSearchQuery('')}>
|
||||
Clear search
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div>
|
||||
{SKILL_LEVELS.map((level, index) => (
|
||||
<div key={level} id={`level-${level}`} className="scroll-mt-8">
|
||||
<SkillLevelSection
|
||||
level={{
|
||||
id: level,
|
||||
label: level.charAt(0).toUpperCase() + level.slice(1),
|
||||
icon: level === 'pro' ? Crown : level === 'advanced' ? Star : level === 'intermediate' ? TrendingUp : Shield,
|
||||
color: level === 'pro' ? 'text-yellow-400' : level === 'advanced' ? 'text-purple-400' : level === 'intermediate' ? 'text-primary-blue' : 'text-green-400',
|
||||
bgColor: level === 'pro' ? 'bg-yellow-400/10' : level === 'advanced' ? 'bg-purple-400/10' : level === 'intermediate' ? 'bg-primary-blue/10' : 'bg-green-400/10',
|
||||
borderColor: level === 'pro' ? 'border-yellow-400/30' : level === 'advanced' ? 'border-purple-400/30' : level === 'intermediate' ? 'border-primary-blue/30' : 'border-green-400/30',
|
||||
description: level === 'pro' ? 'Elite competition, sponsored teams' : level === 'advanced' ? 'Competitive racing, high consistency' : level === 'intermediate' ? 'Growing skills, regular practice' : 'Learning the basics, friendly environment',
|
||||
}}
|
||||
teams={teamsByLevel[level] ?? []}
|
||||
onTeamClick={handleTeamClick}
|
||||
defaultExpanded={index === 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import TeamsTemplate from '@/templates/TeamsTemplate';
|
||||
|
||||
// This is a server component that fetches data server-side
|
||||
// It will be used by the page.tsx when server-side rendering is needed
|
||||
// This is a static component that receives data as props
|
||||
// It can be used in server components or parent components that fetch data
|
||||
// For client-side data fetching, use TeamsInteractive instead
|
||||
|
||||
interface TeamsStaticProps {
|
||||
teams: TeamSummaryViewModel[];
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
|
||||
import { useServices } from '@/lib/services/ServiceProvider';
|
||||
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
||||
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
|
||||
// Shared state components
|
||||
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||
|
||||
export default function TeamDetailInteractive() {
|
||||
@@ -17,43 +20,37 @@ export default function TeamDetailInteractive() {
|
||||
const router = useRouter();
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const [team, setTeam] = useState<TeamDetailsViewModel | null>(null);
|
||||
const [memberships, setMemberships] = useState<TeamMemberViewModel[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const loadTeamData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const teamDetails = await teamService.getTeamDetails(teamId, currentDriverId);
|
||||
// Fetch team details
|
||||
const { data: teamDetails, isLoading: teamLoading, error: teamError, retry: teamRetry } = useDataFetching({
|
||||
queryKey: ['teamDetails', teamId, currentDriverId],
|
||||
queryFn: () => teamService.getTeamDetails(teamId, currentDriverId),
|
||||
});
|
||||
|
||||
if (!teamDetails) {
|
||||
setTeam(null);
|
||||
setMemberships([]);
|
||||
setIsAdmin(false);
|
||||
return;
|
||||
}
|
||||
// Fetch team members
|
||||
const { data: memberships, isLoading: membersLoading, error: membersError, retry: membersRetry } = useDataFetching({
|
||||
queryKey: ['teamMembers', teamId, currentDriverId],
|
||||
queryFn: async () => {
|
||||
if (!teamDetails?.ownerId) return [];
|
||||
return teamService.getTeamMembers(teamId, currentDriverId, teamDetails.ownerId);
|
||||
},
|
||||
enabled: !!teamDetails?.ownerId,
|
||||
});
|
||||
|
||||
const teamMembers = await teamService.getTeamMembers(teamId, currentDriverId, teamDetails.ownerId);
|
||||
const isLoading = teamLoading || membersLoading;
|
||||
const error = teamError || membersError;
|
||||
const retry = async () => {
|
||||
await teamRetry();
|
||||
await membersRetry();
|
||||
};
|
||||
|
||||
const adminStatus = teamDetails.isOwner ||
|
||||
teamMembers.some((m) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
|
||||
|
||||
setTeam(teamDetails);
|
||||
setMemberships(teamMembers);
|
||||
setIsAdmin(adminStatus);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [teamId, currentDriverId, teamService]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTeamData();
|
||||
}, [loadTeamData]);
|
||||
// Determine admin status
|
||||
const isAdmin = teamDetails?.isOwner ||
|
||||
(memberships || []).some((m: any) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
|
||||
|
||||
const handleUpdate = () => {
|
||||
loadTeamData();
|
||||
retry();
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (driverId: string) => {
|
||||
@@ -111,17 +108,36 @@ export default function TeamDetailInteractive() {
|
||||
};
|
||||
|
||||
return (
|
||||
<TeamDetailTemplate
|
||||
team={team}
|
||||
memberships={memberships}
|
||||
activeTab={activeTab}
|
||||
loading={loading}
|
||||
isAdmin={isAdmin}
|
||||
onTabChange={setActiveTab}
|
||||
onUpdate={handleUpdate}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onChangeRole={handleChangeRole}
|
||||
onGoBack={handleGoBack}
|
||||
/>
|
||||
<StateContainer
|
||||
data={teamDetails}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'skeleton', message: 'Loading team details...' },
|
||||
error: { variant: 'full-screen' },
|
||||
empty: {
|
||||
icon: Users,
|
||||
title: 'Team not found',
|
||||
description: 'The team may have been deleted or you may not have access',
|
||||
action: { label: 'Back to Teams', onClick: () => router.push('/teams') }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(teamData) => (
|
||||
<TeamDetailTemplate
|
||||
team={teamData}
|
||||
memberships={memberships || []}
|
||||
activeTab={activeTab}
|
||||
loading={isLoading}
|
||||
isAdmin={isAdmin}
|
||||
onTabChange={setActiveTab}
|
||||
onUpdate={handleUpdate}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
onChangeRole={handleChangeRole}
|
||||
onGoBack={handleGoBack}
|
||||
/>
|
||||
)}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user