page wrapper
This commit is contained in:
@@ -1,237 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Users, Search, Sparkles, Crown, Star, TrendingUp, Shield } from 'lucide-react';
|
||||
import TeamsTemplate from '@/templates/TeamsTemplate';
|
||||
import TeamHeroSection from '@/components/teams/TeamHeroSection';
|
||||
import TeamSearchBar from '@/components/teams/TeamSearchBar';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Heading from '@/components/ui/Heading';
|
||||
import CreateTeamForm from '@/components/teams/CreateTeamForm';
|
||||
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 type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
// Shared state components
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useAllTeams } from '@/hooks/team/useAllTeams';
|
||||
|
||||
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, error, retry } = useAllTeams();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
// Derive groups by skill level from the loaded teams
|
||||
const groupsBySkillLevel = useMemo(() => {
|
||||
const byLevel: Record<string, typeof teams> = {
|
||||
beginner: [],
|
||||
intermediate: [],
|
||||
advanced: [],
|
||||
pro: [],
|
||||
};
|
||||
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.
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return sortedByRating.slice(0, 5);
|
||||
}, [teams]);
|
||||
|
||||
const handleTeamClick = (teamId: string) => {
|
||||
if (teamId.startsWith('demo-team-')) {
|
||||
return;
|
||||
}
|
||||
router.push(`/teams/${teamId}`);
|
||||
};
|
||||
|
||||
const handleCreateSuccess = (teamId: string) => {
|
||||
setShowCreateForm(false);
|
||||
router.push(`/teams/${teamId}`);
|
||||
};
|
||||
|
||||
// Filter by search query
|
||||
const filteredTeams = teams ? teams.filter((team) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
team.name.toLowerCase().includes(query) ||
|
||||
(team.description ?? '').toLowerCase().includes(query) ||
|
||||
(team.region ?? '').toLowerCase().includes(query) ||
|
||||
(team.languages ?? []).some((lang) => lang.toLowerCase().includes(query))
|
||||
);
|
||||
}) : [];
|
||||
|
||||
// Group teams by skill level
|
||||
const teamsByLevel = useMemo(() => {
|
||||
return SKILL_LEVELS.reduce(
|
||||
(acc, level) => {
|
||||
const fromGroup = groupsBySkillLevel[level] ?? [];
|
||||
acc[level] = filteredTeams.filter((team) =>
|
||||
fromGroup.some((groupTeam) => groupTeam.id === team.id),
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
beginner: [],
|
||||
intermediate: [],
|
||||
advanced: [],
|
||||
pro: [],
|
||||
} as Record<string, TeamSummaryViewModel[]>,
|
||||
);
|
||||
}, [groupsBySkillLevel, filteredTeams]);
|
||||
|
||||
const recruitingCount = teams ? teams.filter((t) => t.isRecruiting).length : 0;
|
||||
|
||||
const handleSkillLevelClick = (level: SkillLevel) => {
|
||||
const element = document.getElementById(`level-${level}`);
|
||||
element?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
const handleBrowseTeams = () => {
|
||||
const element = document.getElementById('teams-list');
|
||||
element?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (showCreateForm) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="mb-6">
|
||||
<Button variant="secondary" onClick={() => setShowCreateForm(false)}>
|
||||
← Back to Teams
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">Create New Team</h2>
|
||||
<CreateTeamForm onCancel={() => setShowCreateForm(false)} onSuccess={handleCreateSuccess} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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} />
|
||||
|
||||
{/* Why Join Section */}
|
||||
{!searchQuery && <WhyJoinTeamSection />}
|
||||
|
||||
{/* Team Leaderboard Preview */}
|
||||
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={handleTeamClick} />}
|
||||
|
||||
{/* Featured Recruiting */}
|
||||
{!searchQuery && <FeaturedRecruiting teams={teamsData} onTeamClick={handleTeamClick} />}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StateContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import TeamsTemplate from '@/templates/TeamsTemplate';
|
||||
|
||||
// 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[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function TeamsStatic({ teams, isLoading = false }: TeamsStaticProps) {
|
||||
// Calculate derived data that would normally be done in the template
|
||||
const teamsBySkillLevel = teams.reduce(
|
||||
(acc, team) => {
|
||||
const level = team.performanceLevel || 'intermediate';
|
||||
if (!acc[level]) {
|
||||
acc[level] = [];
|
||||
}
|
||||
acc[level].push(team);
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
beginner: [],
|
||||
intermediate: [],
|
||||
advanced: [],
|
||||
pro: [],
|
||||
} as Record<string, TeamSummaryViewModel[]>,
|
||||
);
|
||||
|
||||
const topTeams = [...teams]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, 5);
|
||||
|
||||
const recruitingCount = teams.filter((t) => t.isRecruiting).length;
|
||||
|
||||
// For static rendering, we don't have interactive state
|
||||
// So we pass empty values and handlers that won't be used
|
||||
return (
|
||||
<TeamsTemplate
|
||||
teams={teams}
|
||||
isLoading={isLoading}
|
||||
searchQuery=""
|
||||
showCreateForm={false}
|
||||
teamsByLevel={teamsBySkillLevel}
|
||||
topTeams={topTeams}
|
||||
recruitingCount={recruitingCount}
|
||||
filteredTeams={teams}
|
||||
onSearchChange={() => {}}
|
||||
onShowCreateForm={() => {}}
|
||||
onHideCreateForm={() => {}}
|
||||
onTeamClick={() => {}}
|
||||
onCreateSuccess={() => {}}
|
||||
onBrowseTeams={() => {}}
|
||||
onSkillLevelClick={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
|
||||
// Shared state components
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { useTeamDetails } from '@/hooks/team/useTeamDetails';
|
||||
import { useTeamMembers } from '@/hooks/team/useTeamMembers';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||
|
||||
export default function TeamDetailInteractive() {
|
||||
const params = useParams();
|
||||
const teamId = params.id as string;
|
||||
const router = useRouter();
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const teamService = useInject(TEAM_SERVICE_TOKEN);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
|
||||
// Fetch team details using DI + React-Query
|
||||
const { data: teamDetails, isLoading: teamLoading, error: teamError, retry: teamRetry } = useTeamDetails(teamId, currentDriverId);
|
||||
|
||||
// Fetch team members using DI + React-Query
|
||||
const { data: memberships, isLoading: membersLoading, error: membersError, retry: membersRetry } = useTeamMembers(
|
||||
teamId,
|
||||
currentDriverId,
|
||||
teamDetails?.ownerId || ''
|
||||
);
|
||||
|
||||
const isLoading = teamLoading || membersLoading;
|
||||
const error = teamError || membersError;
|
||||
const retry = async () => {
|
||||
await teamRetry();
|
||||
await membersRetry();
|
||||
};
|
||||
|
||||
// Determine admin status
|
||||
const isAdmin = teamDetails?.isOwner ||
|
||||
(memberships || []).some((m: any) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
|
||||
|
||||
const handleUpdate = () => {
|
||||
retry();
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (driverId: string) => {
|
||||
if (!confirm('Are you sure you want to remove this member?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const performer = await teamService.getMembership(teamId, currentDriverId);
|
||||
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
|
||||
throw new Error('Only owners or admins can remove members');
|
||||
}
|
||||
|
||||
const membership = await teamService.getMembership(teamId, driverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot remove the team owner');
|
||||
}
|
||||
|
||||
await teamService.removeMembership(teamId, driverId);
|
||||
handleUpdate();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to remove member');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeRole = async (driverId: string, newRole: 'owner' | 'admin' | 'member') => {
|
||||
try {
|
||||
const performer = await teamService.getMembership(teamId, currentDriverId);
|
||||
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
|
||||
throw new Error('Only owners or admins can update roles');
|
||||
}
|
||||
|
||||
const membership = await teamService.getMembership(teamId, driverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot change the owner role');
|
||||
}
|
||||
|
||||
// Convert 'admin' to 'manager' for the service
|
||||
const serviceRole = newRole === 'admin' ? 'manager' : newRole;
|
||||
await teamService.updateMembership(teamId, driverId, serviceRole);
|
||||
handleUpdate();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to change role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
|
||||
import type { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
||||
import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
||||
|
||||
// This is a server component that can be used for static rendering
|
||||
// It receives pre-fetched data and renders the template
|
||||
|
||||
interface TeamDetailStaticProps {
|
||||
team: TeamDetailsViewModel | null;
|
||||
memberships: TeamMemberViewModel[];
|
||||
currentDriverId: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function TeamDetailStatic({
|
||||
team,
|
||||
memberships,
|
||||
currentDriverId,
|
||||
isLoading = false
|
||||
}: TeamDetailStaticProps) {
|
||||
// Determine admin status
|
||||
const isAdmin = team ? (
|
||||
team.isOwner ||
|
||||
memberships.some((m) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'))
|
||||
) : false;
|
||||
|
||||
// For static rendering, we don't have interactive state
|
||||
// So we pass empty values and handlers that won't be used
|
||||
return (
|
||||
<TeamDetailTemplate
|
||||
team={team}
|
||||
memberships={memberships}
|
||||
activeTab="overview"
|
||||
loading={isLoading}
|
||||
isAdmin={isAdmin}
|
||||
onTabChange={() => {}}
|
||||
onUpdate={() => {}}
|
||||
onRemoveMember={() => {}}
|
||||
onChangeRole={() => {}}
|
||||
onGoBack={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,102 @@
|
||||
import TeamDetailInteractive from './TeamDetailInteractive';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||
import { TeamService } from '@/lib/services/teams/TeamService';
|
||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
||||
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
||||
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
|
||||
|
||||
export default TeamDetailInteractive;
|
||||
// Template wrapper to adapt TeamDetailTemplate for SSR
|
||||
interface TeamDetailData {
|
||||
team: TeamDetailsViewModel;
|
||||
memberships: TeamMemberViewModel[];
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
function TeamDetailTemplateWrapper({ data }: { data: TeamDetailData }) {
|
||||
return (
|
||||
<TeamDetailTemplate
|
||||
team={data.team}
|
||||
memberships={data.memberships}
|
||||
activeTab="overview"
|
||||
loading={false}
|
||||
isAdmin={data.isAdmin}
|
||||
// Event handlers are no-ops for SSR (client will handle real interactions)
|
||||
onTabChange={() => {}}
|
||||
onUpdate={() => {}}
|
||||
onRemoveMember={() => {}}
|
||||
onChangeRole={() => {}}
|
||||
onGoBack={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: { id: string } }) {
|
||||
// Validate params
|
||||
if (!params.id) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Fetch data using PageDataFetcher.fetchManual
|
||||
const data = await PageDataFetcher.fetchManual(async () => {
|
||||
// Manual dependency creation
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
// Create API client
|
||||
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
// Create service
|
||||
const service = new TeamService(teamsApiClient);
|
||||
|
||||
// For server-side, we need a current driver ID
|
||||
// This would typically come from session, but for server components we'll use a placeholder
|
||||
const currentDriverId = ''; // Placeholder - would need session handling
|
||||
|
||||
// Fetch team details
|
||||
const teamData = await service.getTeamDetails(params.id, currentDriverId);
|
||||
|
||||
if (!teamData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch team members
|
||||
const membersData = await service.getTeamMembers(params.id, currentDriverId, teamData.ownerId || '');
|
||||
|
||||
// Determine admin status
|
||||
const isAdmin = teamData.isOwner ||
|
||||
(membersData || []).some((m: any) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
|
||||
|
||||
return {
|
||||
team: teamData,
|
||||
memberships: membersData || [],
|
||||
isAdmin,
|
||||
};
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={data}
|
||||
Template={TeamDetailTemplateWrapper}
|
||||
loading={{ variant: 'skeleton', message: 'Loading team details...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
title: 'Team not found',
|
||||
description: 'The team you are looking for does not exist or has been removed.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import TeamLeaderboardTemplate from '@/templates/TeamLeaderboardTemplate';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||
|
||||
interface TeamLeaderboardInteractiveProps {
|
||||
teams: TeamSummaryViewModel[];
|
||||
}
|
||||
|
||||
export default function TeamLeaderboardInteractive({ teams }: TeamLeaderboardInteractiveProps) {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
|
||||
const [sortBy, setSortBy] = useState<SortBy>('rating');
|
||||
|
||||
const handleTeamClick = (teamId: string) => {
|
||||
if (teamId.startsWith('demo-team-')) {
|
||||
return;
|
||||
}
|
||||
router.push(`/teams/${teamId}`);
|
||||
};
|
||||
|
||||
const handleBackToTeams = () => {
|
||||
router.push('/teams');
|
||||
};
|
||||
|
||||
return (
|
||||
<TeamLeaderboardTemplate
|
||||
teams={teams}
|
||||
searchQuery={searchQuery}
|
||||
filterLevel={filterLevel}
|
||||
sortBy={sortBy}
|
||||
onSearchChange={setSearchQuery}
|
||||
onFilterLevelChange={setFilterLevel}
|
||||
onSortChange={setSortBy}
|
||||
onTeamClick={handleTeamClick}
|
||||
onBackToTeams={handleBackToTeams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ServiceFactory } from '@/lib/services/ServiceFactory';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import TeamLeaderboardInteractive from './TeamLeaderboardInteractive';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
// ============================================================================
|
||||
// SERVER COMPONENT - Fetches data and passes to Interactive wrapper
|
||||
// ============================================================================
|
||||
|
||||
export default async function TeamLeaderboardStatic() {
|
||||
// Create services for server-side data fetching
|
||||
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
|
||||
const teamService = serviceFactory.createTeamService();
|
||||
|
||||
// Fetch data server-side
|
||||
let teams: TeamSummaryViewModel[] = [];
|
||||
|
||||
try {
|
||||
teams = await teamService.getAllTeams();
|
||||
} catch (error) {
|
||||
console.error('Failed to load team leaderboard:', error);
|
||||
teams = [];
|
||||
}
|
||||
|
||||
// Pass data to Interactive wrapper which handles client-side interactions
|
||||
return <TeamLeaderboardInteractive teams={teams} />;
|
||||
}
|
||||
@@ -1,9 +1,97 @@
|
||||
import TeamLeaderboardStatic from './TeamLeaderboardStatic';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import TeamLeaderboardTemplate from '@/templates/TeamLeaderboardTemplate';
|
||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { TeamService } from '@/lib/services/teams/TeamService';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { redirect , useRouter } from 'next/navigation';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
import { useState } from 'react';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
||||
|
||||
// ============================================================================
|
||||
// WRAPPER COMPONENT (Client-side state management)
|
||||
// ============================================================================
|
||||
|
||||
function TeamLeaderboardPageWrapper({ data }: { data: TeamSummaryViewModel[] | null }) {
|
||||
const router = useRouter();
|
||||
|
||||
// Client-side state for filtering and sorting
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
|
||||
const [sortBy, setSortBy] = useState<SortBy>('rating');
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleTeamClick = (teamId: string) => {
|
||||
router.push(`/teams/${teamId}`);
|
||||
};
|
||||
|
||||
const handleBackToTeams = () => {
|
||||
router.push('/teams');
|
||||
};
|
||||
|
||||
return (
|
||||
<TeamLeaderboardTemplate
|
||||
teams={data}
|
||||
searchQuery={searchQuery}
|
||||
filterLevel={filterLevel}
|
||||
sortBy={sortBy}
|
||||
onSearchChange={setSearchQuery}
|
||||
onFilterLevelChange={setFilterLevel}
|
||||
onSortChange={setSortBy}
|
||||
onTeamClick={handleTeamClick}
|
||||
onBackToTeams={handleBackToTeams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN PAGE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export default function TeamLeaderboardPage() {
|
||||
return <TeamLeaderboardStatic />;
|
||||
export default async function TeamLeaderboardPage() {
|
||||
// Fetch data using PageDataFetcher
|
||||
const teamsData = await PageDataFetcher.fetch<TeamService, 'getAllTeams'>(
|
||||
TEAM_SERVICE_TOKEN,
|
||||
'getAllTeams'
|
||||
);
|
||||
|
||||
// Prepare data for template
|
||||
const data: TeamSummaryViewModel[] | null = teamsData as TeamSummaryViewModel[] | null;
|
||||
|
||||
const hasData = (teamsData as any)?.length > 0;
|
||||
|
||||
// Handle loading state (should be fast since we're using async/await)
|
||||
const isLoading = false;
|
||||
const error = null;
|
||||
const retry = async () => {
|
||||
// In server components, we can't retry without a reload
|
||||
redirect('/teams/leaderboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={hasData ? data : null}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
Template={TeamLeaderboardPageWrapper}
|
||||
loading={{ variant: 'full-screen', message: 'Loading team leaderboard...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
icon: Trophy,
|
||||
title: 'No teams found',
|
||||
description: 'There are no teams in the system yet.',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,97 @@
|
||||
import TeamsInteractive from './TeamsInteractive';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import TeamsTemplate from '@/templates/TeamsTemplate';
|
||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||
import { TeamService } from '@/lib/services/teams/TeamService';
|
||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
export default TeamsInteractive;
|
||||
// Helper to compute derived data for SSR
|
||||
function computeDerivedData(teams: TeamSummaryViewModel[]) {
|
||||
// Group teams by performance level (skill level)
|
||||
const teamsByLevel = teams.reduce((acc, team) => {
|
||||
const level = team.performanceLevel || 'intermediate';
|
||||
if (!acc[level]) {
|
||||
acc[level] = [];
|
||||
}
|
||||
acc[level].push(team);
|
||||
return acc;
|
||||
}, {} as Record<string, TeamSummaryViewModel[]>);
|
||||
|
||||
// Get top teams (by rating, descending)
|
||||
const topTeams = [...teams]
|
||||
.filter(t => t.rating !== undefined)
|
||||
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
|
||||
.slice(0, 5);
|
||||
|
||||
// Count recruiting teams
|
||||
const recruitingCount = teams.filter(t => t.isRecruiting).length;
|
||||
|
||||
// For SSR, filtered teams = all teams (no search filter applied server-side)
|
||||
const filteredTeams = teams;
|
||||
|
||||
return {
|
||||
teamsByLevel,
|
||||
topTeams,
|
||||
recruitingCount,
|
||||
filteredTeams,
|
||||
};
|
||||
}
|
||||
|
||||
// Template wrapper for SSR
|
||||
function TeamsTemplateWrapper({ data }: { data: TeamSummaryViewModel[] }) {
|
||||
const derived = computeDerivedData(data);
|
||||
|
||||
// Provide default values for SSR
|
||||
// The template will handle client-side state management
|
||||
return (
|
||||
<TeamsTemplate
|
||||
teams={data}
|
||||
isLoading={false}
|
||||
searchQuery=""
|
||||
showCreateForm={false}
|
||||
teamsByLevel={derived.teamsByLevel}
|
||||
topTeams={derived.topTeams}
|
||||
recruitingCount={derived.recruitingCount}
|
||||
filteredTeams={derived.filteredTeams}
|
||||
// No-op handlers for SSR (client will override)
|
||||
onSearchChange={() => {}}
|
||||
onShowCreateForm={() => {}}
|
||||
onHideCreateForm={() => {}}
|
||||
onTeamClick={() => {}}
|
||||
onCreateSuccess={() => {}}
|
||||
onBrowseTeams={() => {}}
|
||||
onSkillLevelClick={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const data = await PageDataFetcher.fetchManual(async () => {
|
||||
// Manual dependency creation
|
||||
const baseUrl = getWebsiteApiBaseUrl();
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: true,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
// Create API client
|
||||
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
|
||||
|
||||
// Create service
|
||||
const service = new TeamService(teamsApiClient);
|
||||
|
||||
return await service.getAllTeams();
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <PageWrapper data={data} Template={TeamsTemplateWrapper} />;
|
||||
}
|
||||
Reference in New Issue
Block a user