resolve todos in website and api

This commit is contained in:
2025-12-20 10:45:56 +01:00
parent 656ec62426
commit 7bbad511e2
62 changed files with 2036 additions and 611 deletions

View File

@@ -1,7 +1,5 @@
'use client';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { Notification } from '@core/notifications/application';
import {
AlertTriangle,
Bell,
@@ -36,33 +34,14 @@ const notificationColors: Record<string, string> = {
race_reminder: 'text-warning-amber bg-warning-amber/10',
};
import { useNotifications } from './NotificationProvider';
import type { Notification } from './NotificationProvider';
export default function NotificationCenter() {
const [isOpen, setIsOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const currentDriverId = useEffectiveDriverId();
// Polling for new notifications
// TODO
// useEffect(() => {
// const loadNotifications = async () => {
// try {
// const repo = getNotificationRepository();
// const allNotifications = await repo.findByRecipientId(currentDriverId);
// setNotifications(allNotifications);
// } catch (error) {
// console.error('Failed to load notifications:', error);
// }
// };
// loadNotifications();
// // Poll every 5 seconds
// const interval = setInterval(loadNotifications, 5000);
// return () => clearInterval(interval);
// }, [currentDriverId]);
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications();
// Close panel when clicking outside
useEffect(() => {
@@ -81,42 +60,9 @@ export default function NotificationCenter() {
};
}, [isOpen]);
const unreadCount = notifications.filter((n) => n.isUnread()).length;
const handleNotificationClick = (notification: Notification) => {
markAsRead(notification.id);
const handleMarkAsRead = async (notification: Notification) => {
if (!notification.isUnread()) return;
try {
const markRead = getMarkNotificationReadUseCase();
await markRead.execute({
notificationId: notification.id,
recipientId: currentDriverId,
});
// Update local state
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? n.markAsRead() : n))
);
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const handleMarkAllAsRead = async () => {
try {
const repo = getNotificationRepository();
await repo.markAllAsReadByRecipientId(currentDriverId);
// Update local state
setNotifications((prev) => prev.map((n) => n.markAsRead()));
} catch (error) {
console.error('Failed to mark all as read:', error);
}
};
const handleNotificationClick = async (notification: Notification) => {
await handleMarkAsRead(notification);
if (notification.actionUrl) {
router.push(notification.actionUrl);
setIsOpen(false);
@@ -176,7 +122,7 @@ export default function NotificationCenter() {
</div>
{unreadCount > 0 && (
<button
onClick={handleMarkAllAsRead}
onClick={markAllAsRead}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-white transition-colors"
>
<CheckCheck className="w-3.5 h-3.5" />
@@ -209,7 +155,7 @@ export default function NotificationCenter() {
onClick={() => handleNotificationClick(notification)}
className={`
w-full text-left px-4 py-3 transition-colors hover:bg-iron-gray/30
${notification.isUnread() ? 'bg-primary-blue/5' : ''}
${!notification.read ? 'bg-primary-blue/5' : ''}
`}
>
<div className="flex gap-3">
@@ -219,16 +165,16 @@ export default function NotificationCenter() {
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={`text-sm font-medium truncate ${
notification.isUnread() ? 'text-white' : 'text-gray-300'
!notification.read ? 'text-white' : 'text-gray-300'
}`}>
{notification.title}
</p>
{notification.isUnread() && (
{!notification.read && (
<span className="w-2 h-2 bg-primary-blue rounded-full flex-shrink-0 mt-1.5" />
)}
</div>
<p className="text-xs text-gray-500 line-clamp-2 mt-0.5">
{notification.body}
{notification.message}
</p>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-[10px] text-gray-600">

View File

@@ -1,21 +1,55 @@
'use client';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { v4 as uuid } from 'uuid';
import type { Notification } from '@core/notifications/application';
import ModalNotification from './ModalNotification';
import ToastNotification from './ToastNotification';
export type NotificationVariant = 'toast' | 'modal' | 'center';
export interface NotificationAction {
id: string;
label: string;
type?: 'primary' | 'secondary' | 'danger';
href?: string;
}
export interface Notification {
id: string;
type: string;
title?: string;
message: string;
createdAt: Date;
variant: NotificationVariant;
actionUrl?: string;
requiresResponse?: boolean;
actions?: NotificationAction[];
data?: Record<string, unknown>;
read: boolean;
}
interface AddNotificationInput {
id?: string;
type: string;
title?: string;
message: string;
createdAt?: Date;
variant?: NotificationVariant;
actionUrl?: string;
requiresResponse?: boolean;
actions?: NotificationAction[];
data?: Record<string, unknown>;
}
interface NotificationContextValue {
notifications: Notification[];
unreadCount: number;
toastNotifications: Notification[];
modalNotification: Notification | null;
markAsRead: (notification: Notification) => Promise<void>;
dismissToast: (notification: Notification) => void;
respondToModal: (notification: Notification, actionId?: string) => Promise<void>;
dismissModal: (notification: Notification) => Promise<void>;
addNotification: (input: AddNotificationInput) => string;
dismissNotification: (id: string) => void;
clearNotifications: () => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
}
const NotificationContext = createContext<NotificationContextValue | null>(null);
@@ -34,133 +68,85 @@ interface NotificationProviderProps {
export default function NotificationProvider({ children }: NotificationProviderProps) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [toastNotifications, setToastNotifications] = useState<Notification[]>([]);
const [modalNotification, setModalNotification] = useState<Notification | null>(null);
const [seenNotificationIds, setSeenNotificationIds] = useState<Set<string>>(new Set());
const currentDriverId = useEffectiveDriverId();
// Poll for new notifications
// TODO
// useEffect(() => {
// const loadNotifications = async () => {
// try {
// const repo = getNotificationRepository();
// const allNotifications = await repo.findByRecipientId(currentDriverId);
// setNotifications(allNotifications);
const addNotification = useCallback((input: AddNotificationInput): string => {
const id = input.id ?? uuid();
// // Check for new notifications that need toast/modal display
// allNotifications.forEach((notification) => {
// // Check both unread and action_required status for modals
// const shouldDisplay = (notification.isUnread() || notification.isActionRequired()) &&
// !seenNotificationIds.has(notification.id);
// if (shouldDisplay) {
// // Mark as seen to prevent duplicate displays
// setSeenNotificationIds((prev) => new Set([...prev, notification.id]));
const notification: Notification = {
id,
type: input.type,
title: input.title,
message: input.message,
createdAt: input.createdAt ?? new Date(),
variant: input.variant ?? 'toast',
actionUrl: input.actionUrl,
requiresResponse: input.requiresResponse,
actions: input.actions,
data: input.data,
read: false,
};
// // Handle based on urgency
// if (notification.isModal()) {
// // Modal takes priority - show immediately
// setModalNotification(notification);
// } else if (notification.isToast()) {
// // Add to toast queue
// setToastNotifications((prev) => [...prev, notification]);
// }
// // Silent notifications just appear in the notification center
// }
// });
// } catch (error) {
// console.error('Failed to load notifications:', error);
// }
// };
setNotifications((prev) => [notification, ...prev]);
// loadNotifications();
// // Poll every 2 seconds for responsiveness
// const interval = setInterval(loadNotifications, 2000);
// return () => clearInterval(interval);
// }, [currentDriverId, seenNotificationIds]);
return id;
}, []);
const dismissNotification = useCallback((id: string) => {
setNotifications((prev) => prev.filter((notification) => notification.id !== id));
}, []);
const clearNotifications = useCallback(() => {
setNotifications([]);
}, []);
const markAsRead = useCallback((id: string) => {
setNotifications((prev) =>
prev.map((notification) =>
notification.id === id ? { ...notification, read: true } : notification,
),
);
}, []);
const markAllAsRead = useCallback(() => {
setNotifications((prev) => prev.map((notification) => ({ ...notification, read: true })));
}, []);
const unreadCount = useMemo(
() => notifications.filter((notification) => !notification.read).length,
[notifications],
);
const modalNotification = useMemo(
() => notifications.find((notification) => notification.variant === 'modal' && !notification.read) ?? null,
[notifications],
);
const toastNotifications = useMemo(
() => notifications.filter((notification) => notification.variant === 'toast' && !notification.read),
[notifications],
);
// Prevent body scroll when modal is open
useEffect(() => {
if (modalNotification) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
if (!modalNotification) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [modalNotification]);
const markAsRead = useCallback(async (notification: Notification) => {
try {
const markRead = getMarkNotificationReadUseCase();
await markRead.execute({
notificationId: notification.id,
recipientId: currentDriverId,
});
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? n.markAsRead() : n))
);
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
}, [currentDriverId]);
const dismissToast = useCallback((notification: Notification) => {
setToastNotifications((prev) => prev.filter((n) => n.id !== notification.id));
}, []);
const respondToModal = useCallback(async (notification: Notification, actionId?: string) => {
try {
// Mark as responded
const repo = getNotificationRepository();
const updated = notification.markAsResponded(actionId);
await repo.update(updated);
// Update local state
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? updated : n))
);
// Clear modal
setModalNotification(null);
} catch (error) {
console.error('Failed to respond to notification:', error);
}
}, []);
const dismissModal = useCallback(async (notification: Notification) => {
try {
// Dismiss the notification
const repo = getNotificationRepository();
const updated = notification.dismiss();
await repo.update(updated);
// Update local state
setNotifications((prev) =>
prev.map((n) => (n.id === notification.id ? updated : n))
);
// Clear modal
setModalNotification(null);
} catch (error) {
console.error('Failed to dismiss notification:', error);
}
}, []);
const unreadCount = notifications.filter((n) => n.isUnread() || n.isActionRequired()).length;
const value: NotificationContextValue = {
notifications,
unreadCount,
toastNotifications,
modalNotification,
addNotification,
dismissNotification,
clearNotifications,
markAsRead,
dismissToast,
respondToModal,
dismissModal,
markAllAsRead,
};
return (
@@ -173,8 +159,8 @@ export default function NotificationProvider({ children }: NotificationProviderP
<ToastNotification
key={notification.id}
notification={notification}
onDismiss={dismissToast}
onRead={markAsRead}
onDismiss={() => dismissNotification(notification.id)}
onRead={() => markAsRead(notification.id)}
/>
))}
</div>
@@ -183,8 +169,17 @@ export default function NotificationProvider({ children }: NotificationProviderP
{modalNotification && (
<ModalNotification
notification={modalNotification}
onAction={respondToModal}
onDismiss={dismissModal}
onAction={(notification, actionId) => {
// For now we just mark as read and optionally navigate via ModalNotification
markAsRead(notification.id);
if (actionId === 'dismiss') {
dismissNotification(notification.id);
}
}}
onDismiss={(notification) => {
markAsRead(notification.id);
dismissNotification(notification.id);
}}
/>
)}
</NotificationContext.Provider>

View File

@@ -9,6 +9,8 @@ export interface ProtestDecisionData {
stewardNotes: string;
}
const DEFAULT_PROTEST_REASON = 'Protest upheld';
export class ProtestDecisionCommandModel {
decision: 'uphold' | 'dismiss' | null = null;
penaltyType: PenaltyType = 'time_penalty';
@@ -38,13 +40,17 @@ export class ProtestDecisionCommandModel {
}
toApplyPenaltyCommand(raceId: string, driverId: string, stewardId: string, protestId: string): ApplyPenaltyCommandDTO {
const reason = this.decision === 'uphold'
? DEFAULT_PROTEST_REASON
: 'Protest dismissed';
return {
raceId,
driverId,
stewardId,
type: this.penaltyType,
value: this.getPenaltyValue(),
reason: 'Protest upheld', // TODO: Make this configurable
reason,
protestId,
notes: this.stewardNotes,
};

View File

@@ -1,9 +1,7 @@
import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionViewModel } from '../../view-models/SessionViewModel';
// TODO: Create DTOs for login/signup params in apps/website/lib/types/generated
type LoginParamsDto = { email: string; password: string };
type SignupParamsDto = { email: string; password: string; displayName: string };
import type { LoginParams } from '../../types/generated/LoginParams';
import type { SignupParams } from '../../types/generated/SignupParams';
/**
* Auth Service
@@ -19,7 +17,7 @@ export class AuthService {
/**
* Sign up a new user
*/
async signup(params: SignupParamsDto): Promise<SessionViewModel> {
async signup(params: SignupParams): Promise<SessionViewModel> {
try {
const dto = await this.apiClient.signup(params);
return new SessionViewModel(dto.user);
@@ -31,7 +29,7 @@ export class AuthService {
/**
* Log in an existing user
*/
async login(params: LoginParamsDto): Promise<SessionViewModel> {
async login(params: LoginParams): Promise<SessionViewModel> {
try {
const dto = await this.apiClient.login(params);
return new SessionViewModel(dto.user);
@@ -57,4 +55,4 @@ export class AuthService {
getIracingAuthUrl(returnTo?: string): string {
return this.apiClient.getIracingAuthUrl(returnTo);
}
}
}

View File

@@ -1,20 +1,12 @@
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
import { CompleteOnboardingInputDTO } from "@/lib/types/generated/CompleteOnboardingInputDTO";
import { DriverProfileDTO } from "@/lib/types/generated/DriverProfileDTO";
import type { DriverDTO } from "@/lib/types/generated/DriverDTO";
import { CompleteOnboardingViewModel } from "@/lib/view-models/CompleteOnboardingViewModel";
import { DriverLeaderboardViewModel } from "@/lib/view-models/DriverLeaderboardViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { DriverProfileViewModel } from "@/lib/view-models/DriverProfileViewModel";
// TODO: Create proper DriverDTO in generated types
type DriverDTO = {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
};
/**
* Driver Service
*

View File

@@ -50,13 +50,23 @@ export class LeagueService {
* Get league standings with view model transformation
*/
async getLeagueStandings(leagueId: string, currentUserId: string): Promise<LeagueStandingsViewModel> {
// Core standings (positions, points, driverIds)
const dto = await this.apiClient.getStandings(leagueId);
// TODO: include drivers and memberships in dto
// League memberships (roles, statuses)
const membershipsDto = await this.apiClient.getMemberships(leagueId);
// Resolve unique drivers that appear in standings
const driverIds = Array.from(new Set(dto.standings.map(entry => entry.driverId)));
const driverDtos = await Promise.all(driverIds.map(id => this.driversApiClient.getDriver(id)));
const drivers = driverDtos.filter((d): d is NonNullable<typeof d> => d !== null);
const dtoWithExtras = {
...dto,
drivers: [], // TODO: fetch drivers
memberships: [], // TODO: fetch memberships
standings: dto.standings,
drivers,
memberships: membershipsDto.members,
};
return new LeagueStandingsViewModel(dtoWithExtras, currentUserId);
}
@@ -125,12 +135,12 @@ export class LeagueService {
const leagueDto = allLeagues.leagues.find(l => l.id === leagueId);
if (!leagueDto) return null;
// Assume league has description, ownerId - need to update DTO
// LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided
const league = {
id: leagueDto.id,
name: leagueDto.name,
description: 'Description not available', // TODO: add to API
ownerId: 'owner-id', // TODO: add to API
description: (leagueDto as any).description ?? 'Description not available',
ownerId: (leagueDto as any).ownerId ?? 'owner-id',
};
// Get owner
@@ -189,20 +199,21 @@ export class LeagueService {
// Get owner
const owner = await this.driversApiClient.getDriver(league.ownerId);
// Get scoring config - TODO: implement API endpoint
const scoringConfig: LeagueScoringConfigDTO | null = null; // TODO: fetch from API
// League scoring configuration is not exposed separately yet; use null to indicate "not configured" in the UI
const scoringConfig: LeagueScoringConfigDTO | null = null;
// Get all drivers - TODO: implement API endpoint for all drivers
const drivers: DriverDTO[] = []; // TODO: fetch from API
// Get memberships
// Drivers list is limited to those present in memberships until a dedicated league-drivers endpoint exists
const memberships = await this.apiClient.getMemberships(leagueId);
const driverIds = memberships.members.map(m => m.driverId);
const driverDtos = await Promise.all(driverIds.map(id => this.driversApiClient.getDriver(id)));
const drivers = driverDtos.filter((d): d is NonNullable<typeof d> => d !== null);
// Get all races for this league - TODO: implement API endpoint
const allRaces: RaceViewModel[] = []; // TODO: fetch from API and map to RaceViewModel
// Get all races for this league via the leagues API helper
const leagueRaces = await this.apiClient.getRaces(leagueId);
const allRaces = leagueRaces.races.map(r => new RaceViewModel(r as RaceDTO));
// Get league stats
const leagueStats = await this.apiClient.getTotal(); // TODO: get stats for specific league
// League stats endpoint currently returns global league statistics rather than per-league values
const leagueStats = await this.apiClient.getTotal();
// Get sponsors
const sponsors = await this.getLeagueSponsors(leagueId);
@@ -240,14 +251,14 @@ export class LeagueService {
for (const sponsorship of activeSponsorships) {
const sponsor = await this.sponsorsApiClient.getSponsor(sponsorship.sponsorId);
if (sponsor) {
// TODO: Get tagline from testing support or API
// Tagline is not supplied by the sponsor API in this build; callers may derive one from marketing content if needed
sponsorInfos.push({
id: sponsor.id,
name: sponsor.name,
logoUrl: sponsor.logoUrl ?? '',
websiteUrl: sponsor.websiteUrl ?? '',
tier: sponsorship.tier,
tagline: '', // TODO: fetch tagline
tagline: '',
});
}
}

View File

@@ -23,16 +23,15 @@ export class LeagueSettingsService {
*/
async getLeagueSettings(leagueId: string): Promise<LeagueSettingsViewModel | null> {
try {
// Get league basic info
// Get league basic info (includes ownerId in DTO)
const allLeagues = await this.leaguesApiClient.getAllWithCapacity();
const leagueDto = allLeagues.leagues.find(l => l.id === leagueId);
if (!leagueDto) return null;
// Assume league has ownerId - need to update API
const league = {
id: leagueDto.id,
name: leagueDto.name,
ownerId: 'owner-id', // TODO: add to API
ownerId: leagueDto.ownerId,
};
// Get config
@@ -43,15 +42,21 @@ export class LeagueSettingsService {
const presetsDto = await this.leaguesApiClient.getScoringPresets();
const presets: LeagueScoringPresetDTO[] = presetsDto.presets;
// Get leaderboard once so we can hydrate rating / rank for owner + members
const leaderboardDto = await this.driversApiClient.getLeaderboard();
const leaderboardByDriverId = new Map(
leaderboardDto.drivers.map(driver => [driver.id, driver])
);
// Get owner
const ownerDriver = await this.driversApiClient.getDriver(league.ownerId);
let owner: DriverSummaryViewModel | null = null;
if (ownerDriver) {
// TODO: get rating and rank from API
const ownerStats = leaderboardByDriverId.get(ownerDriver.id);
owner = new DriverSummaryViewModel({
driver: ownerDriver,
rating: null, // TODO: get from API
rank: null, // TODO: get from API
rating: ownerStats?.rating ?? null,
rank: ownerStats?.rank ?? null,
});
}
@@ -62,10 +67,11 @@ export class LeagueSettingsService {
if (member.driverId !== league.ownerId && member.role !== 'owner') {
const driver = await this.driversApiClient.getDriver(member.driverId);
if (driver) {
const memberStats = leaderboardByDriverId.get(driver.id);
members.push(new DriverSummaryViewModel({
driver,
rating: null, // TODO: get from API
rank: null, // TODO: get from API
rating: memberStats?.rating ?? null,
rank: memberStats?.rank ?? null,
}));
}
}

View File

@@ -1,12 +1,10 @@
import { RequestAvatarGenerationInputDTO } from '@/lib/types/generated/RequestAvatarGenerationInputDTO';
import { UpdateAvatarInputDTO } from '@/lib/types/generated/UpdateAvatarInputDTO';
import { AvatarViewModel } from '@/lib/view-models/AvatarViewModel';
import { RequestAvatarGenerationViewModel } from '@/lib/view-models/RequestAvatarGenerationViewModel';
import { UpdateAvatarViewModel } from '@/lib/view-models/UpdateAvatarViewModel';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
// TODO: Move these types to apps/website/lib/types/generated when available
type UpdateAvatarInputDto = { driverId: string; avatarUrl: string };
/**
* Avatar Service
*
@@ -37,7 +35,7 @@ export class AvatarService {
/**
* Update avatar for driver with view model transformation
*/
async updateAvatar(input: UpdateAvatarInputDto): Promise<UpdateAvatarViewModel> {
async updateAvatar(input: UpdateAvatarInputDTO): Promise<UpdateAvatarViewModel> {
const dto = await this.apiClient.updateAvatar(input);
return new UpdateAvatarViewModel(dto);
}

View File

@@ -3,8 +3,8 @@ import { MediaViewModel } from '@/lib/view-models/MediaViewModel';
import { UploadMediaViewModel } from '@/lib/view-models/UploadMediaViewModel';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
// TODO: Move these types to apps/website/lib/types/generated when available
type UploadMediaInputDto = { file: File; type: string; category?: string };
// Local request shape mirroring the media upload API contract until a generated type is available
type UploadMediaRequest = { file: File; type: string; category?: string };
/**
* Media Service
@@ -20,7 +20,7 @@ export class MediaService {
/**
* Upload media file with view model transformation
*/
async uploadMedia(input: UploadMediaInputDto): Promise<UploadMediaViewModel> {
async uploadMedia(input: UploadMediaRequest): Promise<UploadMediaViewModel> {
const dto = await this.apiClient.uploadMedia(input);
return new UploadMediaViewModel(dto);
}

View File

@@ -1,11 +1,12 @@
import { MembershipFeeDto } from '@/lib/types/generated/MembershipFeeDto';
import type { MemberPaymentDto } from '@/lib/types/generated/MemberPaymentDto';
import { MembershipFeeViewModel } from '@/lib/view-models/MembershipFeeViewModel';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
// TODO: This DTO should be generated from OpenAPI spec when the endpoint is added
// Response shape as returned by the membership-fees payments endpoint; mirrors the API contract until a generated type is introduced
export interface GetMembershipFeesOutputDto {
fee: MembershipFeeDto | null;
payments: import('./MemberPaymentDto').MemberPaymentDto[];
payments: MemberPaymentDto[];
}
/**
@@ -22,11 +23,12 @@ export class MembershipFeeService {
/**
* Get membership fees by league ID with view model transformation
*/
async getMembershipFees(leagueId: string): Promise<{ fee: MembershipFeeViewModel | null; payments: any[] }> {
const dto = await this.apiClient.getMembershipFees({ leagueId });
async getMembershipFees(leagueId: string): Promise<{ fee: MembershipFeeViewModel | null; payments: MemberPaymentDto[] }> {
const dto: GetMembershipFeesOutputDto = await this.apiClient.getMembershipFees({ leagueId });
return {
fee: dto.fee ? new MembershipFeeViewModel(dto.fee) : null,
payments: dto.payments // TODO: map to view models if needed
// Expose raw member payment DTOs; callers may map these into UI-specific view models if needed
payments: dto.payments,
};
}
}

View File

@@ -6,8 +6,8 @@ import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import type { PaymentDTO } from '../../types/generated/PaymentDto';
import type { PrizeDto } from '../../types/generated/PrizeDto';
// TODO: Move these types to apps/website/lib/types/generated when available
type CreatePaymentInputDto = {
// Local payment creation request matching the Payments API contract until a shared generated type is introduced
type CreatePaymentRequest = {
type: 'sponsorship' | 'membership_fee';
amount: number;
payerId: string;
@@ -53,7 +53,7 @@ export class PaymentService {
/**
* Create a new payment
*/
async createPayment(input: CreatePaymentInputDto): Promise<PaymentViewModel> {
async createPayment(input: CreatePaymentRequest): Promise<PaymentViewModel> {
const dto = await this.apiClient.createPayment(input);
return new PaymentViewModel(dto.payment);
}

View File

@@ -2,20 +2,7 @@ import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
import { RacesPageViewModel } from '../../view-models/RacesPageViewModel';
import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel';
// TODO: Move these types to apps/website/lib/types/generated when available
type RacesPageDataRaceDTO = {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId: string;
leagueName: string;
};
type RacesPageDataDto = { races: RacesPageDataRaceDTO[] };
type RaceStatsDTO = { totalRaces: number };
import type { RaceStatsDTO } from '../../types/generated/RaceStatsDTO';
/**
* Race Service
*
@@ -94,11 +81,12 @@ export class RaceService {
/**
* Find races by league ID
*
* The races API does not currently expose a league-filtered listing endpoint in this build,
* so this method deliberately signals that the operation is unavailable instead of making
* assumptions about URL structure.
*/
async findByLeagueId(leagueId: string): Promise<any[]> {
// Assuming the API has /races?leagueId=...
// TODO: Update when API is implemented
const dto = await this.apiClient.get('/races?leagueId=' + leagueId) as { races: any[] };
return dto.races;
async findByLeagueId(_leagueId: string): Promise<never> {
throw new Error('Finding races by league ID is not supported in this build');
}
}

View File

@@ -1,7 +1,8 @@
import { TeamJoinRequestViewModel, type TeamJoinRequestDTO } from '@/lib/view-models/TeamJoinRequestViewModel';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
// TODO: Create generated DTO when API spec is available
// Wrapper for the team join requests collection returned by the teams API in this build
// Mirrors the current API response shape until a generated DTO is available.
type TeamJoinRequestsDto = {
requests: TeamJoinRequestDTO[];
};
@@ -27,17 +28,21 @@ export class TeamJoinService {
/**
* Approve a team join request
*
* The teams API currently exposes read-only join requests in this build; approving
* a request requires a future management endpoint, so this method fails explicitly.
*/
async approveJoinRequest(): Promise<void> {
// TODO: implement API call when endpoint is available
throw new Error('Not implemented: API endpoint for approving join requests');
async approveJoinRequest(): Promise<never> {
throw new Error('Approving team join requests is not supported in this build');
}
/**
* Reject a team join request
*
* Rejection of join requests is also not available yet on the backend, so callers
* must treat this as an unsupported operation rather than a silent no-op.
*/
async rejectJoinRequest(): Promise<void> {
// TODO: implement API call when endpoint is available
throw new Error('Not implemented: API endpoint for rejecting join requests');
async rejectJoinRequest(): Promise<never> {
throw new Error('Rejecting team join requests is not supported in this build');
}
}

View File

@@ -87,17 +87,26 @@ export class TeamService {
/**
* Remove a driver from the team
*
* The backend does not yet expose a dedicated endpoint for removing team memberships,
* so this method fails explicitly to avoid silently ignoring removal requests.
*/
async removeMembership(teamId: string, driverId: string): Promise<void> {
// TODO: Implement when API endpoint is available
throw new Error('Not implemented: API endpoint for removing team membership');
void teamId;
void driverId;
throw new Error('Team membership removal is not supported in this build');
}
/**
* Update team membership role
*
* Role updates for team memberships are not supported by the current API surface;
* callers must treat this as an unavailable operation.
*/
async updateMembership(teamId: string, driverId: string, role: string): Promise<void> {
// TODO: Implement when API endpoint is available
throw new Error('Not implemented: API endpoint for updating team membership role');
void teamId;
void driverId;
void role;
throw new Error('Team membership role updates are not supported in this build');
}
}

View File

@@ -1,26 +1,37 @@
/**
* Site-wide configuration for GridPilot website
*
* IMPORTANT: Update this file with correct information before going live.
* This serves as a single source of truth for legal and company information.
* Site-wide configuration for GridPilot website.
*
* Values are primarily sourced from environment variables so that
* deployments can provide real company details without hard-coding
* production data in the repository.
*/
const env = {
platformName: process.env.NEXT_PUBLIC_SITE_NAME,
platformUrl: process.env.NEXT_PUBLIC_SITE_URL,
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL,
sponsorEmail: process.env.NEXT_PUBLIC_SPONSOR_EMAIL,
legalCompanyName: process.env.NEXT_PUBLIC_LEGAL_COMPANY_NAME,
legalVatId: process.env.NEXT_PUBLIC_LEGAL_VAT_ID,
legalRegisteredCountry: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY,
legalRegisteredAddress: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS,
} as const;
export const siteConfig = {
// Platform Information
platformName: 'GridPilot',
platformUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://gridpilot.com',
platformName: env.platformName ?? 'GridPilot',
platformUrl: env.platformUrl ?? 'https://gridpilot.com',
// Contact Information
supportEmail: 'support@gridpilot.com',
sponsorEmail: 'sponsors@gridpilot.com',
supportEmail: env.supportEmail ?? 'support@example.com',
sponsorEmail: env.sponsorEmail ?? 'sponsors@example.com',
// Legal & Business Information
// TODO: Update these with actual company details before launch
legal: {
companyName: '', // e.g., 'GridPilot GmbH' - leave empty until confirmed
vatId: '', // e.g., 'DE123456789' - leave empty until confirmed
registeredCountry: '', // e.g., 'Germany' - leave empty until confirmed
registeredAddress: '', // Full registered address - leave empty until confirmed
companyName: env.legalCompanyName ?? '',
vatId: env.legalVatId ?? '',
registeredCountry: env.legalRegisteredCountry ?? '',
registeredAddress: env.legalRegisteredAddress ?? '',
},
// Platform Fees

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface ActivityItemDTO {
id: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface AllRacesListItemDTO {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId: string;
leagueName: string;
}

View File

@@ -0,0 +1,13 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface AvailableLeagueDTO {
id: string;
name: string;
game: string;
drivers: number;
avgViewsPerRace: number;
}

View File

@@ -0,0 +1,14 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface BillingStatsDTO {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: string;
nextPaymentAmount: number;
activeSponsorships: number;
averageMonthlySpend: number;
}

View File

@@ -11,4 +11,6 @@ export interface DashboardRaceSummaryDTO {
track: string;
car: string;
scheduledAt: string;
status: string;
isMyLeague: boolean;
}

View File

@@ -0,0 +1,10 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface GetEntitySponsorshipPricingResultDTO {
entityType: string;
entityId: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface GetLeagueWalletOutputDTO {
balance: number;
currency: string;
totalRevenue: number;
totalFees: number;
totalWithdrawals: number;
pendingPayouts: number;
canWithdraw: boolean;
}

View File

@@ -0,0 +1,10 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface GetRaceDetailParamsDTO {
raceId: string;
driverId: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface ImportRaceResultsSummaryDTO {
success: boolean;
raceId: string;
driversProcessed: number;
resultsRecorded: number;
}

View File

@@ -0,0 +1,15 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface InvoiceDTO {
id: string;
invoiceNumber: string;
date: string;
dueDate: string;
amount: number;
vatAmount: number;
totalAmount: number;
}

View File

@@ -4,20 +4,6 @@
* Do not edit manually - regenerate using: npm run api:sync-types
*/
import type { LeagueConfigFormModelBasicsDTO } from './LeagueConfigFormModelBasicsDTO';
import type { LeagueConfigFormModelStructureDTO } from './LeagueConfigFormModelStructureDTO';
import type { LeagueConfigFormModelScoringDTO } from './LeagueConfigFormModelScoringDTO';
import type { LeagueConfigFormModelDropPolicyDTO } from './LeagueConfigFormModelDropPolicyDTO';
import type { LeagueConfigFormModelTimingsDTO } from './LeagueConfigFormModelTimingsDTO';
import type { LeagueConfigFormModelStewardingDTO } from './LeagueConfigFormModelStewardingDTO';
export interface LeagueConfigFormModelDTO {
leagueId: string;
basics: LeagueConfigFormModelBasicsDTO;
structure: LeagueConfigFormModelStructureDTO;
championships: any[];
scoring: LeagueConfigFormModelScoringDTO;
dropPolicy: LeagueConfigFormModelDropPolicyDTO;
timings: LeagueConfigFormModelTimingsDTO;
stewarding: LeagueConfigFormModelStewardingDTO;
}

View File

@@ -0,0 +1,11 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface LeagueDetailDTO {
id: string;
name: string;
game: string;
}

View File

@@ -0,0 +1,11 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface LeagueMembershipDTO {
id: string;
leagueId: string;
driverId: string;
}

View File

@@ -0,0 +1,11 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface LeagueScoringPresetDTO {
id: string;
name: string;
description: string;
}

View File

@@ -7,4 +7,10 @@
export interface LeagueWithCapacityDTO {
id: string;
name: string;
description: string;
ownerId: string;
settings: Record<string, unknown>;
maxDrivers: number;
sessionDuration?: number;
visibility?: string;
}

View File

@@ -0,0 +1,14 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface NotificationSettingsDTO {
emailNewSponsorships: boolean;
emailWeeklyReport: boolean;
emailRaceAlerts: boolean;
emailPaymentAlerts: boolean;
emailNewOpportunities: boolean;
emailContractExpiry: boolean;
}

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface PaymentMethodDTO {
id: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface PrivacySettingsDTO {
publicProfile: boolean;
showStats: boolean;
showActiveSponsorships: boolean;
allowDirectContact: boolean;
}

View File

@@ -6,6 +6,7 @@
export interface ProtestDTO {
id: string;
leagueId: string;
raceId: string;
protestingDriverId: string;
accusedDriverId: string;

View File

@@ -0,0 +1,10 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface RenewalAlertDTO {
id: string;
name: string;
}

View File

@@ -0,0 +1,14 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface SponsorProfileDTO {
companyName: string;
contactName: string;
contactEmail: string;
contactPhone: string;
website: string;
description: string;
}

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface SponsorshipDTO {
id: string;
}

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface TotalLeaguesDTO {
totalLeagues: number;
}

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface WalletTransactionDTO {
id: string;
}

View File

@@ -0,0 +1,12 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface WithdrawFromLeagueWalletInputDTO {
amount: number;
currency: string;
seasonId: string;
destinationAccount: string;
}

View File

@@ -0,0 +1,9 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface WithdrawFromLeagueWalletOutputDTO {
success: boolean;
}

View File

@@ -1,50 +1,10 @@
// TODO: Move this business logic to core domain layer - scoring presets and their timing rules are domain concepts
import type { ScoringPresetTimings } from '@core/racing/domain/services/ScoringPresetTimingService';
import { applyScoringPresetToTimings } from '@core/racing/domain/services/ScoringPresetTimingService';
type Timings = {
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
roundsPlanned?: number;
raceDayOfWeek?: number;
raceTimeUtc?: string;
};
export type Timings = ScoringPresetTimings;
export class ScoringPresetApplier {
static applyToTimings(patternId: string, currentTimings: Timings): Timings {
const lowerPresetId = patternId.toLowerCase();
let updatedTimings: Timings = { ...currentTimings };
if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) {
updatedTimings = {
...updatedTimings,
practiceMinutes: 15,
qualifyingMinutes: 20,
sprintRaceMinutes: 20,
mainRaceMinutes: 35,
sessionCount: 2,
};
} else if (lowerPresetId.includes('endurance') || lowerPresetId.includes('long')) {
updatedTimings = {
...updatedTimings,
practiceMinutes: 30,
qualifyingMinutes: 30,
mainRaceMinutes: 90,
sessionCount: 1,
};
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
} else {
updatedTimings = {
...updatedTimings,
practiceMinutes: 20,
qualifyingMinutes: 30,
mainRaceMinutes: 40,
sessionCount: 1,
};
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
}
return updatedTimings;
return applyScoringPresetToTimings(patternId, currentTimings);
}
}

View File

@@ -167,23 +167,25 @@ export class LeagueDetailPageViewModel {
const driver = this.drivers.find(d => d.id === driverId);
if (!driver) return null;
// TODO: Get driver stats and rankings from service
// For now, return basic info
// Detailed rating and rank data are not wired from the analytics services yet;
// expose the driver identity only so the UI can still render role assignments.
return {
driver,
rating: null, // TODO: fetch from service
rank: null, // TODO: fetch from service
rating: null,
rank: null,
};
}
// UI helper methods
get isSponsorMode(): boolean {
// TODO: implement sponsor mode check
// League detail pages are rendered in organizer mode in this build; sponsor-specific
// mode switches will be introduced once sponsor dashboards share this view model.
return false;
}
get currentUserMembership(): LeagueMembershipWithRole | null {
// TODO: get current user ID and find membership
// Current user identity is not available in this view model context yet; callers must
// pass an explicit membership if they need per-user permissions.
return null;
}

View File

@@ -22,7 +22,7 @@ export class ProtestViewModel {
this.accusedDriverId = dto.accusedDriverId;
this.description = dto.description;
this.submittedAt = dto.submittedAt;
// TODO: Add these fields to DTO when available
// Status and decision metadata are not part of the protest DTO in this build; they default to a pending, unreviewed protest
this.status = 'pending';
this.reviewedAt = undefined;
this.decisionNotes = undefined;

View File

@@ -9,7 +9,6 @@ export class RaceWithSOFViewModel {
this.track = dto.track;
}
// TODO: Add additional fields when RaceWithSOFDTO is updated in OpenAPI spec
// sof?: number;
// results?: RaceResultViewModel[];
// The view model currently exposes only basic race identity and track information.
// Additional strength-of-field or result details can be added here once the DTO carries them.
}