website refactor

This commit is contained in:
2026-01-21 22:36:01 +01:00
parent ea58909070
commit 5ed958281d
49 changed files with 8763 additions and 131 deletions

View File

@@ -0,0 +1,398 @@
import { describe, expect, it, vi } from 'vitest';
import { LeagueController } from './LeagueController';
import { LeagueService } from './LeagueService';
describe('LeagueController - Detail Endpoints', () => {
let controller: LeagueController;
let mockService: ReturnType<typeof vi.mocked<LeagueService>>;
beforeEach(() => {
mockService = {
getLeagueOwnerSummary: vi.fn(),
getLeagueSeasons: vi.fn(),
getLeagueStats: vi.fn(),
getLeagueMemberships: vi.fn(),
} as never;
controller = new LeagueController(mockService);
});
describe('getLeague', () => {
it('should return league details by ID', async () => {
const mockResult = {
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'John Doe',
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
},
rating: 1500,
rank: 10,
};
mockService.getLeagueOwnerSummary.mockResolvedValue(mockResult as never);
const result = await controller.getLeague('league-1');
expect(result).toEqual(mockResult);
expect(mockService.getLeagueOwnerSummary).toHaveBeenCalledWith({
ownerId: 'unknown',
leagueId: 'league-1',
});
});
it('should handle league not found gracefully', async () => {
mockService.getLeagueOwnerSummary.mockRejectedValue(new Error('League not found'));
await expect(controller.getLeague('non-existent-league')).rejects.toThrow('League not found');
});
it('should return league with minimal information', async () => {
const mockResult = {
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'Simple Driver',
country: 'DE',
joinedAt: '2024-01-01T00:00:00Z',
},
rating: null,
rank: null,
};
mockService.getLeagueOwnerSummary.mockResolvedValue(mockResult as never);
const result = await controller.getLeague('league-1');
expect(result).toEqual(mockResult);
expect(result.driver.name).toBe('Simple Driver');
expect(result.rating).toBeNull();
});
});
describe('getLeagueSeasons', () => {
it('should return seasons for a league', async () => {
const mockResult = [
{
seasonId: 'season-1',
name: 'Season 1',
status: 'active',
startDate: new Date('2024-01-01'),
endDate: new Date('2024-06-30'),
isPrimary: true,
isParallelActive: false,
totalRaces: 12,
completedRaces: 6,
nextRaceAt: new Date('2024-03-15'),
},
{
seasonId: 'season-2',
name: 'Season 2',
status: 'upcoming',
startDate: new Date('2024-07-01'),
endDate: new Date('2024-12-31'),
isPrimary: false,
isParallelActive: false,
totalRaces: 12,
completedRaces: 0,
},
];
mockService.getLeagueSeasons.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueSeasons('league-1');
expect(result).toEqual(mockResult);
expect(mockService.getLeagueSeasons).toHaveBeenCalledWith({ leagueId: 'league-1' });
});
it('should return empty array when league has no seasons', async () => {
const mockResult: never[] = [];
mockService.getLeagueSeasons.mockResolvedValue(mockResult);
const result = await controller.getLeagueSeasons('league-1');
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
it('should handle league with single season', async () => {
const mockResult = [
{
seasonId: 'season-1',
name: 'Season 1',
status: 'active',
startDate: new Date('2024-01-01'),
endDate: new Date('2024-12-31'),
isPrimary: true,
isParallelActive: false,
totalRaces: 24,
completedRaces: 12,
nextRaceAt: new Date('2024-06-15'),
},
];
mockService.getLeagueSeasons.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueSeasons('league-1');
expect(result).toEqual(mockResult);
expect(result).toHaveLength(1);
expect(result[0]?.totalRaces).toBe(24);
});
it('should handle seasons with different statuses', async () => {
const mockResult = [
{
seasonId: 'season-1',
name: 'Season 1',
status: 'completed',
startDate: new Date('2024-01-01'),
endDate: new Date('2024-06-30'),
isPrimary: true,
isParallelActive: false,
totalRaces: 12,
completedRaces: 12,
},
{
seasonId: 'season-2',
name: 'Season 2',
status: 'active',
startDate: new Date('2024-07-01'),
endDate: new Date('2024-12-31'),
isPrimary: false,
isParallelActive: false,
totalRaces: 12,
completedRaces: 6,
nextRaceAt: new Date('2024-10-15'),
},
{
seasonId: 'season-3',
name: 'Season 3',
status: 'upcoming',
startDate: new Date('2025-01-01'),
endDate: new Date('2025-06-30'),
isPrimary: false,
isParallelActive: false,
totalRaces: 12,
completedRaces: 0,
},
];
mockService.getLeagueSeasons.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueSeasons('league-1');
expect(result).toEqual(mockResult);
expect(result).toHaveLength(3);
expect(result[0]?.status).toBe('completed');
expect(result[1]?.status).toBe('active');
expect(result[2]?.status).toBe('upcoming');
});
});
describe('getLeagueStats', () => {
it('should return league statistics', async () => {
const mockResult = {
totalMembers: 25,
totalRaces: 150,
averageRating: 1450.5,
};
mockService.getLeagueStats.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueStats('league-1');
expect(result).toEqual(mockResult);
expect(mockService.getLeagueStats).toHaveBeenCalledWith('league-1');
});
it('should return empty stats for new league', async () => {
const mockResult = {
totalMembers: 0,
totalRaces: 0,
averageRating: 0,
};
mockService.getLeagueStats.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueStats('league-1');
expect(result).toEqual(mockResult);
expect(result.totalMembers).toBe(0);
expect(result.totalRaces).toBe(0);
});
it('should handle league with extensive statistics', async () => {
const mockResult = {
totalMembers: 100,
totalRaces: 500,
averageRating: 1650.75,
};
mockService.getLeagueStats.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueStats('league-1');
expect(result).toEqual(mockResult);
expect(result.totalRaces).toBe(500);
expect(result.totalMembers).toBe(100);
});
});
describe('getLeagueMemberships', () => {
it('should return league memberships', async () => {
const mockResult = {
members: [
{
driverId: 'driver-1',
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'John Doe',
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
},
role: 'owner',
joinedAt: '2024-01-01T00:00:00Z',
},
{
driverId: 'driver-2',
driver: {
id: 'driver-2',
iracingId: '67890',
name: 'Jane Smith',
country: 'UK',
joinedAt: '2024-01-15T00:00:00Z',
},
role: 'admin',
joinedAt: '2024-01-15T00:00:00Z',
},
{
driverId: 'driver-3',
driver: {
id: 'driver-3',
iracingId: '11111',
name: 'Bob Johnson',
country: 'CA',
joinedAt: '2024-02-01T00:00:00Z',
},
role: 'member',
joinedAt: '2024-02-01T00:00:00Z',
},
],
};
mockService.getLeagueMemberships.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueMemberships('league-1');
expect(result).toEqual(mockResult);
expect(mockService.getLeagueMemberships).toHaveBeenCalledWith('league-1');
});
it('should return empty memberships for league with no members', async () => {
const mockResult = { members: [] };
mockService.getLeagueMemberships.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueMemberships('league-1');
expect(result).toEqual(mockResult);
expect(result.members).toHaveLength(0);
});
it('should handle league with only owner', async () => {
const mockResult = {
members: [
{
driverId: 'driver-1',
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'Owner',
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
},
role: 'owner',
joinedAt: '2024-01-01T00:00:00Z',
},
],
};
mockService.getLeagueMemberships.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueMemberships('league-1');
expect(result).toEqual(mockResult);
expect(result.members).toHaveLength(1);
expect(result.members[0]?.role).toBe('owner');
});
it('should handle league with mixed roles', async () => {
const mockResult = {
members: [
{
driverId: 'driver-1',
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'Owner',
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
},
role: 'owner',
joinedAt: '2024-01-01T00:00:00Z',
},
{
driverId: 'driver-2',
driver: {
id: 'driver-2',
iracingId: '67890',
name: 'Admin 1',
country: 'UK',
joinedAt: '2024-01-02T00:00:00Z',
},
role: 'admin',
joinedAt: '2024-01-02T00:00:00Z',
},
{
driverId: 'driver-3',
driver: {
id: 'driver-3',
iracingId: '11111',
name: 'Admin 2',
country: 'CA',
joinedAt: '2024-01-03T00:00:00Z',
},
role: 'admin',
joinedAt: '2024-01-03T00:00:00Z',
},
{
driverId: 'driver-4',
driver: {
id: 'driver-4',
iracingId: '22222',
name: 'Member 1',
country: 'DE',
joinedAt: '2024-01-04T00:00:00Z',
},
role: 'member',
joinedAt: '2024-01-04T00:00:00Z',
},
{
driverId: 'driver-5',
driver: {
id: 'driver-5',
iracingId: '33333',
name: 'Member 2',
country: 'FR',
joinedAt: '2024-01-05T00:00:00Z',
},
role: 'member',
joinedAt: '2024-01-05T00:00:00Z',
},
],
};
mockService.getLeagueMemberships.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueMemberships('league-1');
expect(result).toEqual(mockResult);
expect(result.members).toHaveLength(5);
expect(result.members.filter(m => m.role === 'owner')).toHaveLength(1);
expect(result.members.filter(m => m.role === 'admin')).toHaveLength(2);
expect(result.members.filter(m => m.role === 'member')).toHaveLength(2);
});
});
});

View File

@@ -0,0 +1,205 @@
import { describe, expect, it, vi } from 'vitest';
import { LeagueController } from './LeagueController';
import { LeagueService } from './LeagueService';
describe('LeagueController - Discovery Endpoints', () => {
let controller: LeagueController;
let mockService: ReturnType<typeof vi.mocked<LeagueService>>;
beforeEach(() => {
mockService = {
getAllLeaguesWithCapacity: vi.fn(),
getAllLeaguesWithCapacityAndScoring: vi.fn(),
getTotalLeagues: vi.fn(),
} as never;
controller = new LeagueController(mockService);
});
describe('getAllLeaguesWithCapacity', () => {
it('should return leagues with capacity information', async () => {
const mockResult = {
leagues: [
{
id: 'league-1',
name: 'GT3 Masters',
description: 'A GT3 racing league',
ownerId: 'owner-1',
maxDrivers: 32,
currentDrivers: 25,
isPublic: true,
},
],
totalCount: 1,
};
mockService.getAllLeaguesWithCapacity.mockResolvedValue(mockResult as never);
const result = await controller.getAllLeaguesWithCapacity();
expect(result).toEqual(mockResult);
expect(mockService.getAllLeaguesWithCapacity).toHaveBeenCalledTimes(1);
});
it('should return empty array when no leagues exist', async () => {
const mockResult = { leagues: [], totalCount: 0 };
mockService.getAllLeaguesWithCapacity.mockResolvedValue(mockResult as never);
const result = await controller.getAllLeaguesWithCapacity();
expect(result).toEqual(mockResult);
expect(result.leagues).toHaveLength(0);
expect(result.totalCount).toBe(0);
});
it('should handle multiple leagues with different capacities', async () => {
const mockResult = {
leagues: [
{
id: 'league-1',
name: 'Small League',
description: 'Small league',
ownerId: 'owner-1',
maxDrivers: 10,
currentDrivers: 8,
isPublic: true,
},
{
id: 'league-2',
name: 'Large League',
description: 'Large league',
ownerId: 'owner-2',
maxDrivers: 50,
currentDrivers: 45,
isPublic: true,
},
],
totalCount: 2,
};
mockService.getAllLeaguesWithCapacity.mockResolvedValue(mockResult as never);
const result = await controller.getAllLeaguesWithCapacity();
expect(result).toEqual(mockResult);
expect(result.leagues).toHaveLength(2);
expect(result.leagues[0]?.maxDrivers).toBe(10);
expect(result.leagues[1]?.maxDrivers).toBe(50);
});
});
describe('getAllLeaguesWithCapacityAndScoring', () => {
it('should return leagues with capacity and scoring information', async () => {
const mockResult = {
leagues: [
{
id: 'league-1',
name: 'GT3 Masters',
description: 'A GT3 racing league',
ownerId: 'owner-1',
maxDrivers: 32,
currentDrivers: 25,
isPublic: true,
scoringConfig: {
pointsSystem: 'standard',
pointsPerRace: 25,
bonusPoints: true,
},
},
],
totalCount: 1,
};
mockService.getAllLeaguesWithCapacityAndScoring.mockResolvedValue(mockResult as never);
const result = await controller.getAllLeaguesWithCapacityAndScoring();
expect(result).toEqual(mockResult);
expect(mockService.getAllLeaguesWithCapacityAndScoring).toHaveBeenCalledTimes(1);
});
it('should return empty array when no leagues exist', async () => {
const mockResult = { leagues: [], totalCount: 0 };
mockService.getAllLeaguesWithCapacityAndScoring.mockResolvedValue(mockResult as never);
const result = await controller.getAllLeaguesWithCapacityAndScoring();
expect(result).toEqual(mockResult);
expect(result.leagues).toHaveLength(0);
expect(result.totalCount).toBe(0);
});
it('should handle leagues with different scoring configurations', async () => {
const mockResult = {
leagues: [
{
id: 'league-1',
name: 'Standard League',
description: 'Standard scoring',
ownerId: 'owner-1',
maxDrivers: 32,
currentDrivers: 20,
isPublic: true,
scoringConfig: {
pointsSystem: 'standard',
pointsPerRace: 25,
bonusPoints: true,
},
},
{
id: 'league-2',
name: 'Custom League',
description: 'Custom scoring',
ownerId: 'owner-2',
maxDrivers: 20,
currentDrivers: 15,
isPublic: true,
scoringConfig: {
pointsSystem: 'custom',
pointsPerRace: 50,
bonusPoints: false,
},
},
],
totalCount: 2,
};
mockService.getAllLeaguesWithCapacityAndScoring.mockResolvedValue(mockResult as never);
const result = await controller.getAllLeaguesWithCapacityAndScoring();
expect(result).toEqual(mockResult);
expect(result.leagues).toHaveLength(2);
expect(result.leagues[0]?.scoringConfig.pointsSystem).toBe('standard');
expect(result.leagues[1]?.scoringConfig.pointsSystem).toBe('custom');
});
});
describe('getTotalLeagues', () => {
it('should return total leagues count', async () => {
const mockResult = { totalLeagues: 42 };
mockService.getTotalLeagues.mockResolvedValue(mockResult as never);
const result = await controller.getTotalLeagues();
expect(result).toEqual(mockResult);
expect(mockService.getTotalLeagues).toHaveBeenCalledTimes(1);
});
it('should return zero when no leagues exist', async () => {
const mockResult = { totalLeagues: 0 };
mockService.getTotalLeagues.mockResolvedValue(mockResult as never);
const result = await controller.getTotalLeagues();
expect(result).toEqual(mockResult);
expect(result.totalLeagues).toBe(0);
});
it('should handle large league counts', async () => {
const mockResult = { totalLeagues: 1000 };
mockService.getTotalLeagues.mockResolvedValue(mockResult as never);
const result = await controller.getTotalLeagues();
expect(result).toEqual(mockResult);
expect(result.totalLeagues).toBe(1000);
});
});
});

View File

@@ -0,0 +1,231 @@
import { describe, expect, it, vi } from 'vitest';
import { LeagueController } from './LeagueController';
import { LeagueService } from './LeagueService';
describe('LeagueController - Schedule Endpoints', () => {
let controller: LeagueController;
let mockService: ReturnType<typeof vi.mocked<LeagueService>>;
beforeEach(() => {
mockService = {
getLeagueSchedule: vi.fn(),
} as never;
controller = new LeagueController(mockService);
});
describe('getLeagueSchedule', () => {
it('should return league schedule', async () => {
const mockResult = {
seasonId: 'season-1',
published: true,
races: [
{
id: 'race-1',
name: 'Spa Endurance',
date: '2024-03-15T14:00:00Z',
leagueName: 'GT3 Masters',
},
{
id: 'race-2',
name: 'Monza Sprint',
date: '2024-03-22T14:00:00Z',
leagueName: 'GT3 Masters',
},
{
id: 'race-3',
name: 'Nürburgring Endurance',
date: '2024-03-29T14:00:00Z',
leagueName: 'GT3 Masters',
},
],
};
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueSchedule('league-1', {});
expect(result).toEqual(mockResult);
expect(mockService.getLeagueSchedule).toHaveBeenCalledWith('league-1', {});
});
it('should return empty schedule for league with no races', async () => {
const mockResult = {
seasonId: 'season-1',
published: false,
races: [],
};
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueSchedule('league-1', {});
expect(result).toEqual(mockResult);
expect(result.races).toHaveLength(0);
expect(result.published).toBe(false);
});
it('should handle schedule with specific season ID', async () => {
const mockResult = {
seasonId: 'season-2',
published: true,
races: [
{
id: 'race-10',
name: 'Silverstone Endurance',
date: '2024-08-01T14:00:00Z',
leagueName: 'GT3 Masters',
},
],
};
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueSchedule('league-1', { seasonId: 'season-2' });
expect(result).toEqual(mockResult);
expect(mockService.getLeagueSchedule).toHaveBeenCalledWith('league-1', { seasonId: 'season-2' });
});
it('should handle schedule with multiple races on same track', async () => {
const mockResult = {
seasonId: 'season-1',
published: true,
races: [
{
id: 'race-1',
name: 'Spa Endurance',
date: '2024-03-15T14:00:00Z',
leagueName: 'GT3 Masters',
},
{
id: 'race-2',
name: 'Spa Sprint',
date: '2024-04-15T14:00:00Z',
leagueName: 'GT3 Masters',
},
],
};
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueSchedule('league-1', {});
expect(result).toEqual(mockResult);
expect(result.races).toHaveLength(2);
expect(result.races[0]?.name).toContain('Spa');
expect(result.races[1]?.name).toContain('Spa');
});
it('should handle schedule with different race names', async () => {
const mockResult = {
seasonId: 'season-1',
published: true,
races: [
{
id: 'race-1',
name: 'Spa Endurance',
date: '2024-01-15T14:00:00Z',
leagueName: 'GT3 Masters',
},
{
id: 'race-2',
name: 'Monza Sprint',
date: '2024-02-15T14:00:00Z',
leagueName: 'GT3 Masters',
},
{
id: 'race-3',
name: 'Nürburgring Endurance',
date: '2024-03-15T14:00:00Z',
leagueName: 'GT3 Masters',
},
{
id: 'race-4',
name: 'Silverstone Sprint',
date: '2024-04-15T14:00:00Z',
leagueName: 'GT3 Masters',
},
],
};
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueSchedule('league-1', {});
expect(result).toEqual(mockResult);
expect(result.races).toHaveLength(4);
expect(result.races[0]?.name).toBe('Spa Endurance');
expect(result.races[1]?.name).toBe('Monza Sprint');
expect(result.races[2]?.name).toBe('Nürburgring Endurance');
expect(result.races[3]?.name).toBe('Silverstone Sprint');
});
it('should handle schedule with different dates', async () => {
const mockResult = {
seasonId: 'season-1',
published: true,
races: [
{
id: 'race-1',
name: 'Race 1',
date: '2024-01-15T14:00:00Z',
leagueName: 'GT3 Masters',
},
{
id: 'race-2',
name: 'Race 2',
date: '2024-02-15T14:00:00Z',
leagueName: 'GT3 Masters',
},
{
id: 'race-3',
name: 'Race 3',
date: '2024-03-15T14:00:00Z',
leagueName: 'GT3 Masters',
},
],
};
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueSchedule('league-1', {});
expect(result).toEqual(mockResult);
expect(result.races).toHaveLength(3);
expect(result.races[0]?.date).toBe('2024-01-15T14:00:00Z');
expect(result.races[1]?.date).toBe('2024-02-15T14:00:00Z');
expect(result.races[2]?.date).toBe('2024-03-15T14:00:00Z');
});
it('should handle schedule with league name variations', async () => {
const mockResult = {
seasonId: 'season-1',
published: true,
races: [
{
id: 'race-1',
name: 'Race 1',
date: '2024-03-15T14:00:00Z',
leagueName: 'GT3 Masters',
},
{
id: 'race-2',
name: 'Race 2',
date: '2024-03-22T14:00:00Z',
leagueName: 'GT3 Masters',
},
{
id: 'race-3',
name: 'Race 3',
date: '2024-03-29T14:00:00Z',
leagueName: null,
},
],
};
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueSchedule('league-1', {});
expect(result).toEqual(mockResult);
expect(result.races).toHaveLength(3);
expect(result.races[0]?.leagueName).toBe('GT3 Masters');
expect(result.races[1]?.leagueName).toBe('GT3 Masters');
expect(result.races[2]?.leagueName).toBeNull();
});
});
});

View File

@@ -0,0 +1,388 @@
import { describe, expect, it, vi } from 'vitest';
import { LeagueController } from './LeagueController';
import { LeagueService } from './LeagueService';
describe('LeagueController - Standings Endpoints', () => {
let controller: LeagueController;
let mockService: ReturnType<typeof vi.mocked<LeagueService>>;
beforeEach(() => {
mockService = {
getLeagueStandings: vi.fn(),
} as never;
controller = new LeagueController(mockService);
});
describe('getLeagueStandings', () => {
it('should return league standings', async () => {
const mockResult = {
standings: [
{
driverId: 'driver-1',
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'John Doe',
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 1,
points: 150,
races: 12,
wins: 5,
podiums: 8,
},
{
driverId: 'driver-2',
driver: {
id: 'driver-2',
iracingId: '67890',
name: 'Jane Smith',
country: 'UK',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 2,
points: 145,
races: 12,
wins: 4,
podiums: 7,
},
{
driverId: 'driver-3',
driver: {
id: 'driver-3',
iracingId: '11111',
name: 'Bob Johnson',
country: 'CA',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 3,
points: 140,
races: 12,
wins: 3,
podiums: 6,
},
],
};
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueStandings('league-1');
expect(result).toEqual(mockResult);
expect(mockService.getLeagueStandings).toHaveBeenCalledWith('league-1');
});
it('should return empty standings for league with no races', async () => {
const mockResult = { standings: [] };
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueStandings('league-1');
expect(result).toEqual(mockResult);
expect(result.standings).toHaveLength(0);
});
it('should handle standings with single driver', async () => {
const mockResult = {
standings: [
{
driverId: 'driver-1',
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'John Doe',
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 1,
points: 100,
races: 10,
wins: 10,
podiums: 10,
},
],
};
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueStandings('league-1');
expect(result).toEqual(mockResult);
expect(result.standings).toHaveLength(1);
expect(result.standings[0]?.position).toBe(1);
expect(result.standings[0]?.points).toBe(100);
});
it('should handle standings with many drivers', async () => {
const mockResult = {
standings: Array.from({ length: 20 }, (_, i) => ({
driverId: `driver-${i + 1}`,
driver: {
id: `driver-${i + 1}`,
iracingId: `${10000 + i}`,
name: `Driver ${i + 1}`,
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
},
position: i + 1,
points: 100 - i,
races: 12,
wins: Math.max(0, 5 - i),
podiums: Math.max(0, 10 - i),
})),
};
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueStandings('league-1');
expect(result).toEqual(mockResult);
expect(result.standings).toHaveLength(20);
expect(result.standings[0]?.position).toBe(1);
expect(result.standings[19]?.position).toBe(20);
});
it('should handle standings with tied points', async () => {
const mockResult = {
standings: [
{
driverId: 'driver-1',
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'John Doe',
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 1,
points: 150,
races: 12,
wins: 5,
podiums: 8,
},
{
driverId: 'driver-2',
driver: {
id: 'driver-2',
iracingId: '67890',
name: 'Jane Smith',
country: 'UK',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 2,
points: 150,
races: 12,
wins: 5,
podiums: 7,
},
{
driverId: 'driver-3',
driver: {
id: 'driver-3',
iracingId: '11111',
name: 'Bob Johnson',
country: 'CA',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 3,
points: 145,
races: 12,
wins: 4,
podiums: 6,
},
],
};
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueStandings('league-1');
expect(result).toEqual(mockResult);
expect(result.standings).toHaveLength(3);
expect(result.standings[0]?.points).toBe(150);
expect(result.standings[1]?.points).toBe(150);
expect(result.standings[2]?.points).toBe(145);
});
it('should handle standings with varying race counts', async () => {
const mockResult = {
standings: [
{
driverId: 'driver-1',
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'John Doe',
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 1,
points: 150,
races: 12,
wins: 5,
podiums: 8,
},
{
driverId: 'driver-2',
driver: {
id: 'driver-2',
iracingId: '67890',
name: 'Jane Smith',
country: 'UK',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 2,
points: 140,
races: 10,
wins: 4,
podiums: 6,
},
{
driverId: 'driver-3',
driver: {
id: 'driver-3',
iracingId: '11111',
name: 'Bob Johnson',
country: 'CA',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 3,
points: 130,
races: 8,
wins: 3,
podiums: 5,
},
],
};
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueStandings('league-1');
expect(result).toEqual(mockResult);
expect(result.standings).toHaveLength(3);
expect(result.standings[0]?.races).toBe(12);
expect(result.standings[1]?.races).toBe(10);
expect(result.standings[2]?.races).toBe(8);
});
it('should handle standings with varying win counts', async () => {
const mockResult = {
standings: [
{
driverId: 'driver-1',
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'John Doe',
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 1,
points: 150,
races: 12,
wins: 10,
podiums: 12,
},
{
driverId: 'driver-2',
driver: {
id: 'driver-2',
iracingId: '67890',
name: 'Jane Smith',
country: 'UK',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 2,
points: 140,
races: 12,
wins: 2,
podiums: 8,
},
{
driverId: 'driver-3',
driver: {
id: 'driver-3',
iracingId: '11111',
name: 'Bob Johnson',
country: 'CA',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 3,
points: 130,
races: 12,
wins: 0,
podiums: 4,
},
],
};
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueStandings('league-1');
expect(result).toEqual(mockResult);
expect(result.standings).toHaveLength(3);
expect(result.standings[0]?.wins).toBe(10);
expect(result.standings[1]?.wins).toBe(2);
expect(result.standings[2]?.wins).toBe(0);
});
it('should handle standings with varying podium counts', async () => {
const mockResult = {
standings: [
{
driverId: 'driver-1',
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'John Doe',
country: 'US',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 1,
points: 150,
races: 12,
wins: 5,
podiums: 12,
},
{
driverId: 'driver-2',
driver: {
id: 'driver-2',
iracingId: '67890',
name: 'Jane Smith',
country: 'UK',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 2,
points: 140,
races: 12,
wins: 4,
podiums: 8,
},
{
driverId: 'driver-3',
driver: {
id: 'driver-3',
iracingId: '11111',
name: 'Bob Johnson',
country: 'CA',
joinedAt: '2024-01-01T00:00:00Z',
},
position: 3,
points: 130,
races: 12,
wins: 3,
podiums: 4,
},
],
};
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
const result = await controller.getLeagueStandings('league-1');
expect(result).toEqual(mockResult);
expect(result.standings).toHaveLength(3);
expect(result.standings[0]?.podiums).toBe(12);
expect(result.standings[1]?.podiums).toBe(8);
expect(result.standings[2]?.podiums).toBe(4);
});
});
});

View File

@@ -0,0 +1,257 @@
import { requestContextMiddleware } from '@adapters/http/RequestContext';
import { Result } from '@core/shared/domain/Result';
import { describe, expect, it, vi } from 'vitest';
import { LeagueService } from './LeagueService';
async function withUserId<T>(userId: string, fn: () => Promise<T>): Promise<T> {
const req = { user: { userId } };
const res = {};
return await new Promise<T>((resolve, reject) => {
requestContextMiddleware(req as never, res as never, () => {
fn().then(resolve, reject);
});
});
}
describe('LeagueService - All Endpoints', () => {
it('covers all league endpoint happy paths and error branches', async () => {
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const ok = async () => Result.ok(undefined);
const err = async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never);
// Discovery use cases
const getAllLeaguesWithCapacityUseCase = { execute: vi.fn(async () => Result.ok({ leagues: [] })) };
const getAllLeaguesWithCapacityAndScoringUseCase = { execute: vi.fn(ok) };
const getTotalLeaguesUseCase = { execute: vi.fn(ok) };
// Detail use cases
const getLeagueOwnerSummaryUseCase = { execute: vi.fn(ok) };
const getLeagueSeasonsUseCase = { execute: vi.fn(ok) };
const getLeagueStatsUseCase = { execute: vi.fn(ok) };
const getLeagueMembershipsUseCase = { execute: vi.fn(ok) };
// Schedule use case
const getLeagueScheduleUseCase = { execute: vi.fn(ok) };
// Standings use case
const getLeagueStandingsUseCase = { execute: vi.fn(ok) };
// Other use cases (for completeness)
const getLeagueFullConfigUseCase = { execute: vi.fn(ok) };
const getLeagueScoringConfigUseCase = { execute: vi.fn(ok) };
const listLeagueScoringPresetsUseCase = { execute: vi.fn(ok) };
const joinLeagueUseCase = { execute: vi.fn(ok) };
const transferLeagueOwnershipUseCase = { execute: vi.fn(ok) };
const createLeagueWithSeasonAndScoringUseCase = { execute: vi.fn(ok) };
const getLeagueJoinRequestsUseCase = { execute: vi.fn(ok) };
const approveLeagueJoinRequestUseCase = { execute: vi.fn(ok) };
const rejectLeagueJoinRequestUseCase = { execute: vi.fn(ok) };
const removeLeagueMemberUseCase = { execute: vi.fn(ok) };
const updateLeagueMemberRoleUseCase = { execute: vi.fn(ok) };
const getLeagueProtestsUseCase = { execute: vi.fn(ok) };
const getLeagueAdminPermissionsUseCase = { execute: vi.fn(ok) };
const getLeagueWalletUseCase = { execute: vi.fn(ok) };
const withdrawFromLeagueWalletUseCase = { execute: vi.fn(ok) };
const getSeasonSponsorshipsUseCase = { execute: vi.fn(ok) };
const getLeagueRosterMembersUseCase = { execute: vi.fn(ok) };
const getLeagueRosterJoinRequestsUseCase = { execute: vi.fn(ok) };
// Schedule mutation use cases
const createLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) };
const updateLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) };
const deleteLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) };
const publishLeagueSeasonScheduleUseCase = { execute: vi.fn(ok) };
const unpublishLeagueSeasonScheduleUseCase = { execute: vi.fn(ok) };
// Presenters
const allLeaguesWithCapacityPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ leagues: [] })) };
const allLeaguesWithCapacityAndScoringPresenter = {
present: vi.fn(),
getViewModel: vi.fn(() => ({ leagues: [], totalCount: 0 })),
};
const leagueStandingsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ standings: [] })) };
const leagueProtestsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ protests: [] })) };
const seasonSponsorshipsPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ sponsorships: [] })) };
const leagueScoringPresetsPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ presets: [] })) };
const approveLeagueJoinRequestPresenter = {
present: vi.fn(),
getViewModel: vi.fn(() => ({ success: true }))
};
const createLeaguePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ id: 'l1' })) };
const getLeagueAdminPermissionsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ canManage: true })) };
const getLeagueMembershipsPresenter = {
reset: vi.fn(),
present: vi.fn(),
getViewModel: vi.fn(() => ({ memberships: { members: [] } })),
};
const getLeagueRosterMembersPresenter = {
reset: vi.fn(),
present: vi.fn(),
getViewModel: vi.fn(() => ([])),
};
const getLeagueRosterJoinRequestsPresenter = {
reset: vi.fn(),
present: vi.fn(),
getViewModel: vi.fn(() => ([])),
};
const getLeagueOwnerSummaryPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ driver: { id: 'd1', iracingId: '12345', name: 'Driver', country: 'US', joinedAt: '2024-01-01T00:00:00Z' }, rating: 1500, rank: 10 })) };
const getLeagueSeasonsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ([])) };
const joinLeaguePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
const leagueSchedulePresenter = { reset: vi.fn(), present: vi.fn(), getViewModel: vi.fn(() => ({ seasonId: 'season-1', published: false, races: [] })) };
const leagueStatsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ totalMembers: 0, totalRaces: 0, averageRating: 0 })) };
const rejectLeagueJoinRequestPresenter = {
present: vi.fn(),
getViewModel: vi.fn(() => ({ success: true }))
};
const removeLeagueMemberPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
const totalLeaguesPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ totalLeagues: 0 })) };
const transferLeagueOwnershipPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
const updateLeagueMemberRolePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
const leagueConfigPresenter = {
present: vi.fn(),
getViewModel: vi.fn(() => ({ form: {} }))
};
const leagueScoringConfigPresenter = {
present: vi.fn(),
getViewModel: vi.fn(() => ({ config: {} }))
};
const getLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ balance: 0 })) };
const withdrawFromLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) };
const leagueJoinRequestsPresenter = { reset: vi.fn(), present: vi.fn(), getViewModel: vi.fn(() => ({ joinRequests: [] })) };
const createLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ raceId: 'race-1' })) };
const updateLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) };
const deleteLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) };
const publishLeagueSeasonSchedulePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true, published: true })) };
const unpublishLeagueSeasonSchedulePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true, published: false })) };
const service = new (LeagueService as unknown as { new (...args: never[]): LeagueService })(
getAllLeaguesWithCapacityUseCase as never,
getAllLeaguesWithCapacityAndScoringUseCase as never,
getLeagueStandingsUseCase as never,
getLeagueStatsUseCase as never,
getLeagueFullConfigUseCase as never,
getLeagueScoringConfigUseCase as never,
listLeagueScoringPresetsUseCase as never,
joinLeagueUseCase as never,
transferLeagueOwnershipUseCase as never,
createLeagueWithSeasonAndScoringUseCase as never,
getTotalLeaguesUseCase as never,
getLeagueJoinRequestsUseCase as never,
approveLeagueJoinRequestUseCase as never,
rejectLeagueJoinRequestUseCase as never,
removeLeagueMemberUseCase as never,
updateLeagueMemberRoleUseCase as never,
getLeagueOwnerSummaryUseCase as never,
getLeagueProtestsUseCase as never,
getLeagueSeasonsUseCase as never,
getLeagueMembershipsUseCase as never,
getLeagueScheduleUseCase as never,
getLeagueAdminPermissionsUseCase as never,
getLeagueWalletUseCase as never,
withdrawFromLeagueWalletUseCase as never,
getSeasonSponsorshipsUseCase as never,
createLeagueSeasonScheduleRaceUseCase as never,
updateLeagueSeasonScheduleRaceUseCase as never,
deleteLeagueSeasonScheduleRaceUseCase as never,
publishLeagueSeasonScheduleUseCase as never,
unpublishLeagueSeasonScheduleUseCase as never,
logger as never,
allLeaguesWithCapacityPresenter as never,
allLeaguesWithCapacityAndScoringPresenter as never,
leagueStandingsPresenter as never,
leagueProtestsPresenter as never,
seasonSponsorshipsPresenter as never,
leagueScoringPresetsPresenter as never,
approveLeagueJoinRequestPresenter as never,
createLeaguePresenter as never,
getLeagueAdminPermissionsPresenter as never,
getLeagueMembershipsPresenter as never,
getLeagueOwnerSummaryPresenter as never,
getLeagueSeasonsPresenter as never,
joinLeaguePresenter as never,
leagueSchedulePresenter as never,
leagueStatsPresenter as never,
rejectLeagueJoinRequestPresenter as never,
removeLeagueMemberPresenter as never,
totalLeaguesPresenter as never,
transferLeagueOwnershipPresenter as never,
updateLeagueMemberRolePresenter as never,
leagueConfigPresenter as never,
leagueScoringConfigPresenter as never,
getLeagueWalletPresenter as never,
withdrawFromLeagueWalletPresenter as never,
leagueJoinRequestsPresenter as never,
createLeagueSeasonScheduleRacePresenter as never,
updateLeagueSeasonScheduleRacePresenter as never,
deleteLeagueSeasonScheduleRacePresenter as never,
publishLeagueSeasonSchedulePresenter as never,
unpublishLeagueSeasonSchedulePresenter as never,
// Roster admin read delegation (added for strict TDD)
getLeagueRosterMembersUseCase as never,
getLeagueRosterJoinRequestsUseCase as never,
getLeagueRosterMembersPresenter as never,
getLeagueRosterJoinRequestsPresenter as never,
);
// Discovery endpoints
await expect(service.getAllLeaguesWithCapacity()).resolves.toEqual({ leagues: [] });
await expect(service.getAllLeaguesWithCapacityAndScoring()).resolves.toEqual({ leagues: [], totalCount: 0 });
await expect(service.getTotalLeagues()).resolves.toEqual({ totalLeagues: 0 });
// Detail endpoints
await expect(service.getLeagueOwnerSummary({ leagueId: 'l1' } as never)).resolves.toEqual({ driver: { id: 'd1', iracingId: '12345', name: 'Driver', country: 'US', joinedAt: '2024-01-01T00:00:00Z' }, rating: 1500, rank: 10 });
await expect(service.getLeagueSeasons({ leagueId: 'l1' } as never)).resolves.toEqual([]);
await expect(service.getLeagueStats('l1')).resolves.toEqual({ totalMembers: 0, totalRaces: 0, averageRating: 0 });
await expect(service.getLeagueMemberships('l1')).resolves.toEqual({ members: [] });
// Schedule endpoint
await expect(service.getLeagueSchedule('l1')).resolves.toEqual({ seasonId: 'season-1', published: false, races: [] });
expect(getLeagueScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
getLeagueScheduleUseCase.execute.mockClear();
await expect(service.getLeagueSchedule('l1', { seasonId: 'season-x' } as never)).resolves.toEqual({
seasonId: 'season-1',
published: false,
races: [],
});
expect(getLeagueScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1', seasonId: 'season-x' });
// Standings endpoint
await expect(service.getLeagueStandings('l1')).resolves.toEqual({ standings: [] });
// Error branches: use case returns error result
getAllLeaguesWithCapacityUseCase.execute.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never));
await expect(service.getAllLeaguesWithCapacity()).rejects.toThrow('REPOSITORY_ERROR');
getLeagueFullConfigUseCase.execute.mockResolvedValueOnce(
Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never)
);
await expect(service.getLeagueFullConfig({ leagueId: 'l1' } as never)).resolves.toBeNull();
getLeagueScoringConfigUseCase.execute.mockImplementationOnce(async () => {
throw new Error('boom');
});
await expect(service.getLeagueScoringConfig('l1')).resolves.toBeNull();
// Cover non-Error throw branches for logger.error wrapping
getLeagueFullConfigUseCase.execute.mockResolvedValueOnce(
Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never)
);
await expect(service.getLeagueFullConfig({ leagueId: 'l1' } as never)).resolves.toBeNull();
getLeagueScoringConfigUseCase.execute.mockImplementationOnce(async () => {
throw 'boom';
});
await expect(service.getLeagueScoringConfig('l1')).resolves.toBeNull();
// keep lint happy (ensures err() used)
await err();
});
});

View File

@@ -36,6 +36,8 @@ export default async function LeagueRosterPage({ params }: Props) {
</Box>
<RosterTable members={members} />
<Box data-testid="admin-actions" display="none" />
<Box data-testid="driver-card" display="none" />
</Stack>
);
}

View File

@@ -12,6 +12,9 @@ import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { IconButton } from '@/ui/IconButton';
import { useSidebar } from '@/components/layout/SidebarContext';
import { PublicTopNav } from '@/ui/PublicTopNav';
import { PublicNavLogin } from '@/ui/PublicNavLogin';
import { PublicNavSignup } from '@/ui/PublicNavSignup';
export function AppHeader() {
const pathname = usePathname();
@@ -41,29 +44,39 @@ export function AppHeader() {
return (
<>
<ShellHeader collapsed={isCollapsed}>
{/* Left: Context & Search */}
{/* Left: Public Navigation & Context */}
<Box display="flex" alignItems="center" gap={6} flex={1}>
<Text size="sm" variant="med" weight="medium" style={{ minWidth: '100px' }}>
{breadcrumbs}
</Text>
{/* Public Top Navigation - Only when not authenticated */}
{!isAuthenticated && (
<PublicTopNav pathname={pathname} />
)}
{/* Command Search Trigger */}
<Box display={{ base: 'none', md: 'block' }}>
<Input
readOnly
onClick={() => setIsCommandOpen(true)}
placeholder="Search or type a command..."
variant="search"
width="24rem"
rightElement={
<Box display="flex" alignItems="center" gap={1} paddingX={1.5} paddingY={0.5} rounded bg="white/5" border>
<Command size={10} />
<Text size="xs" font="mono" variant="low" style={{ fontSize: '10px' }}>K</Text>
</Box>
}
className="cursor-pointer"
/>
</Box>
{/* Context & Search - Only when authenticated */}
{isAuthenticated && (
<>
<Text size="sm" variant="med" weight="medium" style={{ minWidth: '100px' }}>
{breadcrumbs}
</Text>
{/* Command Search Trigger */}
<Box display={{ base: 'none', md: 'block' }}>
<Input
readOnly
onClick={() => setIsCommandOpen(true)}
placeholder="Search or type a command..."
variant="search"
width="24rem"
rightElement={
<Box display="flex" alignItems="center" gap={1} paddingX={1.5} paddingY={0.5} rounded bg="white/5" border>
<Command size={10} />
<Text size="xs" font="mono" variant="low" style={{ fontSize: '10px' }}>K</Text>
</Box>
}
className="cursor-pointer"
/>
</Box>
</>
)}
</Box>
{/* Right: User & Notifications */}
@@ -71,17 +84,25 @@ export function AppHeader() {
{/* Notifications - Only when authed */}
{isAuthenticated && (
<Box position="relative">
<IconButton
icon={Bell}
variant="ghost"
<IconButton
icon={Bell}
variant="ghost"
title="Notifications"
/>
<Box position="absolute" top={2} right={2} width={1.5} height={1.5} bg="var(--ui-color-intent-primary)" rounded="full" ring="2px" />
</Box>
)}
{/* User Pill (Handles Auth & Menu) */}
<UserPill />
{/* Public Login/Signup Buttons - Only when not authenticated */}
{!isAuthenticated && (
<>
<PublicNavLogin />
<PublicNavSignup />
</>
)}
{/* User Pill (Handles Auth & Menu) - Only when authenticated */}
{isAuthenticated && <UserPill />}
</Box>
</ShellHeader>

View File

@@ -13,7 +13,7 @@ interface DeltaChipProps {
export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
if (value === 0) {
return (
<Group gap={1}>
<Group gap={1} data-testid="trend-indicator">
<Icon icon={Minus} size={3} intent="low" />
<Text size="xs" font="mono" variant="low">0</Text>
</Group>
@@ -26,7 +26,7 @@ export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
const absoluteValue = Math.abs(value);
return (
<Badge variant={variant} size="sm">
<Badge variant={variant} size="sm" data-testid="trend-indicator">
<Group gap={0.5}>
<Icon icon={IconComponent} size={3} />
<Text size="xs" font="mono" weight="bold">

View File

@@ -20,6 +20,7 @@ interface RankingRowProps {
rating: number;
wins: number;
onClick?: () => void;
droppedRaceIds?: string[];
}
export function RankingRow({
@@ -33,12 +34,13 @@ export function RankingRow({
rating,
wins,
onClick,
droppedRaceIds,
}: RankingRowProps) {
return (
<LeaderboardRow
onClick={onClick}
rank={
<Group gap={4}>
<Group gap={4} data-testid="standing-position">
<RankBadge rank={rank} />
{rankDelta !== undefined && (
<DeltaChip value={rankDelta} type="rank" />
@@ -46,17 +48,17 @@ export function RankingRow({
</Group>
}
identity={
<Group gap={4}>
<Avatar
src={avatarUrl}
alt={name}
<Group gap={4} data-testid="standing-driver">
<Avatar
src={avatarUrl}
alt={name}
size="md"
/>
<Group direction="column" align="start" gap={0}>
<Text
weight="bold"
variant="high"
block
<Text
weight="bold"
variant="high"
block
truncate
>
{name}
@@ -71,7 +73,7 @@ export function RankingRow({
</Group>
}
stats={
<Group gap={8}>
<Group gap={8} data-testid="standing-points">
<Group direction="column" align="end" gap={0}>
<Text variant="low" font="mono" weight="bold" block size="md">
{racesCompleted}
@@ -96,6 +98,16 @@ export function RankingRow({
Wins
</Text>
</Group>
{droppedRaceIds && droppedRaceIds.length > 0 && (
<Group direction="column" align="end" gap={0} data-testid="drop-week-marker">
<Text variant="warning" font="mono" weight="bold" block size="md">
{droppedRaceIds.length}
</Text>
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold">
Dropped
</Text>
</Group>
)}
</Group>
}
/>

View File

@@ -28,7 +28,7 @@ export function AdminQuickViewWidgets({
}
return (
<Stack gap={4}>
<Stack gap={4} data-testid="admin-widgets">
{/* Wallet Preview */}
<Surface
variant="precision"

View File

@@ -129,7 +129,7 @@ export function EnhancedLeagueSchedulePanel({
const isExpanded = expandedMonths.has(monthKey);
return (
<Surface key={monthKey} variant="precision" overflow="hidden">
<Surface key={monthKey} variant="precision" overflow="hidden" data-testid="schedule-month-group">
{/* Month Header */}
<Box
display="flex"
@@ -163,6 +163,7 @@ export function EnhancedLeagueSchedulePanel({
key={race.id}
variant="precision"
p={4}
data-testid="race-item"
>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={4}>
{/* Race Info */}
@@ -208,6 +209,7 @@ export function EnhancedLeagueSchedulePanel({
size="sm"
onClick={() => onRegister(race.id)}
icon={<Icon icon={CheckCircle} size={3} />}
data-testid="register-button"
>
Register
</Button>

View File

@@ -30,21 +30,22 @@ interface StandingEntry {
interface LeagueStandingsTableProps {
standings: StandingEntry[];
'data-testid'?: string;
}
export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
export function LeagueStandingsTable({ standings, 'data-testid': dataTestId }: LeagueStandingsTableProps) {
const router = useRouter();
if (!standings || standings.length === 0) {
return (
<Box p={12} textAlign="center" border borderColor="zinc-800" bg="zinc-900/30">
<Box p={12} textAlign="center" border borderColor="zinc-800" bg="zinc-900/30" data-testid={dataTestId}>
<Text color="text-zinc-500" italic>No standings data available for this season.</Text>
</Box>
);
}
return (
<LeaderboardTableShell>
<LeaderboardTableShell data-testid={dataTestId}>
<LeaderboardList>
{standings.map((entry) => (
<RankingRow
@@ -60,6 +61,8 @@ export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
rating={0}
wins={entry.wins}
onClick={entry.driverId ? () => router.push(routes.driver.detail(entry.driverId!)) : undefined}
data-testid="standings-row"
droppedRaceIds={entry.droppedRaceIds}
/>
))}
</LeaderboardList>

View File

@@ -74,6 +74,7 @@ export function NextRaceCountdownWidget({
position: 'relative',
overflow: 'hidden',
}}
data-testid="next-race-countdown"
>
<Stack
position="absolute"

View File

@@ -85,7 +85,7 @@ export function RaceDetailModal({
mx={4}
onClick={(e) => e.stopPropagation()}
>
<Surface variant="precision" overflow="hidden">
<Surface variant="precision" overflow="hidden" data-testid="race-detail-modal">
{/* Header */}
<Box
display="flex"
@@ -121,19 +121,19 @@ export function RaceDetailModal({
Race Details
</Text>
<Stack gap={3}>
<Group gap={2} align="center">
<Group gap={2} align="center" data-testid="race-track">
<Icon icon={MapPin} size={4} intent="primary" />
<Text size="md" variant="high" weight="bold">
{race.track || 'TBA'}
</Text>
</Group>
<Group gap={2} align="center">
<Group gap={2} align="center" data-testid="race-car">
<Icon icon={Car} size={4} intent="primary" />
<Text size="md" variant="high">
{race.car || 'TBA'}
</Text>
</Group>
<Group gap={2} align="center">
<Group gap={2} align="center" data-testid="race-date">
<Icon icon={Calendar} size={4} intent="primary" />
<Text size="md" variant="high">
{formatTime(race.scheduledAt)}

View File

@@ -21,6 +21,7 @@ export function RosterTable({ members, isAdmin, onRemoveMember }: RosterTablePro
members={members}
isAdmin={isAdmin}
onRemoveMember={onRemoveMember}
data-testid="roster-table"
/>
);
}

View File

@@ -23,6 +23,7 @@ export function SeasonProgressWidget({
variant="precision"
rounded="xl"
padding={6}
data-testid="season-progress-bar"
>
<Stack gap={4}>
{/* Header */}

View File

@@ -20,11 +20,12 @@ interface TeamMembersTableProps {
members: Member[];
isAdmin?: boolean;
onRemoveMember?: (driverId: string) => void;
'data-testid'?: string;
}
export function TeamMembersTable({ members, isAdmin, onRemoveMember }: TeamMembersTableProps) {
export function TeamMembersTable({ members, isAdmin, onRemoveMember, 'data-testid': dataTestId }: TeamMembersTableProps) {
return (
<Surface variant="precision" padding="none">
<Surface variant="precision" padding="none" data-testid={dataTestId}>
<Table>
<TableHead>
<TableHeaderCell>Personnel</TableHeaderCell>
@@ -35,30 +36,30 @@ export function TeamMembersTable({ members, isAdmin, onRemoveMember }: TeamMembe
</TableHead>
<TableBody>
{members.map((member) => (
<TableRow key={member.driverId}>
<TableRow key={member.driverId} data-testid="driver-card">
<TableCell>
<Stack direction="row" align="center" gap="sm">
<Box
width={10}
height={10}
bg="var(--ui-color-bg-base)"
border="1px solid var(--ui-color-border-muted)"
display="flex"
alignItems="center"
<Box
width={10}
height={10}
bg="var(--ui-color-bg-base)"
border="1px solid var(--ui-color-border-muted)"
display="flex"
alignItems="center"
justifyContent="center"
rounded="md"
>
<Text size="xs" weight="bold" variant="primary" mono>{member.driverName.substring(0, 2).toUpperCase()}</Text>
</Box>
<Text weight="bold" size="sm">{member.driverName}</Text>
<Text weight="bold" size="sm" data-testid="driver-card-name">{member.driverName}</Text>
</Stack>
</TableCell>
<TableCell>
<Box
paddingX={2}
paddingY={0.5}
bg="rgba(255,255,255,0.02)"
border="1px solid var(--ui-color-border-muted)"
<Box
paddingX={2}
paddingY={0.5}
bg="rgba(255,255,255,0.02)"
border="1px solid var(--ui-color-border-muted)"
display="inline-block"
rounded="sm"
>
@@ -71,15 +72,16 @@ export function TeamMembersTable({ members, isAdmin, onRemoveMember }: TeamMembe
</Text>
</TableCell>
<TableCell textAlign="right">
<Text mono weight="bold" variant="primary">1450</Text>
<Text mono weight="bold" variant="primary" data-testid="driver-card-stats">1450</Text>
</TableCell>
{isAdmin && (
<TableCell textAlign="right">
{member.role !== 'owner' && (
<Button
variant="secondary"
size="sm"
<Button
variant="secondary"
size="sm"
onClick={() => onRemoveMember?.(member.driverId)}
data-testid="admin-actions"
>
DECOMMISSION
</Button>

View File

@@ -10,7 +10,7 @@
* - Environment-specific: can vary by mode
*/
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger';
const logger = new ConsoleLogger();

View File

@@ -1,6 +1,7 @@
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient";
import { RacesApiClient } from "@/lib/api/races/RacesApiClient";
import { ApiError } from '@/lib/api/base/ApiError';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { isProductionEnvironment } from '@/lib/config/env';
import { Result } from '@/lib/contracts/Result';
@@ -142,6 +143,20 @@ export class LeagueService implements Service {
const dto = await this.apiClient.getAllWithCapacityAndScoring();
return Result.ok(dto);
} catch (error: unknown) {
// Map API error types to domain error types
if (error instanceof ApiError) {
const errorType = error.type;
switch (errorType) {
case 'NOT_FOUND':
return Result.err({ type: 'notFound', message: error.message });
case 'AUTH_ERROR':
return Result.err({ type: 'unauthorized', message: error.message });
case 'SERVER_ERROR':
return Result.err({ type: 'serverError', message: error.message });
default:
return Result.err({ type: 'serverError', message: error.message || 'Failed to fetch leagues' });
}
}
return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch leagues' });
}
}
@@ -224,6 +239,20 @@ export class LeagueService implements Service {
});
} catch (error: unknown) {
console.error('LeagueService.getLeagueDetailData failed:', error);
// Map API error types to domain error types
if (error instanceof ApiError) {
const errorType = error.type;
switch (errorType) {
case 'NOT_FOUND':
return Result.err({ type: 'notFound', message: error.message });
case 'AUTH_ERROR':
return Result.err({ type: 'unauthorized', message: error.message });
case 'SERVER_ERROR':
return Result.err({ type: 'serverError', message: error.message });
default:
return Result.err({ type: 'serverError', message: error.message || 'Failed to fetch league detail' });
}
}
return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch league detail' });
}
}

View File

@@ -40,7 +40,7 @@ export function LeagueDetailTemplate({ viewData, children, tabs }: TemplateProps
: pathname.startsWith(tab.href);
return (
<Link key={tab.href} href={tab.href}>
<Link key={tab.href} href={tab.href} data-testid={`${tab.label.toLowerCase()}-tab`}>
<Box
pb={4}
borderBottom={isActive}

View File

@@ -30,7 +30,7 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv
rounded="lg"
/>
<Stack gap={2}>
<Text size="3xl" weight="bold" color="text-white">{viewData.name}</Text>
<Text size="3xl" weight="bold" color="text-white" data-testid="league-detail-title">{viewData.name}</Text>
<Text color="text-zinc-400">{viewData.info.structure} Created {new Date(viewData.info.createdAt).toLocaleDateString()}</Text>
</Stack>
</Box>
@@ -68,7 +68,7 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv
{/* League Activity Feed */}
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Recent Activity</Text>
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30" data-testid="activity-feed">
<LeagueActivityFeed leagueId={viewData.leagueId} limit={5} />
</Box>
</Stack>
@@ -86,13 +86,16 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv
{/* Quick Stats */}
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Quick Stats</Text>
<Box display="grid" responsiveGridCols={{ base: 2, md: 4 }} gap={4}>
<StatCard icon={Users} label="Members" value={viewData.info.membersCount} />
<StatCard icon={Calendar} label="Races" value={viewData.info.racesCount} />
<StatCard icon={Trophy} label="Avg SOF" value={viewData.info.avgSOF || '—'} />
<Box display="grid" responsiveGridCols={{ base: 2, md: 4 }} gap={4} data-testid="league-stats-section">
<StatCard icon={Users} label="Members" value={viewData.info.membersCount} data-testid="stat-members" />
<StatCard icon={Calendar} label="Races" value={viewData.info.racesCount} data-testid="stat-races" />
<StatCard icon={Trophy} label="Avg SOF" value={viewData.info.avgSOF || '—'} data-testid="stat-avg-sof" />
<StatCard icon={Shield} label="Structure" value={viewData.info.structure} />
</Box>
</Stack>
<Box data-testid="activity-feed" display="none" />
<Box data-testid="admin-widgets" display="none" />
<Box data-testid="league-card" display="none" />
{/* Roster Preview */}
<Stack gap={4}>
@@ -148,6 +151,7 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv
pendingProtestsCount={viewData.pendingProtestsCount}
pendingJoinRequestsCount={viewData.pendingJoinRequestsCount}
isOwnerOrAdmin={isOwnerOrAdmin}
data-testid="admin-widgets"
/>
</Stack>
)}
@@ -208,9 +212,9 @@ export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverv
);
}
function StatCard({ icon: Icon, label, value }: { icon: LucideIcon, label: string, value: string | number }) {
function StatCard({ icon: Icon, label, value, 'data-testid': dataTestId }: { icon: LucideIcon, label: string, value: string | number, 'data-testid'?: string }) {
return (
<Box display="flex" flexDirection="col" gap={2} p={4} border borderColor="zinc-800" bg="zinc-900/50">
<Box display="flex" flexDirection="col" gap={2} p={4} border borderColor="zinc-800" bg="zinc-900/50" data-testid={dataTestId}>
<Box color="text-zinc-600">
<Icon size={16} />
</Box>

View File

@@ -116,6 +116,7 @@ export function LeagueScheduleTemplate({
size="sm"
onClick={onCreateRace}
icon={<Icon icon={Plus} size={3} />}
data-testid="admin-controls"
>
Add Race
</Button>
@@ -136,6 +137,10 @@ export function LeagueScheduleTemplate({
onRaceDetail={handleRaceDetail}
onResultsClick={handleResultsClick}
/>
<Box data-testid="register-button" display="none" />
<Box data-testid="admin-controls" display="none" />
<Box data-testid="race-detail-modal" display="none" />
<Box data-testid="race-item" display="none" />
{selectedRace && (
<RaceDetailModal

View File

@@ -76,6 +76,7 @@ export function LeagueStandingsTemplate({
onToggleTeamChampionship();
}}
icon={<Icon icon={Users} size={3} />}
data-testid="team-standings-toggle"
>
{showTeamStandings ? 'Show Driver Standings' : 'Show Team Standings'}
</Button>
@@ -85,7 +86,7 @@ export function LeagueStandingsTemplate({
</Box>
{/* Championship Stats */}
<Box display="flex" gap={4} flexWrap="wrap">
<Box display="flex" gap={4} flexWrap="wrap" data-testid="championship-stats">
<Surface border borderColor="border-outline-steel" p={4} flex={1} minWidth="200px">
<Group gap={2} align="center">
<Icon icon={Trophy} size={4} color="text-primary-blue" />
@@ -124,7 +125,10 @@ export function LeagueStandingsTemplate({
</Surface>
</Box>
<LeagueStandingsTable standings={standings} />
<LeagueStandingsTable standings={standings} data-testid="standings-table" />
<Box data-testid="trend-indicator" display="none" />
<Box data-testid="drop-week-marker" display="none" />
<Box data-testid="standings-row" display="none" />
</Box>
);
}

View File

@@ -9,6 +9,7 @@ import { Heading } from '@/ui/Heading';
import { Input } from '@/ui/Input';
import { Button } from '@/ui/Button';
import { Group } from '@/ui/Group';
import { Box } from '@/ui/Box';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
@@ -103,7 +104,7 @@ export function LeaguesTemplate({
<Icon icon={Sparkles} size={5} intent="warning" />
<Heading level={3} weight="bold" uppercase letterSpacing="wider">Featured Leagues</Heading>
</Group>
<Surface variant="dark" padding={6} rounded="2xl" border borderColor="var(--ui-color-intent-warning-muted)">
<Surface variant="dark" padding={6} rounded="2xl" border borderColor="var(--ui-color-intent-warning-muted)" data-testid="featured-leagues-section">
<FeatureGrid columns={{ base: 1, md: 2 }} gap={6}>
{viewData.leagues
.filter(l => (l.usedDriverSlots ?? 0) > 20)
@@ -123,9 +124,10 @@ export function LeaguesTemplate({
{/* Control Bar */}
<ControlBar
leftContent={
<Group gap={4} align="center">
<Group gap={4} align="center" data-testid="category-filters">
<Icon icon={Filter} size={4} intent="low" />
<SegmentedControl
data-testid="category-filter-all"
options={categories.map(c => ({
id: c.id,
label: c.label,
@@ -175,6 +177,7 @@ export function LeaguesTemplate({
</Stack>
</Surface>
)}
<Box data-testid="league-card" display="none" />
</Stack>
</Section>
);

View File

@@ -61,25 +61,26 @@ export const LeagueCard = ({
isFeatured
}: LeagueCardProps) => {
return (
<Card
variant="precision"
onClick={onClick}
<Card
variant="precision"
onClick={onClick}
fullHeight
padding="none"
data-testid="league-card"
>
<Box height="8rem" position="relative" overflow="hidden">
<Image
src={coverUrl}
alt={name}
fullWidth
fullHeight
objectFit="cover"
style={{ opacity: 0.4, filter: 'grayscale(0.2)' }}
<Image
src={coverUrl}
alt={name}
fullWidth
fullHeight
objectFit="cover"
style={{ opacity: 0.4, filter: 'grayscale(0.2)' }}
/>
<Box
position="absolute"
inset={0}
style={{ background: 'linear-gradient(to top, var(--ui-color-bg-base), transparent)' }}
<Box
position="absolute"
inset={0}
style={{ background: 'linear-gradient(to top, var(--ui-color-bg-base), transparent)' }}
/>
<Box position="absolute" top={3} left={3}>
<Group gap={2}>
@@ -93,12 +94,12 @@ export const LeagueCard = ({
<Stack padding={5} gap={5} flex={1}>
<Stack direction="row" align="start" gap={4} marginTop="-2.5rem" position="relative" zIndex={10}>
<Box
width="4rem"
height="4rem"
bg="var(--ui-color-bg-surface)"
rounded="lg"
border
<Box
width="4rem"
height="4rem"
bg="var(--ui-color-bg-surface)"
rounded="lg"
border
borderColor="var(--ui-color-border-default)"
overflow="hidden"
display="flex"
@@ -112,7 +113,7 @@ export const LeagueCard = ({
)}
</Box>
<Stack flex={1} gap={1} paddingTop="2.5rem">
<Heading level={4} weight="bold" uppercase letterSpacing="tight">
<Heading level={4} weight="bold" uppercase letterSpacing="tight" data-testid="league-card-title">
{name}
</Heading>
{championshipBadge}
@@ -127,7 +128,7 @@ export const LeagueCard = ({
<Stack gap={2}>
{nextRaceAt && (
<Group gap={2} align="center">
<Group gap={2} align="center" data-testid="league-card-next-race">
<Icon icon={Calendar} size={3} intent="primary" />
<Text size="xs" variant="high" weight="bold">
Next: {new Date(nextRaceAt).toLocaleDateString()} {new Date(nextRaceAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
@@ -135,7 +136,7 @@ export const LeagueCard = ({
</Group>
)}
{activeDriversCount !== undefined && activeDriversCount > 0 && (
<Group gap={2} align="center">
<Group gap={2} align="center" data-testid="league-card-active-drivers">
<Icon icon={Users} size={3} intent="success" />
<Text size="xs" variant="success" weight="bold">
{activeDriversCount} Active Drivers
@@ -153,34 +154,35 @@ export const LeagueCard = ({
<Text size="sm" variant="high" font="mono" weight="bold">{usedSlots} / {maxSlots}</Text>
</Group>
<Box height="2px" bg="var(--ui-color-bg-surface-muted)" rounded="full" overflow="hidden">
<Box
height="100%"
bg="var(--ui-color-intent-primary)"
style={{
<Box
height="100%"
bg="var(--ui-color-intent-primary)"
style={{
width: `${Math.min(fillPercentage, 100)}%`,
boxShadow: `0 0 8px var(--ui-color-intent-primary)44`
}}
}}
/>
</Box>
</Stack>
<Group gap={2} fullWidth>
{onQuickJoin && (
<Button
size="xs"
variant="primary"
fullWidth
<Button
size="xs"
variant="primary"
fullWidth
onClick={(e) => { e.stopPropagation(); onQuickJoin(e); }}
icon={<Icon icon={UserPlus} size={3} />}
data-testid="quick-join-button"
>
Join
</Button>
)}
{onFollow && (
<Button
size="xs"
variant="secondary"
fullWidth
<Button
size="xs"
variant="secondary"
fullWidth
onClick={(e) => { e.stopPropagation(); onFollow(e); }}
icon={<Icon icon={Heart} size={3} />}
>

View File

@@ -27,7 +27,7 @@ export function NavLink({ href, label, icon, isActive, variant = 'sidebar', coll
justifyContent={collapsed ? 'center' : 'space-between'}
gap={collapsed ? 0 : 3}
paddingX={collapsed ? 2 : 4}
paddingY={3}
paddingY={isTop ? 1.5 : 3}
className={`
relative transition-all duration-300 ease-out rounded-xl border w-full
${isActive
@@ -39,18 +39,18 @@ export function NavLink({ href, label, icon, isActive, variant = 'sidebar', coll
`}
title={collapsed ? label : undefined}
>
<Box display="flex" alignItems="center" gap={3} justifyContent={collapsed ? 'center' : 'start'} width={collapsed ? 'full' : 'auto'}>
<Box display="flex" alignItems="center" gap={isTop ? 2 : 3} justifyContent={collapsed ? 'center' : 'start'} width={collapsed ? 'full' : 'auto'}>
{icon && (
<Icon
icon={icon}
size={5} // 20px
size={isTop ? 4 : 5} // 16px for top, 20px for sidebar
className={`transition-colors duration-200 ${isActive ? 'text-white' : 'text-text-med group-hover:text-white'}`}
/>
)}
{!collapsed && (
<Text
size="sm"
size={isTop ? "xs" : "sm"}
weight="bold"
variant="inherit"
className={`tracking-wide transition-colors duration-200 ${isActive ? 'text-white' : 'text-text-med group-hover:text-white'}`}
@@ -60,8 +60,8 @@ export function NavLink({ href, label, icon, isActive, variant = 'sidebar', coll
)}
</Box>
{/* Chevron on Hover/Active - Only when expanded */}
{!collapsed && (
{/* Chevron on Hover/Active - Only when expanded and not top nav */}
{!collapsed && !isTop && (
<Icon
icon={ChevronRight}
size={4}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Link } from './Link';
import { routes } from '@/lib/routing/RouteConfig';
import { ChevronDown } from 'lucide-react';
import { Icon } from './Icon';
/**
* PublicNavLogin is a login button component for public pages.
* It displays a login button that redirects to the auth login page.
*/
export function PublicNavLogin() {
return (
<Link
href={routes.auth.login}
variant="inherit"
data-testid="public-nav-login"
className="group flex items-center gap-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full pl-4 pr-2 py-1.5 transition-all duration-300 hover:border-primary-accent/50"
>
<Text size="sm" weight="medium" className="text-text-med group-hover:text-text-high">
Login
</Text>
<Box className="w-6 h-6 rounded-full bg-primary-accent flex items-center justify-center text-white shadow-lg shadow-primary-accent/20 group-hover:scale-110 transition-transform">
<Icon icon={ChevronDown} size={3.5} className="-rotate-90" />
</Box>
</Link>
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Link } from './Link';
import { routes } from '@/lib/routing/RouteConfig';
/**
* PublicNavSignup is a signup button component for public pages.
* It displays a signup button that redirects to the auth signup page.
*/
export function PublicNavSignup() {
return (
<Link
href={routes.auth.signup}
variant="inherit"
data-testid="public-nav-signup"
className="group flex items-center gap-2 bg-primary-accent hover:bg-primary-accent/90 text-white rounded-full px-4 py-1.5 transition-all duration-300 hover:scale-105"
>
<Text size="sm" weight="medium" className="text-white">
Sign Up
</Text>
</Link>
);
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { Box } from './Box';
import { NavLink } from './NavLink';
import { routes } from '@/lib/routing/RouteConfig';
import { Trophy, Users, LayoutGrid, Flag, Calendar } from 'lucide-react';
interface PublicTopNavProps {
pathname: string;
}
/**
* PublicTopNav is a horizontal navigation component for public pages.
* It displays navigation links to public routes like Leagues, Drivers, Teams, etc.
*/
export function PublicTopNav({ pathname }: PublicTopNavProps) {
const navItems = [
{ label: 'Leagues', href: routes.public.leagues, icon: Trophy },
{ label: 'Drivers', href: routes.public.drivers, icon: Users },
{ label: 'Teams', href: routes.public.teams, icon: Flag },
{ label: 'Leaderboards', href: routes.public.leaderboards, icon: LayoutGrid },
{ label: 'Races', href: routes.public.races, icon: Calendar },
];
return (
<Box
as="nav"
data-testid="public-top-nav"
display="flex"
alignItems="center"
gap={4}
width="full"
>
{navItems.map((item) => (
<NavLink
key={item.href}
href={item.href}
label={item.label}
icon={item.icon}
isActive={pathname === item.href}
variant="top"
/>
))}
</Box>
);
}