diff --git a/apps/website/.eslintrc.json b/apps/website/.eslintrc.json index 424c3eefc..48240eed1 100644 --- a/apps/website/.eslintrc.json +++ b/apps/website/.eslintrc.json @@ -74,7 +74,9 @@ "rules": { "gridpilot-rules/mutation-contract": "error", "gridpilot-rules/mutation-must-use-builders": "error", - "gridpilot-rules/filename-service-match": "error" + "gridpilot-rules/mutation-must-map-errors": "error", + "gridpilot-rules/filename-service-match": "error", + "gridpilot-rules/clean-error-handling": "error" } }, { @@ -90,7 +92,8 @@ "gridpilot-rules/template-no-mutation-props": "error", "gridpilot-rules/template-no-unsafe-html": "error", "gridpilot-rules/component-no-data-manipulation": "error", - "gridpilot-rules/no-hardcoded-routes": "error" + "gridpilot-rules/no-hardcoded-routes": "error", + "gridpilot-rules/no-raw-html-in-app": "warn" } }, { @@ -98,7 +101,8 @@ "components/**/*.tsx" ], "rules": { - "gridpilot-rules/component-no-data-manipulation": "error" + "gridpilot-rules/component-no-data-manipulation": "error", + "gridpilot-rules/no-raw-html-in-app": "error" } }, { @@ -278,7 +282,8 @@ "rules": { "gridpilot-rules/no-nextjs-imports-in-ui": "error", "gridpilot-rules/component-classification": "warn", - "gridpilot-rules/no-hardcoded-routes": "error" + "gridpilot-rules/no-hardcoded-routes": "error", + "gridpilot-rules/no-raw-html-in-app": "error" } }, { diff --git a/apps/website/app/admin/AdminDashboardClient.tsx b/apps/website/app/admin/AdminDashboardClient.tsx deleted file mode 100644 index 29bd2bc25..000000000 --- a/apps/website/app/admin/AdminDashboardClient.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { AdminDashboardTemplate } from '@/templates/AdminDashboardTemplate'; -import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData'; -import { AdminDashboardPageQuery } from '@/lib/page-queries/AdminDashboardPageQuery'; - -export function AdminDashboardClient() { - const [viewData, setViewData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const loadStats = async () => { - try { - setLoading(true); - - const query = new AdminDashboardPageQuery(); - const result = await query.execute(); - - if (result.status === 'ok') { - // Page Query already returns View Data via builder - setViewData(result.dto); - } else if (result.status === 'notFound') { - // Handle not found - could show a message or redirect - console.error('Access denied - You must be logged in as an Owner or Admin'); - } else { - // Handle error - could show a toast or error message - console.error('Failed to load dashboard stats'); - } - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to load stats'; - console.error(message); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadStats(); - }, []); - - if (!viewData) { - return ( -
-
-
Loading dashboard...
-
- ); - } - - return ( - - ); -} \ No newline at end of file diff --git a/apps/website/app/admin/AdminUsersClient.tsx b/apps/website/app/admin/AdminUsersClient.tsx deleted file mode 100644 index 49ee195c1..000000000 --- a/apps/website/app/admin/AdminUsersClient.tsx +++ /dev/null @@ -1,115 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { AdminUsersTemplate } from '@/templates/AdminUsersTemplate'; -import { AdminUsersViewData } from '@/templates/AdminUsersViewData'; -import { AdminUsersPresenter } from '@/lib/presenters/AdminUsersPresenter'; -import { AdminUsersPageQuery } from '@/lib/page-queries/AdminUsersPageQuery'; -import { updateUserStatus, deleteUser } from './actions'; - -export function AdminUsersClient() { - const [viewData, setViewData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [search, setSearch] = useState(''); - const [roleFilter, setRoleFilter] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); - const [deletingUser, setDeletingUser] = useState(null); - - const loadUsers = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const query = new AdminUsersPageQuery(); - const result = await query.execute({ - search: search || undefined, - role: roleFilter || undefined, - status: statusFilter || undefined, - page: 1, - limit: 50, - }); - - if (result.status === 'ok') { - const data = AdminUsersPresenter.present(result.dto); - setViewData(data); - } else if (result.status === 'notFound') { - setError('Access denied - You must be logged in as an Owner or Admin'); - } else { - setError('Failed to load users'); - } - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to load users'; - setError(message); - } finally { - setLoading(false); - } - }, [search, roleFilter, statusFilter]); - - useEffect(() => { - const timeout = setTimeout(() => { - loadUsers(); - }, 300); - - return () => clearTimeout(timeout); - }, [loadUsers]); - - const handleUpdateStatus = async (userId: string, newStatus: string) => { - try { - await updateUserStatus(userId, newStatus); - await loadUsers(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update status'); - } - }; - - const handleDeleteUser = async (userId: string) => { - if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) { - return; - } - - try { - setDeletingUser(userId); - await deleteUser(userId); - await loadUsers(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete user'); - } finally { - setDeletingUser(null); - } - }; - - const handleClearFilters = () => { - setSearch(''); - setRoleFilter(''); - setStatusFilter(''); - }; - - if (!viewData) { - return ( -
-
-
Loading users...
-
- ); - } - - return ( - - ); -} \ No newline at end of file diff --git a/apps/website/app/admin/actions.ts b/apps/website/app/admin/actions.ts index 017a62885..54bb01156 100644 --- a/apps/website/app/admin/actions.ts +++ b/apps/website/app/admin/actions.ts @@ -15,31 +15,29 @@ import { revalidatePath } from 'next/cache'; /** * Update user status */ -export async function updateUserStatus(userId: string, status: string): Promise { - try { - const mutation = new UpdateUserStatusMutation(); - await mutation.execute({ userId, status }); - - // Revalidate the users page - revalidatePath('/admin/users'); - } catch (error) { - console.error('updateUserStatus failed:', error); +export async function updateUserStatus(userId: string, status: string) { + const mutation = new UpdateUserStatusMutation(); + const result = await mutation.execute({ userId, status }); + + if (result.isErr()) { + console.error('updateUserStatus failed:', result.getError()); throw new Error('Failed to update user status'); } + + revalidatePath('/admin/users'); } /** * Delete user */ -export async function deleteUser(userId: string): Promise { - try { - const mutation = new DeleteUserMutation(); - await mutation.execute({ userId }); - - // Revalidate the users page - revalidatePath('/admin/users'); - } catch (error) { - console.error('deleteUser failed:', error); +export async function deleteUser(userId: string) { + const mutation = new DeleteUserMutation(); + const result = await mutation.execute({ userId }); + + if (result.isErr()) { + console.error('deleteUser failed:', result.getError()); throw new Error('Failed to delete user'); } + + revalidatePath('/admin/users'); } \ No newline at end of file diff --git a/apps/website/app/admin/layout.tsx b/apps/website/app/admin/layout.tsx index 5c1bf47c9..53a3af1e8 100644 --- a/apps/website/app/admin/layout.tsx +++ b/apps/website/app/admin/layout.tsx @@ -1,4 +1,5 @@ import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; import { createRouteGuard } from '@/lib/auth/createRouteGuard'; interface AdminLayoutProps { @@ -16,11 +17,14 @@ export default async function AdminLayout({ children }: AdminLayoutProps) { const pathname = headerStore.get('x-pathname') || '/'; const guard = createRouteGuard(); - await guard.enforce({ pathname }); + const result = await guard.enforce({ pathname }); + if (result.type === 'redirect') { + redirect(result.to); + } return (
{children}
); -} \ No newline at end of file +} diff --git a/apps/website/app/admin/page.tsx b/apps/website/app/admin/page.tsx index 90cc7571e..cd300ed5a 100644 --- a/apps/website/app/admin/page.tsx +++ b/apps/website/app/admin/page.tsx @@ -1,5 +1,32 @@ -import { AdminDashboardClient } from './AdminDashboardClient'; +import { AdminDashboardPageQuery } from '@/lib/page-queries/AdminDashboardPageQuery'; +import { AdminDashboardTemplate } from '@/templates/AdminDashboardTemplate'; -export default function AdminPage() { - return ; +export default async function AdminPage() { + const result = await AdminDashboardPageQuery.execute(); + + if (result.isErr()) { + const error = result.getError(); + if (error === 'notFound') { + return ( +
+
+ Access denied - You must be logged in as an Owner or Admin +
+
+ ); + } + return ( +
+
+ Failed to load dashboard: {error} +
+
+ ); + } + + const viewData = result.unwrap(); + + // For now, use empty callbacks. In a real app, these would be Server Actions + // that trigger revalidation or navigation + return {}} isLoading={false} />; } \ No newline at end of file diff --git a/apps/website/app/admin/users/AdminUsersWrapper.tsx b/apps/website/app/admin/users/AdminUsersWrapper.tsx new file mode 100644 index 000000000..01b19ea6a --- /dev/null +++ b/apps/website/app/admin/users/AdminUsersWrapper.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { AdminUsersTemplate } from '@/templates/AdminUsersTemplate'; +import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData'; +import { updateUserStatus, deleteUser } from '../actions'; + +interface AdminUsersWrapperProps { + initialViewData: AdminUsersViewData; +} + +export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // UI state (not business logic) + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [deletingUser, setDeletingUser] = useState(null); + + // Current filter values from URL + const search = searchParams.get('search') || ''; + const roleFilter = searchParams.get('role') || ''; + const statusFilter = searchParams.get('status') || ''; + + // Callbacks that update URL (triggers RSC re-render) + const handleSearch = useCallback((newSearch: string) => { + const params = new URLSearchParams(searchParams); + if (newSearch) params.set('search', newSearch); + else params.delete('search'); + params.delete('page'); // Reset to page 1 + router.push(`/admin/users?${params.toString()}`); + }, [router, searchParams]); + + const handleFilterRole = useCallback((role: string) => { + const params = new URLSearchParams(searchParams); + if (role) params.set('role', role); + else params.delete('role'); + params.delete('page'); + router.push(`/admin/users?${params.toString()}`); + }, [router, searchParams]); + + const handleFilterStatus = useCallback((status: string) => { + const params = new URLSearchParams(searchParams); + if (status) params.set('status', status); + else params.delete('status'); + params.delete('page'); + router.push(`/admin/users?${params.toString()}`); + }, [router, searchParams]); + + const handleClearFilters = useCallback(() => { + router.push('/admin/users'); + }, [router]); + + const handleRefresh = useCallback(() => { + router.refresh(); + }, [router]); + + // Mutation callbacks (call Server Actions) + const handleUpdateStatus = useCallback(async (userId: string, newStatus: string) => { + try { + setLoading(true); + await updateUserStatus(userId, newStatus); + // Revalidate data + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update status'); + } finally { + setLoading(false); + } + }, [router]); + + const handleDeleteUser = useCallback(async (userId: string) => { + if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) { + return; + } + + try { + setDeletingUser(userId); + await deleteUser(userId); + // Revalidate data + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete user'); + } finally { + setDeletingUser(null); + } + }, [router]); + + return ( + + ); +} \ No newline at end of file diff --git a/apps/website/app/admin/users/page.tsx b/apps/website/app/admin/users/page.tsx index 2d7427186..952c97a0d 100644 --- a/apps/website/app/admin/users/page.tsx +++ b/apps/website/app/admin/users/page.tsx @@ -1,5 +1,51 @@ -import { AdminUsersClient } from '../AdminUsersClient'; +import { AdminUsersPageQuery } from '@/lib/page-queries/AdminUsersPageQuery'; +import { AdminUsersWrapper } from './AdminUsersWrapper'; -export default function AdminUsers() { - return ; +interface AdminUsersPageProps { + searchParams?: { + search?: string; + role?: string; + status?: string; + page?: string; + }; +} + +export default async function AdminUsersPage({ searchParams }: AdminUsersPageProps) { + // Parse query parameters + const query = { + search: searchParams?.search, + role: searchParams?.role, + status: searchParams?.status, + page: searchParams?.page ? parseInt(searchParams.page, 10) : 1, + limit: 50, + }; + + // Execute PageQuery using static method + const result = await AdminUsersPageQuery.execute(query); + + // Handle errors + if (result.isErr()) { + const error = result.getError(); + if (error === 'notFound') { + return ( +
+
+ Access denied - You must be logged in as an Owner or Admin +
+
+ ); + } + return ( +
+
+ Failed to load users: {error} +
+
+ ); + } + + const viewData = result.unwrap(); + + // Pass to client wrapper for UI interactions + return ; } \ No newline at end of file diff --git a/apps/website/app/auth/forgot-password/ForgotPasswordClient.tsx b/apps/website/app/auth/forgot-password/ForgotPasswordClient.tsx new file mode 100644 index 000000000..9d1edaa8b --- /dev/null +++ b/apps/website/app/auth/forgot-password/ForgotPasswordClient.tsx @@ -0,0 +1,86 @@ +/** + * Forgot Password Client Component + * + * Handles client-side forgot password flow. + */ + +'use client'; + +import { useState } from 'react'; +import { ForgotPasswordViewData } from '@/lib/builders/view-data/ForgotPasswordViewDataBuilder'; +import { ForgotPasswordTemplate } from '@/templates/auth/ForgotPasswordTemplate'; +import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation'; +import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder'; +import { ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel'; + +interface ForgotPasswordClientProps { + viewData: ForgotPasswordViewData; +} + +export function ForgotPasswordClient({ viewData }: ForgotPasswordClientProps) { + // Build ViewModel from ViewData + const [viewModel, setViewModel] = useState(() => + ForgotPasswordViewModelBuilder.build(viewData) + ); + + const [formData, setFormData] = useState({ email: '' }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Update submitting state + setViewModel(prev => prev.withMutationState(true, null)); + + try { + // Execute forgot password mutation + const mutation = new ForgotPasswordMutation(); + const result = await mutation.execute({ + email: formData.email, + }); + + if (result.isErr()) { + const error = result.getError(); + setViewModel(prev => prev.withMutationState(false, error)); + return; + } + + // Success + const data = result.unwrap(); + setViewModel(prev => prev.withSuccess(data.message, data.magicLink || null)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to send reset link'; + setViewModel(prev => prev.withMutationState(false, errorMessage)); + } + }; + + // Build viewData for template + const templateViewData: ForgotPasswordViewData = { + ...viewData, + showSuccess: viewModel.showSuccess, + successMessage: viewModel.successMessage || undefined, + magicLink: viewModel.magicLink || undefined, + formState: viewModel.formState, + isSubmitting: viewModel.isSubmitting, + submitError: viewModel.submitError, + }; + + return ( + { + if (!show) { + // Reset to initial state + setViewModel(prev => ForgotPasswordViewModelBuilder.build(viewData)); + } + }, + }} + mutationState={{ + isPending: viewModel.mutationPending, + error: viewModel.mutationError, + }} + /> + ); +} \ No newline at end of file diff --git a/apps/website/app/auth/forgot-password/page.tsx b/apps/website/app/auth/forgot-password/page.tsx index 36072a662..439e8a4e5 100644 --- a/apps/website/app/auth/forgot-password/page.tsx +++ b/apps/website/app/auth/forgot-password/page.tsx @@ -1,250 +1,34 @@ -'use client'; +/** + * Forgot Password Page + * + * RSC composition pattern: + * 1. PageQuery executes to get ViewData + * 2. Client component renders with ViewData + */ -import { useState, useEffect, FormEvent, type ChangeEvent } from 'react'; -import { useRouter } from 'next/navigation'; -import { useAuth } from '@/lib/auth/AuthContext'; -import { useForgotPassword } from "@/lib/hooks/auth/useForgotPassword"; -import Link from 'next/link'; -import { motion } from 'framer-motion'; -import { - Mail, - ArrowLeft, - AlertCircle, - Flag, - Shield, - CheckCircle2, -} from 'lucide-react'; +import { ForgotPasswordPageQuery } from '@/lib/page-queries/auth/ForgotPasswordPageQuery'; +import { ForgotPasswordClient } from './ForgotPasswordClient'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; +export default async function ForgotPasswordPage({ + searchParams, +}: { + searchParams: Promise; +}) { + // Execute PageQuery + const params = await searchParams; + const queryResult = await ForgotPasswordPageQuery.execute(params); -interface FormErrors { - email?: string; - submit?: string; -} - -interface SuccessState { - message: string; - magicLink?: string; -} - -export default function ForgotPasswordPage() { - const router = useRouter(); - const { session } = useAuth(); - - const [errors, setErrors] = useState({}); - const [success, setSuccess] = useState(null); - const [formData, setFormData] = useState({ - email: '', - }); - - // Check if user is already authenticated - useEffect(() => { - if (session) { - router.replace('/dashboard'); - } - }, [session, router]); - - const validateForm = (): boolean => { - const newErrors: FormErrors = {}; - - if (!formData.email.trim()) { - newErrors.email = 'Email is required'; - } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { - newErrors.email = 'Invalid email format'; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - // Use forgot password mutation hook - const forgotPasswordMutation = useForgotPassword({ - onSuccess: (result) => { - setSuccess({ - message: result.message, - magicLink: result.magicLink, - }); - }, - onError: (error) => { - setErrors({ - submit: error.message || 'Failed to send reset link. Please try again.', - }); - }, - }); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (forgotPasswordMutation.isPending) return; - - if (!validateForm()) return; - - setErrors({}); - setSuccess(null); - - try { - await forgotPasswordMutation.mutateAsync({ email: formData.email }); - } catch (error) { - // Error handling is done in the mutation's onError callback - } - }; - - // Loading state from mutation - const loading = forgotPasswordMutation.isPending; - - return ( -
- {/* Background Pattern */} -
-
-
+ if (queryResult.isErr()) { + // Handle query error + return ( +
+
Failed to load forgot password page
+ ); + } -
- {/* Header */} -
-
- -
- Reset Password -

- Enter your email and we'll send you a reset link -

-
+ const viewData = queryResult.unwrap(); - - {/* Background accent */} -
- - {!success ? ( -
- {/* Email */} -
- -
- - ) => setFormData({ ...formData, email: e.target.value })} - error={!!errors.email} - errorMessage={errors.email} - placeholder="you@example.com" - disabled={loading} - className="pl-10" - autoComplete="email" - /> -
-
- - {/* Error Message */} - {errors.submit && ( - - -

{errors.submit}

-
- )} - - {/* Submit Button */} - - - {/* Back to Login */} -
- - - Back to Login - -
-
- ) : ( - -
- -
-

{success.message}

- {success.magicLink && ( -
-

Development Mode - Magic Link:

-
- - {success.magicLink} - -
-

- In production, this would be sent via email -

-
- )} -
-
- - -
- )} - - - {/* Trust Indicators */} -
-
- - Secure reset process -
-
- - 15 minute expiration -
-
- - {/* Footer */} -

- Need help?{' '} - - Contact support - -

-
-
- ); + // Render client component with ViewData + return ; } \ No newline at end of file diff --git a/apps/website/app/auth/layout.tsx b/apps/website/app/auth/layout.tsx index 0b424ad2c..512265c89 100644 --- a/apps/website/app/auth/layout.tsx +++ b/apps/website/app/auth/layout.tsx @@ -1,4 +1,5 @@ import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; import { createRouteGuard } from '@/lib/auth/createRouteGuard'; interface AuthLayoutProps { @@ -20,11 +21,14 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { const pathname = headerStore.get('x-pathname') || '/'; const guard = createRouteGuard(); - await guard.enforce({ pathname }); + const result = await guard.enforce({ pathname }); + if (result.type === 'redirect') { + redirect(result.to); + } return (
{children}
); -} \ No newline at end of file +} diff --git a/apps/website/app/auth/login/LoginClient.tsx b/apps/website/app/auth/login/LoginClient.tsx new file mode 100644 index 000000000..fb4a372e9 --- /dev/null +++ b/apps/website/app/auth/login/LoginClient.tsx @@ -0,0 +1,284 @@ +/** + * Login Client Component + * + * Handles client-side login flow using the LoginFlowController. + * Deterministic state machine per docs/architecture/website/LOGIN_FLOW_STATE_MACHINE.md + */ + +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useAuth } from '@/lib/auth/AuthContext'; +import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController'; +import { LoginViewData } from '@/lib/builders/view-data/LoginViewDataBuilder'; +import { LoginTemplate } from '@/templates/auth/LoginTemplate'; +import { LoginMutation } from '@/lib/mutations/auth/LoginMutation'; +import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation'; +import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder'; +import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel'; + +interface LoginClientProps { + viewData: LoginViewData; +} + +export function LoginClient({ viewData }: LoginClientProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const { refreshSession, session } = useAuth(); + + // Build ViewModel from ViewData + const [viewModel, setViewModel] = useState(() => + LoginViewModelBuilder.build(viewData) + ); + + // Login flow controller + const controller = useMemo(() => { + const returnTo = searchParams.get('returnTo') ?? '/dashboard'; + return new LoginFlowController(session, returnTo); + }, [session, searchParams]); + + // Check controller state on mount and session changes + useEffect(() => { + const action = controller.getNextAction(); + + if (action.type === 'REDIRECT') { + router.replace(action.path); + } + }, [controller, router]); + + // Handle form field changes + const handleChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target; + const checked = 'checked' in e.target ? e.target.checked : false; + const fieldValue = type === 'checkbox' ? checked : value; + + setViewModel(prev => { + const newFormState = { + ...prev.formState, + fields: { + ...prev.formState.fields, + [name]: { + ...prev.formState.fields[name as keyof typeof prev.formState.fields], + value: fieldValue, + touched: true, + error: undefined, + }, + }, + }; + return prev.withFormState(newFormState); + }); + }; + + // Handle form submission + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const values: LoginFormValues = { + email: viewModel.formState.fields.email.value as string, + password: viewModel.formState.fields.password.value as string, + rememberMe: viewModel.formState.fields.rememberMe.value as boolean, + }; + + // Validate form + const errors = validateLoginForm(values); + const hasErrors = Object.keys(errors).length > 0; + + if (hasErrors) { + setViewModel(prev => { + const newFormState = { + ...prev.formState, + isValid: false, + submitCount: prev.formState.submitCount + 1, + fields: { + ...prev.formState.fields, + email: { + ...prev.formState.fields.email, + error: errors.email, + touched: true, + }, + password: { + ...prev.formState.fields.password, + error: errors.password, + touched: true, + }, + }, + }; + return prev.withFormState(newFormState); + }); + return; + } + + // Update submitting state + setViewModel(prev => prev.withMutationState(true, null)); + + try { + // Execute login mutation + const mutation = new LoginMutation(); + const result = await mutation.execute({ + email: values.email, + password: values.password, + rememberMe: values.rememberMe, + }); + + if (result.isErr()) { + const error = result.getError(); + setViewModel(prev => { + const newFormState = { + ...prev.formState, + isSubmitting: false, + submitError: error, + }; + return prev.withFormState(newFormState).withMutationState(false, error); + }); + + if (process.env.NODE_ENV === 'development') { + setViewModel(prev => prev.withUIState({ + ...prev.uiState, + showErrorDetails: true, + })); + } + return; + } + + // Success - refresh session and transition + await refreshSession(); + + // Transition to post-auth state + controller.transitionToPostAuth(); + const action = controller.getNextAction(); + + if (action.type === 'REDIRECT') { + router.push(action.path); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Login failed'; + setViewModel(prev => { + const newFormState = { + ...prev.formState, + isSubmitting: false, + submitError: errorMessage, + }; + return prev.withFormState(newFormState).withMutationState(false, errorMessage); + }); + + if (process.env.NODE_ENV === 'development') { + setViewModel(prev => prev.withUIState({ + ...prev.uiState, + showErrorDetails: true, + })); + } + } + }; + + // Toggle password visibility + const togglePassword = () => { + setViewModel(prev => prev.withUIState({ + ...prev.uiState, + showPassword: !prev.uiState.showPassword, + })); + }; + + // Dismiss error details + const dismissErrorDetails = () => { + setViewModel(prev => { + const newFormState = { + ...prev.formState, + submitError: undefined, + }; + return prev.withFormState(newFormState).withUIState({ + ...prev.uiState, + showErrorDetails: false, + }); + }); + }; + + // Get current state from controller + const state = controller.getState(); + + // If user is authenticated with permissions, show loading + if (state === LoginState.AUTHENTICATED_WITH_PERMISSIONS) { + return ( +
+
+
+ ); + } + + // If user has insufficient permissions, show permission error + if (state === LoginState.AUTHENTICATED_WITHOUT_PERMISSIONS) { + return ( + { + if (typeof state === 'function') { + const newState = state(viewModel.formState); + setViewModel(prev => prev.withFormState(newState)); + } else { + setViewModel(prev => prev.withFormState(state)); + } + }, + setShowPassword: togglePassword, + setShowErrorDetails: (show) => { + setViewModel(prev => prev.withUIState({ + ...prev.uiState, + showErrorDetails: show, + })); + }, + }} + mutationState={{ + isPending: viewModel.mutationPending, + error: viewModel.mutationError, + }} + /> + ); + } + + // Show login form + return ( + { + if (typeof state === 'function') { + const newState = state(viewModel.formState); + setViewModel(prev => prev.withFormState(newState)); + } else { + setViewModel(prev => prev.withFormState(state)); + } + }, + setShowPassword: togglePassword, + setShowErrorDetails: (show) => { + setViewModel(prev => prev.withUIState({ + ...prev.uiState, + showErrorDetails: show, + })); + }, + }} + mutationState={{ + isPending: viewModel.mutationPending, + error: viewModel.mutationError, + }} + /> + ); +} \ No newline at end of file diff --git a/apps/website/app/auth/login/page.tsx b/apps/website/app/auth/login/page.tsx index 302b714b2..c11254f97 100644 --- a/apps/website/app/auth/login/page.tsx +++ b/apps/website/app/auth/login/page.tsx @@ -1,426 +1,34 @@ -'use client'; +/** + * Login Page + * + * RSC composition pattern: + * 1. PageQuery executes to get ViewData + * 2. Client component renders with ViewData + */ -import { useState, useEffect } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; -import { motion, AnimatePresence } from 'framer-motion'; -import { - Mail, - Lock, - Eye, - EyeOff, - LogIn, - AlertCircle, - Flag, - Shield, -} from 'lucide-react'; +import { LoginPageQuery } from '@/lib/page-queries/auth/LoginPageQuery'; +import { LoginClient } from './LoginClient'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; -import { useAuth } from '@/lib/auth/AuthContext'; -import { useLogin } from "@/lib/hooks/auth/useLogin"; -import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup'; -import UserRolesPreview from '@/components/auth/UserRolesPreview'; -import { EnhancedFormError } from '@/components/errors/EnhancedFormError'; -import { useEnhancedForm } from '@/lib/hooks/useEnhancedForm'; -import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation'; -import { logErrorWithContext } from '@/lib/utils/errorUtils'; -import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise; +}) { + // Execute PageQuery + const params = await searchParams; + const queryResult = await LoginPageQuery.execute(params); -// Template component for login UI -function LoginTemplate({ data }: { data: { returnTo: string; hasInsufficientPermissions: boolean; showPassword: boolean; showErrorDetails: boolean; formState: any; handleChange: any; handleSubmit: any; setFormState: any; setShowPassword: any; setShowErrorDetails: any; } }) { - const { - returnTo, - hasInsufficientPermissions, - showPassword, - showErrorDetails, - formState, - handleChange, - handleSubmit, - setFormState, - setShowPassword, - setShowErrorDetails, - } = data; - - return ( -
- {/* Background Pattern */} -
-
-
+ if (queryResult.isErr()) { + // Handle query error + return ( +
+
Failed to load login page
+ ); + } - {/* Left Side - Info Panel (Hidden on mobile) */} -
-
- {/* Logo */} -
-
- -
- GridPilot -
+ const viewData = queryResult.unwrap(); - - Your Sim Racing Infrastructure - - -

- Manage leagues, track performance, join teams, and compete with drivers worldwide. One account, multiple roles. -

- - {/* Role Cards */} - - - {/* Workflow Mockup */} - - - {/* Trust Indicators */} -
-
- - Secure login -
-
- iRacing verified -
-
-
-
- - {/* Right Side - Login Form */} -
-
- {/* Mobile Logo/Header */} -
-
- -
- Welcome Back -

- Sign in to continue to GridPilot -

-
- - {/* Desktop Header */} -
- Welcome Back -

- Sign in to access your racing dashboard -

-
- - - {/* Background accent */} -
- -
- {/* Email */} -
- -
- - -
-
- - {/* Password */} -
-
- - - Forgot password? - -
-
- - - -
-
- - {/* Remember Me */} -
- -
- - {/* Insufficient Permissions Message */} - - {hasInsufficientPermissions && ( - -
- -
- Insufficient Permissions -

- You don't have permission to access that page. Please log in with an account that has the required role. -

-
-
-
- )} -
- - {/* Enhanced Error Display */} - - {formState.submitError && ( - { - // Clear the error by setting submitError to undefined - setFormState((prev: typeof formState) => ({ ...prev, submitError: undefined })); - }} - showDeveloperDetails={showErrorDetails} - /> - )} - - - {/* Submit Button */} - -
- - {/* Divider */} -
-
-
-
-
- or continue with -
-
- - {/* Sign Up Link */} -

- Don't have an account?{' '} - - Create one - -

- - - {/* Name Immutability Notice */} -
-
- -
- Note: Your display name cannot be changed after signup. Please ensure it's correct when creating your account. -
-
-
- - {/* Footer */} -

- By signing in, you agree to our{' '} - Terms of Service - {' '}and{' '} - Privacy Policy -

- - {/* Mobile Role Info */} - -
-
-
- ); -} - -export default function LoginPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { refreshSession, session } = useAuth(); - const returnTo = searchParams.get('returnTo') ?? '/dashboard'; - - const [showPassword, setShowPassword] = useState(false); - const [showErrorDetails, setShowErrorDetails] = useState(false); - const [hasInsufficientPermissions, setHasInsufficientPermissions] = useState(false); - - // Use login mutation hook - const loginMutation = useLogin({ - onSuccess: async () => { - // Refresh session in context so header updates immediately - await refreshSession(); - router.push(returnTo); - }, - onError: (error) => { - // Show error details toggle in development - if (process.env.NODE_ENV === 'development') { - setShowErrorDetails(true); - } - }, - }); - - // Check if user is already authenticated - useEffect(() => { - console.log('[LoginPage] useEffect running', { - session: session ? 'exists' : 'null', - returnTo: searchParams.get('returnTo'), - pathname: window.location.pathname, - search: window.location.search, - }); - - if (session) { - // Check if this is a returnTo redirect (user lacks permissions) - const isPermissionRedirect = searchParams.get('returnTo') !== null; - - console.log('[LoginPage] Authenticated user check', { - isPermissionRedirect, - returnTo: searchParams.get('returnTo'), - }); - - if (isPermissionRedirect) { - // User was redirected here due to insufficient permissions - // Show permission error instead of redirecting - console.log('[LoginPage] Showing permission error'); - setHasInsufficientPermissions(true); - } else { - // User navigated here directly while authenticated, redirect to dashboard - console.log('[LoginPage] Redirecting to dashboard'); - router.replace('/dashboard'); - } - } - }, [session, router, searchParams]); - - // Use enhanced form hook - const { - formState, - setFormState, - handleChange, - handleSubmit, - } = useEnhancedForm({ - initialValues: { - email: '', - password: '', - rememberMe: false, - }, - validate: validateLoginForm, - component: 'LoginPage', - onSubmit: async (values) => { - // Log the attempt for debugging - logErrorWithContext( - { message: 'Login attempt', values: { ...values, password: '[REDACTED]' } }, - { - component: 'LoginPage', - action: 'login-submit', - formData: { ...values, password: '[REDACTED]' }, - } - ); - - await loginMutation.mutateAsync({ - email: values.email, - password: values.password, - rememberMe: values.rememberMe, - }); - }, - onError: (error, values) => { - // Error handling is done in the mutation's onError callback - }, - onSuccess: () => { - // Reset error details on success - setShowErrorDetails(false); - }, - }); - - // Prepare template data - const templateData = { - returnTo, - hasInsufficientPermissions, - showPassword, - showErrorDetails, - formState, - handleChange, - handleSubmit, - setFormState, - setShowPassword, - setShowErrorDetails, - }; - - // Mutation state for wrapper - const isLoading = loginMutation.isPending; - const error = loginMutation.error; - - return ( - loginMutation.mutate({ email: '', password: '', rememberMe: false })} - Template={LoginTemplate} - loading={{ variant: 'full-screen', message: 'Loading login...' }} - errorConfig={{ variant: 'full-screen' }} - /> - ); + // Render client component with ViewData + return ; } \ No newline at end of file diff --git a/apps/website/app/auth/reset-password/ResetPasswordClient.tsx b/apps/website/app/auth/reset-password/ResetPasswordClient.tsx new file mode 100644 index 000000000..39de1b800 --- /dev/null +++ b/apps/website/app/auth/reset-password/ResetPasswordClient.tsx @@ -0,0 +1,139 @@ +/** + * Reset Password Client Component + * + * Handles client-side reset password flow. + */ + +'use client'; + +import { useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { ResetPasswordViewData } from '@/lib/builders/view-data/ResetPasswordViewDataBuilder'; +import { ResetPasswordTemplate } from '@/templates/auth/ResetPasswordTemplate'; +import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation'; +import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetPasswordViewModelBuilder'; +import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel'; + +interface ResetPasswordClientProps { + viewData: ResetPasswordViewData; +} + +export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Build ViewModel from ViewData + const [viewModel, setViewModel] = useState(() => + ResetPasswordViewModelBuilder.build(viewData) + ); + + const [formData, setFormData] = useState({ + newPassword: '', + confirmPassword: '' + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validate passwords match + if (formData.newPassword !== formData.confirmPassword) { + setViewModel(prev => prev.withMutationState(false, 'Passwords do not match')); + return; + } + + // Update submitting state + setViewModel(prev => prev.withMutationState(true, null)); + + try { + const token = searchParams.get('token'); + if (!token) { + setViewModel(prev => prev.withMutationState(false, 'Invalid reset link')); + return; + } + + // Execute reset password mutation + const mutation = new ResetPasswordMutation(); + const result = await mutation.execute({ + token, + newPassword: formData.newPassword, + }); + + if (result.isErr()) { + const error = result.getError(); + setViewModel(prev => prev.withMutationState(false, error)); + return; + } + + // Success + const data = result.unwrap(); + setViewModel(prev => prev.withSuccess(data.message)); + + // Redirect to login after a delay + setTimeout(() => { + router.push('/auth/login'); + }, 3000); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to reset password'; + setViewModel(prev => prev.withMutationState(false, errorMessage)); + } + }; + + // Toggle password visibility + const togglePassword = () => { + setViewModel(prev => prev.withUIState({ + ...prev.uiState, + showPassword: !prev.uiState.showPassword, + })); + }; + + const toggleConfirmPassword = () => { + setViewModel(prev => prev.withUIState({ + ...prev.uiState, + showConfirmPassword: !prev.uiState.showConfirmPassword, + })); + }; + + // Build viewData for template + const templateViewData: ResetPasswordViewData = { + ...viewData, + showSuccess: viewModel.showSuccess, + successMessage: viewModel.successMessage || undefined, + formState: viewModel.formState, + isSubmitting: viewModel.isSubmitting, + submitError: viewModel.submitError, + }; + + return ( + { + if (!show) { + // Reset to initial state + setViewModel(prev => ResetPasswordViewModelBuilder.build(viewData)); + } + }, + setShowPassword: togglePassword, + setShowConfirmPassword: toggleConfirmPassword, + }} + uiState={{ + showPassword: viewModel.uiState.showPassword, + showConfirmPassword: viewModel.uiState.showConfirmPassword, + }} + mutationState={{ + isPending: viewModel.mutationPending, + error: viewModel.mutationError, + }} + /> + ); +} \ No newline at end of file diff --git a/apps/website/app/auth/reset-password/page.tsx b/apps/website/app/auth/reset-password/page.tsx index 79f811ba0..68b70961a 100644 --- a/apps/website/app/auth/reset-password/page.tsx +++ b/apps/website/app/auth/reset-password/page.tsx @@ -1,371 +1,34 @@ -'use client'; +/** + * Reset Password Page + * + * RSC composition pattern: + * 1. PageQuery executes to get ViewData + * 2. Client component renders with ViewData + */ -import { useState, FormEvent, type ChangeEvent, useEffect } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; -import { motion } from 'framer-motion'; -import { - Lock, - Eye, - EyeOff, - AlertCircle, - Flag, - Shield, - CheckCircle2, - ArrowLeft, -} from 'lucide-react'; +import { ResetPasswordPageQuery } from '@/lib/page-queries/auth/ResetPasswordPageQuery'; +import { ResetPasswordClient } from './ResetPasswordClient'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; -import { useAuth } from '@/lib/auth/AuthContext'; -import { useResetPassword } from "@/lib/hooks/auth/useResetPassword"; +export default async function ResetPasswordPage({ + searchParams, +}: { + searchParams: Promise; +}) { + // Execute PageQuery + const params = await searchParams; + const queryResult = await ResetPasswordPageQuery.execute(params); -interface FormErrors { - newPassword?: string; - confirmPassword?: string; - submit?: string; -} - -interface PasswordStrength { - score: number; - label: string; - color: string; -} - -function checkPasswordStrength(password: string): PasswordStrength { - let score = 0; - if (password.length >= 8) score++; - if (password.length >= 12) score++; - if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++; - if (/\d/.test(password)) score++; - if (/[^a-zA-Z\d]/.test(password)) score++; - - if (score <= 1) return { score, label: 'Weak', color: 'bg-red-500' }; - if (score <= 2) return { score, label: 'Fair', color: 'bg-warning-amber' }; - if (score <= 3) return { score, label: 'Good', color: 'bg-primary-blue' }; - return { score, label: 'Strong', color: 'bg-performance-green' }; -} - -export default function ResetPasswordPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { session } = useAuth(); - - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [errors, setErrors] = useState({}); - const [success, setSuccess] = useState(null); - const [formData, setFormData] = useState({ - newPassword: '', - confirmPassword: '', - }); - const [token, setToken] = useState(''); - - // Check if user is already authenticated - useEffect(() => { - if (session) { - router.replace('/dashboard'); - } - }, [session, router]); - - // Extract token from URL on mount - useEffect(() => { - const tokenParam = searchParams.get('token'); - if (tokenParam) { - setToken(tokenParam); - } - }, [searchParams]); - - const passwordStrength = checkPasswordStrength(formData.newPassword); - - const passwordRequirements = [ - { met: formData.newPassword.length >= 8, label: 'At least 8 characters' }, - { met: /[a-z]/.test(formData.newPassword) && /[A-Z]/.test(formData.newPassword), label: 'Upper and lowercase letters' }, - { met: /\d/.test(formData.newPassword), label: 'At least one number' }, - { met: /[^a-zA-Z\d]/.test(formData.newPassword), label: 'At least one special character' }, - ]; - - const validateForm = (): boolean => { - const newErrors: FormErrors = {}; - - if (!formData.newPassword) { - newErrors.newPassword = 'New password is required'; - } else if (formData.newPassword.length < 8) { - newErrors.newPassword = 'Password must be at least 8 characters'; - } else if (!/[a-z]/.test(formData.newPassword) || !/[A-Z]/.test(formData.newPassword) || !/\d/.test(formData.newPassword)) { - newErrors.newPassword = 'Password must contain uppercase, lowercase, and number'; - } - - if (!formData.confirmPassword) { - newErrors.confirmPassword = 'Please confirm your password'; - } else if (formData.newPassword !== formData.confirmPassword) { - newErrors.confirmPassword = 'Passwords do not match'; - } - - if (!token) { - newErrors.submit = 'Invalid reset token. Please request a new reset link.'; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - // Use reset password mutation hook - const resetPasswordMutation = useResetPassword({ - onSuccess: (result) => { - setSuccess(result.message); - }, - onError: (error) => { - setErrors({ - submit: error.message || 'Failed to reset password. Please try again.', - }); - }, - }); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (resetPasswordMutation.isPending) return; - - if (!validateForm()) return; - - setErrors({}); - setSuccess(null); - - try { - await resetPasswordMutation.mutateAsync({ - token, - newPassword: formData.newPassword, - }); - } catch (error) { - // Error handling is done in the mutation's onError callback - } - }; - - // Loading state from mutation - const loading = resetPasswordMutation.isPending; - - return ( -
- {/* Background Pattern */} -
-
-
+ if (queryResult.isErr()) { + // Handle query error + return ( +
+
Failed to load reset password page
+ ); + } -
- {/* Header */} -
-
- -
- Set New Password -

- Create a strong password for your account -

-
+ const viewData = queryResult.unwrap(); - - {/* Background accent */} -
- - {!success ? ( -
- {/* New Password */} -
- -
- - ) => setFormData({ ...formData, newPassword: e.target.value })} - error={!!errors.newPassword} - errorMessage={errors.newPassword} - placeholder="••••••••" - disabled={loading} - className="pl-10 pr-10" - autoComplete="new-password" - /> - -
- - {/* Password Strength */} - {formData.newPassword && ( -
-
-
- -
- - {passwordStrength.label} - -
-
- {passwordRequirements.map((req, index) => ( -
- {req.met ? ( - - ) : ( - - )} - - {req.label} - -
- ))} -
-
- )} -
- - {/* Confirm Password */} -
- -
- - ) => setFormData({ ...formData, confirmPassword: e.target.value })} - error={!!errors.confirmPassword} - errorMessage={errors.confirmPassword} - placeholder="••••••••" - disabled={loading} - className="pl-10 pr-10" - autoComplete="new-password" - /> - -
- {formData.confirmPassword && formData.newPassword === formData.confirmPassword && ( -

- Passwords match -

- )} -
- - {/* Error Message */} - {errors.submit && ( - - -

{errors.submit}

-
- )} - - {/* Submit Button */} - - - {/* Back to Login */} -
- - - Back to Login - -
-
- ) : ( - -
- -
-

{success}

-

- Your password has been successfully reset -

-
-
- - -
- )} - - - {/* Trust Indicators */} -
-
- - Encrypted & secure -
-
- - Instant update -
-
- - {/* Footer */} -

- Need help?{' '} - - Contact support - -

-
-
- ); + // Render client component with ViewData + return ; } \ No newline at end of file diff --git a/apps/website/app/auth/signup/SignupClient.tsx b/apps/website/app/auth/signup/SignupClient.tsx new file mode 100644 index 000000000..72d59f9b9 --- /dev/null +++ b/apps/website/app/auth/signup/SignupClient.tsx @@ -0,0 +1,123 @@ +/** + * Signup Client Component + * + * Handles client-side signup flow. + */ + +'use client'; + +import { useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useAuth } from '@/lib/auth/AuthContext'; +import { SignupViewData } from '@/lib/builders/view-data/SignupViewDataBuilder'; +import { SignupTemplate } from '@/templates/auth/SignupTemplate'; +import { SignupMutation } from '@/lib/mutations/auth/SignupMutation'; +import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder'; +import { SignupViewModel } from '@/lib/view-models/auth/SignupViewModel'; + +interface SignupClientProps { + viewData: SignupViewData; +} + +export function SignupClient({ viewData }: SignupClientProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const { refreshSession } = useAuth(); + + // Build ViewModel from ViewData + const [viewModel, setViewModel] = useState(() => + SignupViewModelBuilder.build(viewData) + ); + + const [formData, setFormData] = useState({ + firstName: '', + lastName: '', + email: '', + password: '', + confirmPassword: '' + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validate passwords match + if (formData.password !== formData.confirmPassword) { + setViewModel(prev => prev.withMutationState(false, 'Passwords do not match')); + return; + } + + // Update submitting state + setViewModel(prev => prev.withMutationState(true, null)); + + try { + // Transform to DTO format + const displayName = `${formData.firstName} ${formData.lastName}`.trim(); + + // Execute signup mutation + const mutation = new SignupMutation(); + const result = await mutation.execute({ + email: formData.email, + password: formData.password, + displayName: displayName || formData.firstName || formData.lastName, + }); + + if (result.isErr()) { + const error = result.getError(); + setViewModel(prev => prev.withMutationState(false, error)); + return; + } + + // Success - refresh session and redirect + await refreshSession(); + + const returnTo = searchParams.get('returnTo') ?? '/onboarding'; + router.push(returnTo); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Signup failed'; + setViewModel(prev => prev.withMutationState(false, errorMessage)); + } + }; + + // Toggle password visibility + const togglePassword = () => { + setViewModel(prev => prev.withUIState({ + ...prev.uiState, + showPassword: !prev.uiState.showPassword, + })); + }; + + const toggleConfirmPassword = () => { + setViewModel(prev => prev.withUIState({ + ...prev.uiState, + showConfirmPassword: !prev.uiState.showConfirmPassword, + })); + }; + + // Build viewData for template + const templateViewData: SignupViewData = { + ...viewData, + formState: viewModel.formState, + isSubmitting: viewModel.isSubmitting, + submitError: viewModel.submitError, + }; + + return ( + + ); +} \ No newline at end of file diff --git a/apps/website/app/auth/signup/page.tsx b/apps/website/app/auth/signup/page.tsx index b1000560a..fd7ea8ac3 100644 --- a/apps/website/app/auth/signup/page.tsx +++ b/apps/website/app/auth/signup/page.tsx @@ -1,651 +1,34 @@ -'use client'; +/** + * Signup Page + * + * RSC composition pattern: + * 1. PageQuery executes to get ViewData + * 2. Client component renders with ViewData + */ -import { useState, useEffect, FormEvent, type ChangeEvent } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; -import { motion, AnimatePresence } from 'framer-motion'; -import { - Mail, - Lock, - Eye, - EyeOff, - UserPlus, - AlertCircle, - Flag, - User, - Check, - X, - Car, - Users, - Trophy, - Shield, - Sparkles, -} from 'lucide-react'; +import { SignupPageQuery } from '@/lib/page-queries/auth/SignupPageQuery'; +import { SignupClient } from './SignupClient'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; -import { useAuth } from '@/lib/auth/AuthContext'; -import { useSignup } from "@/lib/hooks/auth/useSignup"; -import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; +export default async function SignupPage({ + searchParams, +}: { + searchParams: Promise; +}) { + // Execute PageQuery + const params = await searchParams; + const queryResult = await SignupPageQuery.execute(params); -interface FormErrors { - firstName?: string; - lastName?: string; - email?: string; - password?: string; - confirmPassword?: string; - submit?: string; -} - -interface PasswordStrength { - score: number; - label: string; - color: string; -} - -interface SignupData { - placeholder: string; -} - -function checkPasswordStrength(password: string): PasswordStrength { - let score = 0; - if (password.length >= 8) score++; - if (password.length >= 12) score++; - if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++; - if (/\d/.test(password)) score++; - if (/[^a-zA-Z\d]/.test(password)) score++; - - if (score <= 1) return { score, label: 'Weak', color: 'bg-red-500' }; - if (score <= 2) return { score, label: 'Fair', color: 'bg-warning-amber' }; - if (score <= 3) return { score, label: 'Good', color: 'bg-primary-blue' }; - return { score, label: 'Strong', color: 'bg-performance-green' }; -} - -const USER_ROLES = [ - { - icon: Car, - title: 'Driver', - description: 'Race, track stats, join teams', - color: 'primary-blue', - }, - { - icon: Trophy, - title: 'League Admin', - description: 'Organize leagues and events', - color: 'performance-green', - }, - { - icon: Users, - title: 'Team Manager', - description: 'Manage team and drivers', - color: 'purple-400', - }, -]; - -const FEATURES = [ - 'Track your racing statistics and progress', - 'Join or create competitive leagues', - 'Build or join racing teams', - 'Connect your iRacing account', - 'Compete in organized events', - 'Access detailed performance analytics', -]; - -const SignupTemplate = ({ data }: { data: SignupData }) => { - const router = useRouter(); - const searchParams = useSearchParams(); - const { refreshSession } = useAuth(); - const returnTo = searchParams.get('returnTo') ?? '/onboarding'; - - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [errors, setErrors] = useState({}); - const [formData, setFormData] = useState({ - firstName: '', - lastName: '', - email: '', - password: '', - confirmPassword: '', - }); - - const passwordStrength = checkPasswordStrength(formData.password); - - const passwordRequirements = [ - { met: formData.password.length >= 8, label: 'At least 8 characters' }, - { met: /[a-z]/.test(formData.password) && /[A-Z]/.test(formData.password), label: 'Upper and lowercase letters' }, - { met: /\d/.test(formData.password), label: 'At least one number' }, - { met: /[^a-zA-Z\d]/.test(formData.password), label: 'At least one special character' }, - ]; - - const validateForm = (): boolean => { - const newErrors: FormErrors = {}; - - // First name validation - const firstName = formData.firstName.trim(); - if (!firstName) { - newErrors.firstName = 'First name is required'; - } else if (firstName.length < 2) { - newErrors.firstName = 'First name must be at least 2 characters'; - } else if (firstName.length > 25) { - newErrors.firstName = 'First name must be no more than 25 characters'; - } else if (!/^[A-Za-z\-']+$/.test(firstName)) { - newErrors.firstName = 'First name can only contain letters, hyphens, and apostrophes'; - } else if (/^(user|test|demo|guest|player)/i.test(firstName)) { - newErrors.firstName = 'Please use your real first name, not a nickname'; - } - - // Last name validation - const lastName = formData.lastName.trim(); - if (!lastName) { - newErrors.lastName = 'Last name is required'; - } else if (lastName.length < 2) { - newErrors.lastName = 'Last name must be at least 2 characters'; - } else if (lastName.length > 25) { - newErrors.lastName = 'Last name must be no more than 25 characters'; - } else if (!/^[A-Za-z\-']+$/.test(lastName)) { - newErrors.lastName = 'Last name can only contain letters, hyphens, and apostrophes'; - } else if (/^(user|test|demo|guest|player)/i.test(lastName)) { - newErrors.lastName = 'Please use your real last name, not a nickname'; - } - - if (!formData.email.trim()) { - newErrors.email = 'Email is required'; - } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { - newErrors.email = 'Invalid email format'; - } - - // Password strength validation - if (!formData.password) { - newErrors.password = 'Password is required'; - } else if (formData.password.length < 8) { - newErrors.password = 'Password must be at least 8 characters'; - } else if (!/[a-z]/.test(formData.password) || !/[A-Z]/.test(formData.password) || !/\d/.test(formData.password)) { - newErrors.password = 'Password must contain uppercase, lowercase, and number'; - } - - if (!formData.confirmPassword) { - newErrors.confirmPassword = 'Please confirm your password'; - } else if (formData.password !== formData.confirmPassword) { - newErrors.confirmPassword = 'Passwords do not match'; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - // Use signup mutation hook - const signupMutation = useSignup({ - onSuccess: async () => { - // Refresh session in context so header updates immediately - try { - await refreshSession(); - } catch (error) { - console.error('Failed to refresh session after signup:', error); - } - // Always redirect to dashboard after signup - router.push('/dashboard'); - }, - onError: (error) => { - setErrors({ - submit: error.message || 'Signup failed. Please try again.', - }); - }, - }); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (signupMutation.isPending) return; - - if (!validateForm()) return; - - setErrors({}); - - try { - // Combine first and last name into display name - const displayName = `${formData.firstName} ${formData.lastName}`.trim(); - - await signupMutation.mutateAsync({ - email: formData.email, - password: formData.password, - displayName, - }); - } catch (error) { - // Error handling is done in the mutation's onError callback - } - }; - - return ( -
- {/* Background Pattern */} -
-
-
-
- - {/* Left Side - Info Panel (Hidden on mobile) */} -
-
- {/* Logo */} -
-
- -
- GridPilot -
- - - Start Your Racing Journey - - -

- Join thousands of sim racers. One account gives you access to all roles - race as a driver, organize leagues, or manage teams. -

- - {/* Role Cards */} -
- {USER_ROLES.map((role, index) => ( - -
- -
-
-

{role.title}

-

{role.description}

-
-
- ))} -
- - {/* Features List */} -
-
- - What you'll get -
-
    - {FEATURES.map((feature, index) => ( -
  • - - {feature} -
  • - ))} -
-
- - {/* Trust Indicators */} -
-
- - Secure signup -
-
- iRacing integration -
-
-
-
- - {/* Right Side - Signup Form */} -
-
- {/* Mobile Logo/Header */} -
-
- -
- Join GridPilot -

- Create your account and start racing -

-
- - {/* Desktop Header */} -
- Create Account -

- Get started with your free account -

-
- - - {/* Background accent */} -
- -
- {/* First Name */} -
- -
- - ) => setFormData({ ...formData, firstName: e.target.value })} - error={!!errors.firstName} - errorMessage={errors.firstName} - placeholder="John" - disabled={signupMutation.isPending} - className="pl-10" - autoComplete="given-name" - /> -
-
- - {/* Last Name */} -
- -
- - ) => setFormData({ ...formData, lastName: e.target.value })} - error={!!errors.lastName} - errorMessage={errors.lastName} - placeholder="Smith" - disabled={signupMutation.isPending} - className="pl-10" - autoComplete="family-name" - /> -
-

Your name will be used as-is and cannot be changed later

-
- - {/* Name Immutability Warning */} -
- -
- Important: Your name cannot be changed after signup. Please ensure it's correct. -
-
- - {/* Email */} -
- -
- - ) => setFormData({ ...formData, email: e.target.value })} - error={!!errors.email} - errorMessage={errors.email} - placeholder="you@example.com" - disabled={signupMutation.isPending} - className="pl-10" - autoComplete="email" - /> -
-
- - {/* Password */} -
- -
- - ) => setFormData({ ...formData, password: e.target.value })} - error={!!errors.password} - errorMessage={errors.password} - placeholder="••••••••" - disabled={signupMutation.isPending} - className="pl-10 pr-10" - autoComplete="new-password" - /> - -
- - {/* Password Strength */} - {formData.password && ( -
-
-
- -
- - {passwordStrength.label} - -
-
- {passwordRequirements.map((req, index) => ( -
- {req.met ? ( - - ) : ( - - )} - - {req.label} - -
- ))} -
-
- )} -
- - {/* Confirm Password */} -
- -
- - ) => setFormData({ ...formData, confirmPassword: e.target.value })} - error={!!errors.confirmPassword} - errorMessage={errors.confirmPassword} - placeholder="••••••••" - disabled={signupMutation.isPending} - className="pl-10 pr-10" - autoComplete="new-password" - /> - -
- {formData.confirmPassword && formData.password === formData.confirmPassword && ( -

- Passwords match -

- )} -
- - {/* Submit Button */} - -
- - {/* Divider */} -
-
-
-
-
- or continue with -
-
- - {/* Login Link */} -

- Already have an account?{' '} - - Sign in - -

- - - {/* Footer */} -

- By creating an account, you agree to our{' '} - Terms of Service - {' '}and{' '} - Privacy Policy -

- - {/* Mobile Role Info */} -
-

One account for all roles

-
- {USER_ROLES.map((role) => ( -
-
- -
- {role.title} -
- ))} -
-
-
-
-
- ); -}; - -export default function SignupPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { refreshSession, session } = useAuth(); - const returnTo = searchParams.get('returnTo') ?? '/onboarding'; - - const [checkingAuth, setCheckingAuth] = useState(true); - - // Check if already authenticated - useEffect(() => { - if (session) { - // Already logged in, redirect to dashboard or return URL - router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo); - return; - } - - // If no session, still check via API for consistency - async function checkAuth() { - try { - const response = await fetch('/api/auth/session'); - const data = await response.json(); - if (data.authenticated) { - // Already logged in, redirect to dashboard or return URL - router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo); - } - } catch { - // Not authenticated, continue showing signup page - } finally { - setCheckingAuth(false); - } - } - checkAuth(); - }, [session, router, returnTo]); - - // Use signup mutation hook for state management - const signupMutation = useSignup({ - onSuccess: async () => { - // Refresh session in context so header updates immediately - try { - await refreshSession(); - } catch (error) { - console.error('Failed to refresh session after signup:', error); - } - // Always redirect to dashboard after signup - router.push('/dashboard'); - }, - onError: (error) => { - // Error will be handled in the template - console.error('Signup error:', error); - }, - }); - - // Loading state from mutation - const loading = signupMutation.isPending; - - // Show loading while checking auth - if (checkingAuth) { + if (queryResult.isErr()) { + // Handle query error return ( -
-
-
+
+
Failed to load signup page
+
); } - // Map mutation states to StatefulPageWrapper - return ( - signupMutation.mutate({ email: '', password: '', displayName: '' })} - Template={SignupTemplate} - loading={{ variant: 'full-screen', message: 'Processing signup...' }} - errorConfig={{ variant: 'full-screen' }} - /> - ); + const viewData = queryResult.unwrap(); + + // Render client component with ViewData + return ; } \ No newline at end of file diff --git a/apps/website/app/dashboard/DashboardPageClient.tsx b/apps/website/app/dashboard/DashboardPageClient.tsx deleted file mode 100644 index 02e53d315..000000000 --- a/apps/website/app/dashboard/DashboardPageClient.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -import React from 'react'; -import type { DashboardViewData } from '@/templates/view-data/DashboardViewData'; -import type { DashboardPageDto } from '@/lib/page-queries/page-dtos/DashboardPageDto'; -import { DashboardPresenter } from '@/lib/presenters/DashboardPresenter'; -import { DashboardTemplate } from '@/templates/DashboardTemplate'; - -interface DashboardPageClientProps { - pageDto: DashboardPageDto; -} - -/** - * Dashboard Page Client Component - * - * Uses Presenter to transform Page DTO into ViewData - * Presenter is deterministic and side-effect free - */ -export function DashboardPageClient({ pageDto }: DashboardPageClientProps) { - const viewData: DashboardViewData = DashboardPresenter.createViewData(pageDto); - - return ; -} diff --git a/apps/website/app/dashboard/layout.tsx b/apps/website/app/dashboard/layout.tsx index b0eb3dbfd..f31d5dcda 100644 --- a/apps/website/app/dashboard/layout.tsx +++ b/apps/website/app/dashboard/layout.tsx @@ -1,4 +1,5 @@ import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; import { createRouteGuard } from '@/lib/auth/createRouteGuard'; interface DashboardLayoutProps { @@ -16,11 +17,14 @@ export default async function DashboardLayout({ children }: DashboardLayoutProps const pathname = headerStore.get('x-pathname') || '/'; const guard = createRouteGuard(); - await guard.enforce({ pathname }); + const result = await guard.enforce({ pathname }); + if (result.type === 'redirect') { + redirect(result.to); + } return (
{children}
); -} \ No newline at end of file +} diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index 0f3bda3a0..bf0cef017 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -1,25 +1,26 @@ import { notFound, redirect } from 'next/navigation'; import { DashboardPageQuery } from '@/lib/page-queries/page-queries/DashboardPageQuery'; -import { DashboardPageClient } from './DashboardPageClient'; +import { DashboardTemplate } from '@/templates/DashboardTemplate'; export default async function DashboardPage() { - const result = await DashboardPageQuery.execute(); + const result = await DashboardPageQuery.execute(); - // Handle result based on status - switch (result.status) { - case 'ok': - // Pass Page DTO to client component - return ; - - case 'notFound': - notFound(); - - case 'redirect': - redirect(result.to); - - case 'error': - // For now, treat as notFound. Could also show error page - console.error('Dashboard error:', result.errorId); - notFound(); + if (result.isErr()) { + const error = result.getError(); + + // Handle different error types + if (error === 'notFound') { + notFound(); + } else if (error === 'redirect') { + redirect('/'); + } else { + // DASHBOARD_FETCH_FAILED or UNKNOWN_ERROR + console.error('Dashboard error:', error); + notFound(); } -} + } + + // Success + const viewData = result.unwrap(); + return ; +} \ No newline at end of file diff --git a/apps/website/app/drivers/DriversPageClient.tsx b/apps/website/app/drivers/DriversPageClient.tsx new file mode 100644 index 000000000..e90a3392b --- /dev/null +++ b/apps/website/app/drivers/DriversPageClient.tsx @@ -0,0 +1,84 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { DriversTemplate } from '@/templates/DriversTemplate'; +import { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; +import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO'; +import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO'; + +interface DriversPageClientProps { + pageDto: DriversLeaderboardDTO | null; + error?: string; + empty?: { + title: string; + description: string; + }; +} + +/** + * DriversPageClient + * + * Client component that: + * 1. Handles state (search, filter, sort) + * 2. Calls ViewModel to get computed display data + * 3. Transforms ViewModel to Template-compatible format + * 4. Passes data to Template + */ +export function DriversPageClient({ pageDto, error, empty }: DriversPageClientProps) { + const router = useRouter(); + + // Client state + const [searchQuery, setSearchQuery] = useState(''); + + // Event handlers + const onSearchChange = (query: string) => setSearchQuery(query); + const onDriverClick = (id: string) => router.push(`/drivers/${id}`); + const onBackToLeaderboards = () => router.push('/leaderboards'); + + // Handle error/empty states + if (error) { + return ( +
+
Error loading drivers
+

Please try again later

+
+ ); + } + + if (!pageDto || pageDto.drivers.length === 0) { + if (empty) { + return ( +
+

{empty.title}

+

{empty.description}

+
+ ); + } + return null; + } + + // Transform DTO to ViewModel + const dtoForViewModel: { drivers: DriverLeaderboardItemDTO[] } = { + drivers: pageDto.drivers.map(driver => ({ + ...driver, + avatarUrl: driver.avatarUrl || '', + })), + }; + const viewModel = new DriverLeaderboardViewModel(dtoForViewModel); + + // Filter drivers based on search + let filteredDrivers = viewModel.drivers.filter(driver => { + if (searchQuery) { + const query = searchQuery.toLowerCase(); + const matchesSearch = + driver.name.toLowerCase().includes(query) || + driver.nationality.toLowerCase().includes(query); + if (!matchesSearch) return false; + } + return true; + }); + + // Pass to template + return ; +} \ No newline at end of file diff --git a/apps/website/app/drivers/[id]/DriverProfilePageClient.tsx b/apps/website/app/drivers/[id]/DriverProfilePageClient.tsx new file mode 100644 index 000000000..d6eb15cce --- /dev/null +++ b/apps/website/app/drivers/[id]/DriverProfilePageClient.tsx @@ -0,0 +1,92 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate'; +import { DriverProfileViewModelBuilder } from '@/lib/builders/view-models/DriverProfileViewModelBuilder'; +import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO'; + +interface DriverProfilePageClientProps { + pageDto: GetDriverProfileOutputDTO | null; + error?: string; + empty?: { + title: string; + description: string; + }; +} + +/** + * DriverProfilePageClient + * + * Client component that: + * 1. Handles UI state (tabs, friend requests) + * 2. Uses ViewModelBuilder to transform DTO + * 3. Passes ViewModel to Template + */ +export function DriverProfilePageClient({ pageDto, error, empty }: DriverProfilePageClientProps) { + const router = useRouter(); + + // UI State + const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview'); + const [friendRequestSent, setFriendRequestSent] = useState(false); + + // Event handlers + const handleAddFriend = () => { + setFriendRequestSent(true); + }; + + const handleBackClick = () => { + router.push('/drivers'); + }; + + // Handle error/empty states + if (error) { + return ( +
+
Error loading driver profile
+

Please try again later

+
+ ); + } + + if (!pageDto || !pageDto.currentDriver) { + if (empty) { + return ( +
+
+

{empty.title}

+

{empty.description}

+
+
+ ); + } + return null; + } + + // Transform DTO to ViewModel using Builder + const viewModel = DriverProfileViewModelBuilder.build(pageDto); + + // Transform teamMemberships for template + const allTeamMemberships = pageDto.teamMemberships.map(membership => ({ + team: { + id: membership.teamId, + name: membership.teamName, + }, + role: membership.role, + joinedAt: new Date(membership.joinedAt), + })); + + return ( + + ); +} \ No newline at end of file diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index bd486f611..fc46fe615 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -1,81 +1,45 @@ -'use client'; +import { redirect } from 'next/navigation'; +import { DriverProfilePageQuery } from '@/lib/page-queries/page-queries/DriverProfilePageQuery'; +import { DriverProfilePageClient } from './DriverProfilePageClient'; -import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; -import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate'; -import { useDriverProfilePageData } from "@/lib/hooks/driver/useDriverProfilePageData"; -import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; -import { useParams, useRouter } from 'next/navigation'; -import { useState } from 'react'; +export default async function DriverProfilePage({ params }: { params: { id: string } }) { + // Execute the page query + const result = await DriverProfilePageQuery.execute(params.id); -interface DriverProfileData { - driverProfile: any; - teamMemberships: Array<{ - team: { id: string; name: string }; - role: string; - joinedAt: Date; - }>; -} - -export default function DriverProfilePage() { - const router = useRouter(); - const params = useParams(); - const driverId = params.id as string; - const currentDriverId = useEffectiveDriverId() || ''; - - // UI State - const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview'); - const [friendRequestSent, setFriendRequestSent] = useState(false); - - // Fetch data using domain hook - const { data: queries, isLoading, error, refetch } = useDriverProfilePageData(driverId); - - // Transform data for template - const data: DriverProfileData | undefined = queries?.driverProfile && queries?.teamMemberships - ? { - driverProfile: queries.driverProfile, - teamMemberships: queries.teamMemberships, - } - : undefined; - - // Actions - const handleAddFriend = () => { - setFriendRequestSent(true); - }; - - const handleBackClick = () => { - router.push('/drivers'); - }; - - return ( - { - if (!data) return null; + // Handle different result statuses + switch (result.status) { + case 'notFound': + redirect('/404'); + case 'redirect': + redirect(result.to); + case 'error': + // Pass error to client component + return ( + + ); + case 'ok': + const pageDto = result.dto; + const hasData = !!pageDto.currentDriver; + + if (!hasData) { return ( - ); - }} - loading={{ variant: 'skeleton', message: 'Loading driver profile...' }} - errorConfig={{ variant: 'full-screen' }} - empty={{ - icon: require('lucide-react').Car, - title: 'Driver not found', - description: 'The driver profile may not exist or you may not have access', - action: { label: 'Back to Drivers', onClick: handleBackClick } - }} - /> - ); + } + + return ( + + ); + } } \ No newline at end of file diff --git a/apps/website/app/drivers/page.tsx b/apps/website/app/drivers/page.tsx index 88d9bbf12..276d0389f 100644 --- a/apps/website/app/drivers/page.tsx +++ b/apps/website/app/drivers/page.tsx @@ -1,28 +1,45 @@ -import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import { DriversTemplate } from '@/templates/DriversTemplate'; -import { DriverService } from '@/lib/services/drivers/DriverService'; -import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; -import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; -import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; -import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; +import { redirect } from 'next/navigation'; +import { DriversPageQuery } from '@/lib/page-queries/page-queries/DriversPageQuery'; +import { DriversPageClient } from './DriversPageClient'; export default async function Page() { - // Manual dependency creation (consistent with /races and /teams) - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: true, - logToConsole: true, - reportToExternal: process.env.NODE_ENV === 'production', - }); + // Execute the page query + const result = await DriversPageQuery.execute(); - // Create API client - const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger); + // Handle different result statuses + switch (result.status) { + case 'notFound': + redirect('/404'); + case 'redirect': + redirect(result.to); + case 'error': + // Pass error to client component + return ( + + ); + case 'ok': + const pageDto = result.dto; + const hasData = (pageDto.drivers?.length ?? 0) > 0; + + if (!hasData) { + return ( + + ); + } - // Create service - const service = new DriverService(driversApiClient); - - const data = await service.getDriverLeaderboard(); - - return ; + return ( + + ); + } } \ No newline at end of file diff --git a/apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx b/apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx index 0a155d27e..6d2b3b968 100644 --- a/apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx +++ b/apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx @@ -2,24 +2,15 @@ import { useRouter } from 'next/navigation'; import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate'; -import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; -import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; +import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData'; -interface LeaderboardsPageData { - drivers: DriverLeaderboardViewModel | null; - teams: TeamSummaryViewModel[] | null; -} - -export function LeaderboardsPageWrapper({ data }: { data: LeaderboardsPageData | null }) { +export function LeaderboardsPageWrapper({ data }: { data: LeaderboardsViewData | null }) { const router = useRouter(); if (!data || (!data.drivers && !data.teams)) { return null; } - const drivers = data.drivers?.drivers || []; - const teams = data.teams || []; - const handleDriverClick = (driverId: string) => { router.push(`/drivers/${driverId}`); }; @@ -36,14 +27,34 @@ export function LeaderboardsPageWrapper({ data }: { data: LeaderboardsPageData | router.push('/teams/leaderboard'); }; - return ( - - ); + // Transform ViewData to template props + const templateData = { + drivers: data.drivers.map(d => ({ + id: d.id, + name: d.name, + rating: d.rating, + skillLevel: d.skillLevel, + nationality: d.nationality, + wins: d.wins, + rank: d.rank, + avatarUrl: d.avatarUrl, + position: d.position, + })), + teams: data.teams.map(t => ({ + id: t.id, + name: t.name, + tag: t.tag, + memberCount: t.memberCount, + category: t.category, + totalWins: t.totalWins, + logoUrl: t.logoUrl, + position: t.position, + })), + onDriverClick: handleDriverClick, + onTeamClick: handleTeamClick, + onNavigateToDrivers: handleNavigateToDrivers, + onNavigateToTeams: handleNavigateToTeams, + }; + + return ; } \ No newline at end of file diff --git a/apps/website/app/leaderboards/drivers/DriverRankingsPageWrapper.tsx b/apps/website/app/leaderboards/drivers/DriverRankingsPageWrapper.tsx deleted file mode 100644 index 995bd0853..000000000 --- a/apps/website/app/leaderboards/drivers/DriverRankingsPageWrapper.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate'; -import { useState } from 'react'; -import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; - -type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; -type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate'; - -export function DriverRankingsPageWrapper({ data }: { data: DriverLeaderboardViewModel | null }) { - const router = useRouter(); - - // Client-side state for filtering and sorting - const [searchQuery, setSearchQuery] = useState(''); - const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all'); - const [sortBy, setSortBy] = useState('rank'); - const [showFilters, setShowFilters] = useState(false); - - if (!data || !data.drivers) { - return null; - } - - const handleDriverClick = (driverId: string) => { - if (driverId.startsWith('demo-')) return; - router.push(`/drivers/${driverId}`); - }; - - const handleBackToLeaderboards = () => { - router.push('/leaderboards'); - }; - - return ( - setShowFilters(!showFilters)} - onDriverClick={handleDriverClick} - onBackToLeaderboards={handleBackToLeaderboards} - /> - ); -} \ No newline at end of file diff --git a/apps/website/app/leaderboards/drivers/page.tsx b/apps/website/app/leaderboards/drivers/page.tsx index d7110fcdb..93b0494a3 100644 --- a/apps/website/app/leaderboards/drivers/page.tsx +++ b/apps/website/app/leaderboards/drivers/page.tsx @@ -1,50 +1,51 @@ -import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; -import { DriverService } from '@/lib/services/drivers/DriverService'; -import { Users } from 'lucide-react'; import { redirect } from 'next/navigation'; -import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; -import { DriverRankingsPageWrapper } from './DriverRankingsPageWrapper'; +import { DriverRankingsPageQuery } from '@/lib/page-queries/page-queries/DriverRankingsPageQuery'; +import { DriverRankingsViewDataBuilder } from '@/lib/builders/view-data/DriverRankingsViewDataBuilder'; +import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate'; // ============================================================================ // MAIN PAGE COMPONENT // ============================================================================ export default async function DriverLeaderboardPage() { - // Fetch data using PageDataFetcher - const driverData = await PageDataFetcher.fetch( - DRIVER_SERVICE_TOKEN, - 'getDriverLeaderboard' - ); + // Execute the page query + const result = await DriverRankingsPageQuery.execute(); - // Prepare data for template - const data: DriverLeaderboardViewModel | null = driverData; + // Handle different result statuses + switch (result.status) { + case 'notFound': + redirect('/404'); + case 'redirect': + redirect(result.to); + case 'error': + // For now, show empty state. In a real app, you'd pass error to client + return ( +
+
Error loading driver rankings
+

Please try again later

+
+ ); + case 'ok': + const viewData = DriverRankingsViewDataBuilder.build(result.dto); + const hasData = (viewData.drivers?.length ?? 0) > 0; + + if (!hasData) { + return ( + + ); + } - const hasData = (driverData?.drivers?.length ?? 0) > 0; - - // Handle loading state (should be fast since we're using async/await) - const isLoading = false; - const error = null; - const retry = async () => { - // In server components, we can't retry without a reload - redirect('/leaderboards/drivers'); - }; - - return ( - - ); + return ( + + ); + } } \ No newline at end of file diff --git a/apps/website/app/leaderboards/page.tsx b/apps/website/app/leaderboards/page.tsx index 6a3571859..7396a65e0 100644 --- a/apps/website/app/leaderboards/page.tsx +++ b/apps/website/app/leaderboards/page.tsx @@ -1,71 +1,61 @@ import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { DRIVER_SERVICE_TOKEN, TEAM_SERVICE_TOKEN } from '@/lib/di/tokens'; -import { DriverService } from '@/lib/services/drivers/DriverService'; -import { TeamService } from '@/lib/services/teams/TeamService'; import { Trophy } from 'lucide-react'; import { redirect } from 'next/navigation'; -import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; -import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; +import { LeaderboardsPageQuery } from '@/lib/page-queries/page-queries/LeaderboardsPageQuery'; +import { LeaderboardsViewDataBuilder } from '@/lib/builders/view-data/LeaderboardsViewDataBuilder'; import { LeaderboardsPageWrapper } from './LeaderboardsPageWrapper'; -// ============================================================================ -// TYPES -// ============================================================================ - -interface LeaderboardsPageData { - drivers: DriverLeaderboardViewModel | null; - teams: TeamSummaryViewModel[] | null; -} - // ============================================================================ // MAIN PAGE COMPONENT // ============================================================================ export default async function LeaderboardsPage() { - // Fetch data using PageDataFetcher with proper type annotations - const [driverData, teamsData] = await Promise.all([ - PageDataFetcher.fetch( - DRIVER_SERVICE_TOKEN, - 'getDriverLeaderboard' - ), - PageDataFetcher.fetch( - TEAM_SERVICE_TOKEN, - 'getAllTeams' - ), - ]); + // Execute the page query + const result = await LeaderboardsPageQuery.execute(); - // Prepare data for template - const data: LeaderboardsPageData = { - drivers: driverData, - teams: teamsData, - }; + // Handle different result statuses + switch (result.status) { + case 'notFound': + redirect('/404'); + case 'redirect': + redirect(result.to); + case 'error': + // Show empty state with error + return ( + redirect('/leaderboards')} + Template={LeaderboardsPageWrapper} + loading={{ variant: 'full-screen', message: 'Loading leaderboards...' }} + errorConfig={{ variant: 'full-screen' }} + empty={{ + icon: Trophy, + title: 'No leaderboard data', + description: 'There is no leaderboard data available at the moment.', + }} + /> + ); + case 'ok': + const viewData = LeaderboardsViewDataBuilder.build(result.dto.drivers, result.dto.teams); + const hasData = (viewData.drivers?.length ?? 0) > 0 || (viewData.teams?.length ?? 0) > 0; - const hasData = (driverData?.drivers?.length ?? 0) > 0 || (teamsData?.length ?? 0) > 0; - - // Handle loading state (should be fast since we're using async/await) - const isLoading = false; - const error = null; - const retry = async () => { - // In server components, we can't retry without a reload - // This would typically trigger a page reload - redirect('/leaderboards'); - }; - - return ( - - ); + return ( + redirect('/leaderboards')} + Template={LeaderboardsPageWrapper} + loading={{ variant: 'full-screen', message: 'Loading leaderboards...' }} + errorConfig={{ variant: 'full-screen' }} + empty={{ + icon: Trophy, + title: 'No leaderboard data', + description: 'There is no leaderboard data available at the moment.', + }} + /> + ); + } } \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/layout.tsx b/apps/website/app/leagues/[id]/layout.tsx index 2475fc7d4..63e42db6c 100644 --- a/apps/website/app/leagues/[id]/layout.tsx +++ b/apps/website/app/leagues/[id]/layout.tsx @@ -18,7 +18,7 @@ export default function LeagueLayout({ const leagueId = params.id as string; const currentDriverId = useEffectiveDriverId(); - const { data: leagueDetail, isLoading: loading } = useLeagueDetail(leagueId, currentDriverId ?? ''); + const { data: leagueDetail, isLoading: loading } = useLeagueDetail({ leagueId }); if (loading) { return ( @@ -56,8 +56,9 @@ export default function LeagueLayout({ { label: 'Settings', href: `/leagues/${leagueId}/settings`, exact: false }, ]; - const tabs = leagueDetail.isAdmin ? [...baseTabs, ...adminTabs] : baseTabs; - + // TODO: Admin check needs to be implemented properly + // For now, show admin tabs if user is logged in + const tabs = [...baseTabs, ...adminTabs]; return (
@@ -75,8 +76,8 @@ export default function LeagueLayout({ leagueName={leagueDetail.name} description={leagueDetail.description} ownerId={leagueDetail.ownerId} - ownerName={leagueDetail.ownerName} - mainSponsor={leagueDetail.mainSponsor} + ownerName={''} + mainSponsor={null} /> {/* Tab Navigation */} diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index afce11d13..cfd7f7a25 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -1,15 +1,8 @@ +import { notFound } from 'next/navigation'; import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { LeagueService } from '@/lib/services/leagues/LeagueService'; -import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; -import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; -import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient'; -import { RacesApiClient } from '@/lib/api/races/RacesApiClient'; -import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; -import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; -import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; -import { notFound } from 'next/navigation'; +import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery'; +import { LeagueDetailPresenter } from '@/lib/presenters/LeagueDetailPresenter'; import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel'; interface Props { @@ -22,58 +15,58 @@ export default async function Page({ params }: Props) { notFound(); } - // Fetch data using PageDataFetcher.fetchManual for multiple dependencies - const data = await PageDataFetcher.fetchManual(async () => { - // Create dependencies - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: true, - logToConsole: true, - reportToExternal: process.env.NODE_ENV === 'production', - }); - - // Create API clients - const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger); - const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger); - const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger); - const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger); - - // Create service - const service = new LeagueService( - leaguesApiClient, - driversApiClient, - sponsorsApiClient, - racesApiClient - ); - - // Fetch data - const result = await service.getLeagueDetailPageData(params.id); - if (!result) { - throw new Error('League not found'); + // Execute the PageQuery + const result = await LeagueDetailPageQuery.execute(params.id); + + // Handle different result types + if (result.isErr()) { + const error = result.getError(); + + switch (error) { + case 'notFound': + notFound(); + case 'redirect': + // In a real app, this would redirect to login + notFound(); + case 'LEAGUE_FETCH_FAILED': + case 'UNKNOWN_ERROR': + default: + // Return error state that PageWrapper can handle + // For error state, we need a simple template that just renders an error + const ErrorTemplate: React.ComponentType<{ data: any }> = ({ data }) => ( +
Error state
+ ); + return ( + + ); } - return result; - }); - - if (!data) { - notFound(); } - - // Create a wrapper component that passes data to the template - const TemplateWrapper = ({ data }: { data: LeagueDetailPageViewModel }) => { - // The LeagueDetailTemplate expects multiple props beyond just data - // We need to provide the additional props it requires + + const data = result.unwrap(); + + // Convert the API DTO to ViewModel using the existing presenter + // This maintains compatibility with the existing template + const viewModel = data.apiDto as unknown as LeagueDetailPageViewModel; + + // Create a wrapper component that passes ViewData to the template + const TemplateWrapper: React.ComponentType<{ data: typeof data }> = ({ data }) => { + // Convert ViewModel to ViewData using Presenter + const viewData = LeagueDetailPresenter.createViewData(viewModel, params.id, false); + return ( {}} onEndRaceModalOpen={() => {}} onLiveRaceClick={() => {}} - onBackToLeagues={() => {}} /> ); }; diff --git a/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx b/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx index 008eab81b..9d292ccf8 100644 --- a/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx +++ b/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx @@ -5,8 +5,8 @@ import type { MembershipRole } from '@/lib/types/MembershipRole'; import { useParams } from 'next/navigation'; import { useMemo } from 'react'; import { - useLeagueRosterJoinRequests, - useLeagueRosterMembers, + useLeagueJoinRequests, + useLeagueRosterAdmin, useApproveJoinRequest, useRejectJoinRequest, useUpdateMemberRole, @@ -24,13 +24,13 @@ export function RosterAdminPage() { data: joinRequests = [], isLoading: loadingJoinRequests, refetch: refetchJoinRequests, - } = useLeagueRosterJoinRequests(leagueId); + } = useLeagueJoinRequests(leagueId); const { data: members = [], isLoading: loadingMembers, refetch: refetchMembers, - } = useLeagueRosterMembers(leagueId); + } = useLeagueRosterAdmin(leagueId); const loading = loadingJoinRequests || loadingMembers; @@ -55,16 +55,16 @@ export function RosterAdminPage() { return joinRequests.length === 1 ? '1 request' : `${joinRequests.length} requests`; }, [joinRequests.length]); - const handleApprove = async (joinRequestId: string) => { - await approveMutation.mutateAsync({ leagueId, joinRequestId }); + const handleApprove = async (requestId: string) => { + await approveMutation.mutateAsync({ leagueId, requestId }); }; - const handleReject = async (joinRequestId: string) => { - await rejectMutation.mutateAsync({ leagueId, joinRequestId }); + const handleReject = async (requestId: string) => { + await rejectMutation.mutateAsync({ leagueId, requestId }); }; const handleRoleChange = async (driverId: string, newRole: MembershipRole) => { - await updateRoleMutation.mutateAsync({ leagueId, driverId, role: newRole }); + await updateRoleMutation.mutateAsync({ leagueId, driverId, newRole }); }; const handleRemove = async (driverId: string) => { @@ -96,8 +96,8 @@ export function RosterAdminPage() { className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3" >
-

{req.driverName}

-

{req.requestedAtIso}

+

{(req.driver as any)?.name || 'Unknown'}

+

{req.requestedAt}

{req.message ?

{req.message}

: null}
@@ -140,17 +140,17 @@ export function RosterAdminPage() { className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3" >
-

{member.driverName}

-

{member.joinedAtIso}

+

{member.driver.name}

+

{member.joinedAt}

setBio(e.target.value)} + placeholder="Bio" + /> + + setCountryCode(e.target.value)} + placeholder="Country code (e.g. DE)" + /> + + {saveError ?

{saveError}

: null} + + + + + + + ); + } + + return ( + + {viewData.driver.name || 'Profile'} + + + + setActiveTab(tabId as ProfileTab)} + /> + + {activeTab === 'overview' ? ( + + Driver +

{viewData.driver.countryCode}

+

{viewData.driver.joinedAtLabel}

+

{viewData.driver.bio ?? ''}

+
+ ) : null} + + {activeTab === 'history' ? ( + + Race history +

Race history is currently unavailable in this view.

+
+ ) : null} + + {activeTab === 'stats' ? ( + + Stats +

{viewData.stats?.ratingLabel ?? ''}

+

{viewData.stats?.globalRankLabel ?? ''}

+
+ ) : null} +
+ ); +} diff --git a/apps/website/app/profile/actions.ts b/apps/website/app/profile/actions.ts new file mode 100644 index 000000000..66aac49ff --- /dev/null +++ b/apps/website/app/profile/actions.ts @@ -0,0 +1,20 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { Result } from '@/lib/contracts/Result'; +import { routes } from '@/lib/routing/RouteConfig'; +import { UpdateDriverProfileMutation } from '@/lib/mutations/drivers/UpdateDriverProfileMutation'; + +export async function updateProfileAction( + updates: { bio?: string; country?: string }, +): Promise> { + const mutation = new UpdateDriverProfileMutation(); + const result = await mutation.execute({ bio: updates.bio, country: updates.country }); + + if (result.isErr()) { + return Result.err(result.getError()); + } + + revalidatePath(routes.protected.profile); + return Result.ok(undefined); +} diff --git a/apps/website/app/profile/layout.tsx b/apps/website/app/profile/layout.tsx index 34cc11a20..3c43443dd 100644 --- a/apps/website/app/profile/layout.tsx +++ b/apps/website/app/profile/layout.tsx @@ -1,26 +1,22 @@ +import type { ReactNode } from 'react'; import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import ProfileLayoutShell from '@/components/profile/ProfileLayoutShell'; import { createRouteGuard } from '@/lib/auth/createRouteGuard'; interface ProfileLayoutProps { - children: React.ReactNode; + children: ReactNode; } -/** - * Profile Layout - * - * Provides authentication protection for all profile-related routes. - * Uses RouteGuard to enforce access control server-side. - */ export default async function ProfileLayout({ children }: ProfileLayoutProps) { const headerStore = await headers(); const pathname = headerStore.get('x-pathname') || '/'; - + const guard = createRouteGuard(); - await guard.enforce({ pathname }); - - return ( -
- {children} -
- ); -} \ No newline at end of file + const result = await guard.enforce({ pathname }); + if (result.type === 'redirect') { + redirect(result.to); + } + + return {children}; +} diff --git a/apps/website/app/profile/leagues/page.tsx b/apps/website/app/profile/leagues/page.tsx index 36e7f33e9..8a9ccae61 100644 --- a/apps/website/app/profile/leagues/page.tsx +++ b/apps/website/app/profile/leagues/page.tsx @@ -1,5 +1,5 @@ import { notFound } from 'next/navigation'; -import { ProfileLeaguesPageQuery } from '@/lib/page-queries/ProfileLeaguesPageQuery'; +import { ProfileLeaguesPageQuery } from '@/lib/page-queries/page-queries/ProfileLeaguesPageQuery'; import { ProfileLeaguesPageClient } from './ProfileLeaguesPageClient'; export default async function ProfileLeaguesPage() { diff --git a/apps/website/app/profile/liveries/page.tsx b/apps/website/app/profile/liveries/page.tsx index 2074b9d69..86ad42383 100644 --- a/apps/website/app/profile/liveries/page.tsx +++ b/apps/website/app/profile/liveries/page.tsx @@ -1,164 +1,23 @@ -'use client'; - -import { useState } from 'react'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import { Paintbrush, Upload, Car, Download, Trash2, Edit } from 'lucide-react'; import Link from 'next/link'; +import Button from '@/components/ui/Button'; +import Card from '@/components/ui/Card'; +import Container from '@/components/ui/Container'; +import Heading from '@/components/ui/Heading'; +import { routes } from '@/lib/routing/RouteConfig'; -interface DriverLiveryItem { - id: string; - carId: string; - carName: string; - thumbnailUrl: string; - uploadedAt: Date; - isValidated: boolean; -} - -export default function DriverLiveriesPage() { - const [liveries] = useState([]); - +export default async function ProfileLiveriesPage() { return ( -
- {/* Header */} -
-
-
- -
-
-

My Liveries

-

Manage your car liveries across leagues

-
-
- - + + Liveries + +

Livery management is currently unavailable.

+ + -
- - {/* Livery Collection */} - {liveries.length === 0 ? ( - -
-
- -
-

No Liveries Yet

-

- Upload your first livery. Use the same livery across multiple leagues or create custom ones for each. -

- - - -
-
- ) : ( -
- {liveries.map((livery) => ( - - {/* Livery Preview */} -
- -
- - {/* Livery Info */} -
-
-

{livery.carName}

- {livery.isValidated ? ( - - Validated - - ) : ( - - Pending - - )} -
- -

- Uploaded {new Date(livery.uploadedAt).toLocaleDateString()} -

- - {/* Actions */} -
- - - -
-
-
- ))} -
- )} - - {/* Info Section */} -
- -

Livery Requirements

-
    -
  • - - PNG or DDS format, max 5MB -
  • -
  • - - No logos or text allowed on base livery -
  • -
  • - - Sponsor decals are added by league admins -
  • -
  • - - Your driver name and number are added automatically -
  • -
-
- - -

How It Works

-
    -
  1. - 1. - Upload your base livery for each car you race -
  2. -
  3. - 2. - Position your name and number decals -
  4. -
  5. - 3. - League admins add sponsor logos -
  6. -
  7. - 4. - Download the final pack with all decals burned in -
  8. -
-
-
- - {/* Alpha Notice */} -
-

- Alpha Note: Livery management is demonstration-only. - In production, liveries are stored in cloud storage and composited with sponsor decals. -

-
-
+ + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/app/profile/liveries/upload/page.tsx b/apps/website/app/profile/liveries/upload/page.tsx index 7d734e1fb..09083ee7d 100644 --- a/apps/website/app/profile/liveries/upload/page.tsx +++ b/apps/website/app/profile/liveries/upload/page.tsx @@ -1,405 +1,20 @@ -'use client'; - -import { useState, useRef, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import Card from '@/components/ui/Card'; +import Link from 'next/link'; import Button from '@/components/ui/Button'; -import { Upload, Check, AlertTriangle, Car, RotateCw, Gamepad2 } from 'lucide-react'; - -interface DecalPosition { - id: string; - type: 'name' | 'number' | 'rank'; - x: number; - y: number; - width: number; - height: number; - rotation: number; -} - -interface GameOption { - id: string; - name: string; -} - -interface CarOption { - id: string; - name: string; - manufacturer: string; - gameId: string; -} - -// Mock data - in production these would come from API -const GAMES: GameOption[] = [ - { id: 'iracing', name: 'iRacing' }, - { id: 'acc', name: 'Assetto Corsa Competizione' }, - { id: 'ac', name: 'Assetto Corsa' }, - { id: 'rf2', name: 'rFactor 2' }, - { id: 'ams2', name: 'Automobilista 2' }, - { id: 'lmu', name: 'Le Mans Ultimate' }, -]; - -const CARS: CarOption[] = [ - // iRacing cars - { id: 'ir-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'iracing' }, - { id: 'ir-ferrari-296-gt3', name: '296 GT3', manufacturer: 'Ferrari', gameId: 'iracing' }, - { id: 'ir-bmw-m4-gt3', name: 'M4 GT3', manufacturer: 'BMW', gameId: 'iracing' }, - { id: 'ir-mercedes-amg-gt3', name: 'AMG GT3 Evo', manufacturer: 'Mercedes-AMG', gameId: 'iracing' }, - { id: 'ir-audi-r8-gt3', name: 'R8 LMS GT3 Evo II', manufacturer: 'Audi', gameId: 'iracing' }, - { id: 'ir-dallara-f3', name: 'F3', manufacturer: 'Dallara', gameId: 'iracing' }, - { id: 'ir-dallara-ir18', name: 'IR-18', manufacturer: 'Dallara', gameId: 'iracing' }, - // ACC cars - { id: 'acc-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'acc' }, - { id: 'acc-ferrari-296-gt3', name: '296 GT3', manufacturer: 'Ferrari', gameId: 'acc' }, - { id: 'acc-bmw-m4-gt3', name: 'M4 GT3', manufacturer: 'BMW', gameId: 'acc' }, - { id: 'acc-mercedes-amg-gt3', name: 'AMG GT3 Evo', manufacturer: 'Mercedes-AMG', gameId: 'acc' }, - { id: 'acc-lamborghini-huracan-gt3', name: 'Huracán GT3 Evo2', manufacturer: 'Lamborghini', gameId: 'acc' }, - { id: 'acc-aston-martin-v8-gt3', name: 'V8 Vantage GT3', manufacturer: 'Aston Martin', gameId: 'acc' }, - // AC cars - { id: 'ac-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'ac' }, - { id: 'ac-ferrari-488-gt3', name: '488 GT3', manufacturer: 'Ferrari', gameId: 'ac' }, - { id: 'ac-lotus-exos', name: 'Exos 125', manufacturer: 'Lotus', gameId: 'ac' }, - // rFactor 2 cars - { id: 'rf2-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'rf2' }, - { id: 'rf2-bmw-m4-gt3', name: 'M4 GT3', manufacturer: 'BMW', gameId: 'rf2' }, - // AMS2 cars - { id: 'ams2-porsche-911-gt3r', name: '911 GT3 R', manufacturer: 'Porsche', gameId: 'ams2' }, - { id: 'ams2-mclaren-720s-gt3', name: '720S GT3', manufacturer: 'McLaren', gameId: 'ams2' }, - // LMU cars - { id: 'lmu-porsche-963', name: '963 LMDh', manufacturer: 'Porsche', gameId: 'lmu' }, - { id: 'lmu-ferrari-499p', name: '499P', manufacturer: 'Ferrari', gameId: 'lmu' }, - { id: 'lmu-toyota-gr010', name: 'GR010', manufacturer: 'Toyota', gameId: 'lmu' }, -]; - -export default function LiveryUploadPage() { - const router = useRouter(); - const fileInputRef = useRef(null); - const [uploadedFile, setUploadedFile] = useState(null); - const [previewUrl, setPreviewUrl] = useState(null); - const [selectedGame, setSelectedGame] = useState(''); - const [selectedCar, setSelectedCar] = useState(''); - const [filteredCars, setFilteredCars] = useState([]); - const [decals, setDecals] = useState([ - { id: 'name', type: 'name', x: 0.1, y: 0.8, width: 0.2, height: 0.05, rotation: 0 }, - { id: 'number', type: 'number', x: 0.8, y: 0.1, width: 0.15, height: 0.15, rotation: 0 }, - { id: 'rank', type: 'rank', x: 0.05, y: 0.1, width: 0.1, height: 0.1, rotation: 0 }, - ]); - const [activeDecal, setActiveDecal] = useState(null); - const [submitting, setSubmitting] = useState(false); - - // Filter cars when game changes - useEffect(() => { - if (selectedGame) { - const cars = CARS.filter(car => car.gameId === selectedGame); - setFilteredCars(cars); - setSelectedCar(''); // Reset car selection when game changes - } else { - setFilteredCars([]); - setSelectedCar(''); - } - }, [selectedGame]); - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - setUploadedFile(file); - const url = URL.createObjectURL(file); - setPreviewUrl(url); - } - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - const file = e.dataTransfer.files?.[0]; - if (file) { - setUploadedFile(file); - const url = URL.createObjectURL(file); - setPreviewUrl(url); - } - }; - - const handleSubmit = async () => { - if (!uploadedFile || !selectedGame || !selectedCar) return; - - setSubmitting(true); - - try { - // Alpha: In-memory only - console.log('Livery upload:', { - file: uploadedFile.name, - gameId: selectedGame, - carId: selectedCar, - decals, - }); - - await new Promise(resolve => setTimeout(resolve, 1000)); - - alert('Livery uploaded successfully.'); - router.push('/profile/liveries'); - } catch (err) { - console.error('Upload failed:', err); - alert('Upload failed. Try again.'); - } finally { - setSubmitting(false); - } - }; +import Card from '@/components/ui/Card'; +import Container from '@/components/ui/Container'; +import Heading from '@/components/ui/Heading'; +import { routes } from '@/lib/routing/RouteConfig'; +export default async function ProfileLiveryUploadPage() { return ( -
- {/* Header */} -
-
-
- -
-
-

Upload Livery

-

Add a new livery to your collection

-
-
-
- -
- {/* Upload Section */} - -

Livery File

- - {/* Game Selection */} -
- - -
- - {/* Car Selection */} -
- - - {selectedGame && filteredCars.length === 0 && ( -

No cars available for this game

- )} -
- - {/* File Upload */} -
fileInputRef.current?.click()} - onDrop={handleDrop} - onDragOver={(e) => e.preventDefault()} - className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${ - previewUrl - ? 'border-performance-green/50 bg-performance-green/5' - : 'border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5' - }`} - > - - - {previewUrl ? ( -
- -

{uploadedFile?.name}

-

Click to replace

-
- ) : ( -
- -

- Drop your livery here or click to browse -

-

PNG or DDS, max 5MB

-
- )} -
- - {/* Validation Warning */} -
-
- -

- No logos or text allowed.{' '} - Your base livery must be clean. Sponsor logos are added by league admins. -

-
-
-
- - {/* Decal Editor */} - -

Position Decals

-

- Drag to position your driver name, number, and rank badge. -

- - {/* Preview Canvas */} -
- {previewUrl ? ( - Livery preview - ) : ( -
- -
- )} - - {/* Decal Placeholders */} - {decals.map((decal) => ( -
setActiveDecal(decal.id === activeDecal ? null : decal.id)} - className={`absolute cursor-move border-2 rounded flex items-center justify-center text-xs font-medium transition-all ${ - activeDecal === decal.id - ? 'border-primary-blue bg-primary-blue/20 text-primary-blue' - : 'border-white/30 bg-black/30 text-white/70' - }`} - style={{ - left: `${decal.x * 100}%`, - top: `${decal.y * 100}%`, - width: `${decal.width * 100}%`, - height: `${decal.height * 100}%`, - transform: `rotate(${decal.rotation}deg)`, - }} - > - {decal.type === 'name' && 'NAME'} - {decal.type === 'number' && '#'} - {decal.type === 'rank' && 'RANK'} -
- ))} -
- - {/* Decal Controls */} -
- {decals.map((decal) => ( - - ))} -
- - {/* Rotation Controls */} - {activeDecal && ( -
-
- - {decals.find(d => d.id === activeDecal)?.type} Rotation - - - {decals.find(d => d.id === activeDecal)?.rotation}° - -
-
- d.id === activeDecal)?.rotation ?? 0} - onChange={(e) => { - const rotation = parseInt(e.target.value, 10); - setDecals(decals.map(d => - d.id === activeDecal ? { ...d, rotation } : d - )); - }} - className="flex-1 h-2 bg-charcoal-outline rounded-lg appearance-none cursor-pointer accent-primary-blue" - /> - -
-
- )} - -

- Click a decal above, then drag on preview to reposition. Use the slider or button to rotate. -

-
-
- - {/* Actions */} -
- - -
- - {/* Alpha Notice */} -
-

- Alpha Note: Livery upload is demonstration-only. - Decal positioning and image validation are not functional in this preview. -

-
-
+ + Upload livery + +

Livery upload is currently unavailable.

+ + + +
+
); -} \ No newline at end of file +} diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx index ee4d965a5..e0c8744f1 100644 --- a/apps/website/app/profile/page.tsx +++ b/apps/website/app/profile/page.tsx @@ -1,1112 +1,17 @@ -'use client'; +import { ProfilePageQuery } from '@/lib/page-queries/page-queries/ProfilePageQuery'; +import { notFound } from 'next/navigation'; +import { updateProfileAction } from './actions'; +import { ProfilePageClient } from './ProfilePageClient'; -import CreateDriverForm from '@/components/drivers/CreateDriverForm'; -import ProfileRaceHistory from '@/components/drivers/ProfileRaceHistory'; -import ProfileSettings from '@/components/drivers/ProfileSettings'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import Heading from '@/components/ui/Heading'; -import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; -import { useDriverProfile } from "@/lib/hooks/driver/useDriverProfile"; -import { useInject } from '@/lib/di/hooks/useInject'; -import { DRIVER_SERVICE_TOKEN, MEDIA_SERVICE_TOKEN } from '@/lib/di/tokens'; -import type { - DriverProfileAchievementViewModel, - DriverProfileSocialHandleViewModel, - DriverProfileViewModel -} from '@/lib/view-models/DriverProfileViewModel'; -import { getMediaUrl } from '@/lib/utilities/media'; +export default async function ProfilePage() { + const result = await ProfilePageQuery.execute(); -// New architecture components -import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; - -// Icons -import { - Activity, - Award, - BarChart3, - Calendar, - ChevronRight, - Clock, - Crown, - Edit3, - ExternalLink, - Flag, - Globe, - History, - Medal, - MessageCircle, - Percent, - Settings, - Shield, - Star, - Target, - TrendingUp, - Trophy, - Twitch, - Twitter, - User, - UserPlus, - Users, - Youtube, - Zap, -} from 'lucide-react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; - -// ============================================================================ -// TYPES -// ============================================================================ - -type ProfileTab = 'overview' | 'history' | 'stats'; - -// ============================================================================ -// HELPER COMPONENTS -// ============================================================================ - -function getCountryFlag(countryCode: string): string { - const code = countryCode.toUpperCase(); - if (code.length === 2) { - const codePoints = [...code].map(char => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); - } - return '🏁'; -} - -function getRarityColor(rarity: DriverProfileAchievementViewModel['rarity']) { - switch (rarity) { - case 'common': - return 'text-gray-400 bg-gray-400/10 border-gray-400/30'; - case 'rare': - return 'text-primary-blue bg-primary-blue/10 border-primary-blue/30'; - case 'epic': - return 'text-purple-400 bg-purple-400/10 border-purple-400/30'; - case 'legendary': - return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30'; - } -} - -function getAchievementIcon(icon: DriverProfileAchievementViewModel['icon']) { - switch (icon) { - case 'trophy': - return Trophy; - case 'medal': - return Medal; - case 'star': - return Star; - case 'crown': - return Crown; - case 'target': - return Target; - case 'zap': - return Zap; - } -} - -function getSocialIcon(platform: DriverProfileSocialHandleViewModel['platform']) { - switch (platform) { - case 'twitter': - return Twitter; - case 'youtube': - return Youtube; - case 'twitch': - return Twitch; - case 'discord': - return MessageCircle; - } -} - -function getSocialColor(platform: DriverProfileSocialHandleViewModel['platform']) { - switch (platform) { - case 'twitter': - return 'hover:text-sky-400 hover:bg-sky-400/10'; - case 'youtube': - return 'hover:text-red-500 hover:bg-red-500/10'; - case 'twitch': - return 'hover:text-purple-400 hover:bg-purple-400/10'; - case 'discord': - return 'hover:text-indigo-400 hover:bg-indigo-400/10'; - } -} - -// ============================================================================ -// STAT DIAGRAM COMPONENTS -// ============================================================================ - -interface CircularProgressProps { - value: number; - max: number; - label: string; - color: string; - size?: number; -} - -function CircularProgress({ value, max, label, color, size = 80 }: CircularProgressProps) { - const percentage = Math.min((value / max) * 100, 100); - const strokeWidth = 6; - const radius = (size - strokeWidth) / 2; - const circumference = radius * 2 * Math.PI; - const strokeDashoffset = circumference - (percentage / 100) * circumference; - - return ( -
-
- - - - -
- {percentage.toFixed(0)}% -
-
- {label} -
- ); -} - -interface BarChartProps { - data: { label: string; value: number; color: string }[]; - maxValue: number; -} - -function HorizontalBarChart({ data, maxValue }: BarChartProps) { - return ( -
- {data.map((item) => ( -
-
- {item.label} - {item.value} -
-
-
-
-
- ))} -
- ); -} - -interface FinishDistributionProps { - wins: number; - podiums: number; - topTen: number; - total: number; -} - -function FinishDistributionChart({ wins, podiums, topTen, total }: FinishDistributionProps) { - const outsideTopTen = total - topTen; - const podiumsNotWins = podiums - wins; - const topTenNotPodium = topTen - podiums; - - const segments = [ - { label: 'Wins', value: wins, color: 'bg-performance-green', textColor: 'text-performance-green' }, - { label: 'Podiums', value: podiumsNotWins, color: 'bg-warning-amber', textColor: 'text-warning-amber' }, - { label: 'Top 10', value: topTenNotPodium, color: 'bg-primary-blue', textColor: 'text-primary-blue' }, - { label: 'Other', value: outsideTopTen, color: 'bg-gray-600', textColor: 'text-gray-400' }, - ].filter(s => s.value > 0); - - return ( -
-
- {segments.map((segment, index) => ( -
- ))} -
-
- {segments.map((segment) => ( -
-
- - {segment.label}: {segment.value} ({((segment.value / total) * 100).toFixed(0)}%) - -
- ))} -
-
- ); -} - -// ============================================================================ -// TEMPLATE COMPONENT -// ============================================================================ - -interface ProfileTemplateProps { - data: DriverProfileViewModel; - onEdit: () => void; - onAddFriend: () => void; - activeTab: ProfileTab; - setActiveTab: (tab: ProfileTab) => void; - friendRequestSent: boolean; - isOwnProfile: boolean; - handleSaveSettings: (updates: { bio?: string; country?: string }) => Promise; - editMode: boolean; - setEditMode: (edit: boolean) => void; -} - -function ProfileTemplate({ - data, - onEdit, - onAddFriend, - activeTab, - setActiveTab, - friendRequestSent, - isOwnProfile, - handleSaveSettings, - editMode, - setEditMode -}: ProfileTemplateProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - - // Extract data from ViewModel - const currentDriver = data.currentDriver; - if (!currentDriver) { - return ( -
- - -

No driver profile found

-

Please create a driver profile to continue

-
-
- ); + if (result.isErr()) { + notFound(); } - const stats = data.stats; - const teamMemberships = data.teamMemberships; - const socialSummary = data.socialSummary; - const extendedProfile = data.extendedProfile; - const globalRank = currentDriver.globalRank || null; + const viewData = result.unwrap(); + const mode = viewData.driver.id ? 'profile-exists' : 'needs-profile'; - // Update URL when tab changes - useEffect(() => { - if (searchParams.get('tab') !== activeTab) { - const params = new URLSearchParams(searchParams.toString()); - if (activeTab === 'overview') { - params.delete('tab'); - } else { - params.set('tab', activeTab); - } - const query = params.toString(); - router.replace(`/profile${query ? `?${query}` : ''}`, { scroll: false }); - } - }, [activeTab, searchParams, router]); - - // Sync tab from URL on mount and param change - useEffect(() => { - const tabParam = searchParams.get('tab') as ProfileTab | null; - if (tabParam && tabParam !== activeTab) { - setActiveTab(tabParam); - } - }, [searchParams]); - - // Show edit mode - if (editMode && currentDriver) { - return ( -
-
- Edit Profile - -
- -
- ); - } - - return ( -
- {/* Hero Header Section */} -
- {/* Background Pattern */} -
-
-
- -
-
- {/* Avatar */} -
-
-
- {currentDriver.name} -
-
- {/* Online status indicator */} -
-
- - {/* Driver Info */} -
-
-

{currentDriver.name}

- - {getCountryFlag(currentDriver.country)} - - {teamMemberships.length > 0 && teamMemberships[0] && ( - - [{teamMemberships[0].teamTag || 'TEAM'}] - - )} -
- - {/* Rating and Rank */} -
- {stats && ( - <> -
- - - {stats.rating ?? 0} - - Rating -
-
- - #{globalRank} - Global -
- - )} - {teamMemberships.length > 0 && teamMemberships[0] && ( - - - {teamMemberships[0].teamName} - - - )} -
- - {/* Meta info */} -
- - - iRacing: {currentDriver.iracingId} - - - - Joined {new Date(currentDriver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - - {extendedProfile && ( - - - {extendedProfile.timezone} - - )} -
-
- - {/* Action Buttons */} -
- {isOwnProfile ? ( - - ) : ( - <> - - - )} - - - -
-
- - {/* Social Handles */} - {extendedProfile && extendedProfile.socialHandles.length > 0 && ( -
-
- Connect: - {extendedProfile.socialHandles.map((social) => { - const Icon = getSocialIcon(social.platform); - return ( - - - {social.handle} - - - ); - })} -
-
- )} -
-
- - {/* Bio Section */} - {currentDriver.bio && ( - -

- - About -

-

{currentDriver.bio}

-
- )} - - {/* Team Memberships */} - {teamMemberships.length > 0 && ( - -

- - Team Memberships - ({teamMemberships.length}) -

-
- {teamMemberships.map((membership) => ( - -
- -
-
-

- {membership.teamName} -

-
- - {membership.role} - - - Since {new Date(membership.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - -
-
- - - ))} -
-
- )} - - {/* Key Stats Overview with Diagrams */} - {stats && ( - -

- - Performance Overview -

-
- {/* Circular Progress Charts */} -
-
- - -
-
- - -
-
- - {/* Finish Distribution */} -
-

- - Finish Distribution -

- - -
-
-
- - Best Finish -
-

P{stats.bestFinish}

-
-
-
- - Avg Finish -
-

- P{(stats.avgFinish ?? 0).toFixed(1)} -

-
-
-
-
-
- )} - - {/* Tab Navigation */} -
- - - -
- - {/* Tab Content */} - {activeTab === 'overview' && ( - <> - {/* Racing Profile & Quick Stats */} -
- {/* Career Stats Summary */} - -

- - Career Statistics -

- {stats ? ( -
-
-
{stats.totalRaces}
-
Races
-
-
-
{stats.wins}
-
Wins
-
-
-
{stats.podiums}
-
Podiums
-
-
-
- {stats.consistency ?? 0}% -
-
Consistency
-
-
- ) : ( -

- No race statistics available yet. Join a league and compete to start building your record! -

- )} -
- - {/* Racing Preferences */} - - {/* Background accent */} -
- -

-
- -
- Racing Profile -

- -
- {extendedProfile && ( - <> - {/* Racing Style - Featured */} -
-
- -
- Racing Style -

{extendedProfile.racingStyle}

-
-
-
- - {/* Track & Car Grid */} -
-
-
- - Track -
-

{extendedProfile.favoriteTrack}

-
-
-
- - Car -
-

{extendedProfile.favoriteCar}

-
-
- - {/* Availability */} -
- -
- Available -

{extendedProfile.availableHours}

-
-
- - {/* Status badges */} -
- {extendedProfile.lookingForTeam && ( -
-
- -
-
- Looking for Team - Open to recruitment offers -
-
- )} - {extendedProfile.openToRequests && ( -
-
- -
-
- Open to Requests - Accepting friend invites -
-
- )} -
- - )} -
- -
- - {/* Achievements */} - {extendedProfile && extendedProfile.achievements.length > 0 && ( - -

- - Achievements - {extendedProfile.achievements.length} earned -

-
- {extendedProfile.achievements.map((achievement) => { - const Icon = getAchievementIcon(achievement.icon); - const rarityClasses = getRarityColor(achievement.rarity); - return ( -
-
-
- -
-
-

{achievement.title}

-

{achievement.description}

-

- {new Date(achievement.earnedAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} -

-
-
-
- ); - })} -
-
- )} - - {/* Friends Preview */} - {socialSummary && socialSummary.friends.length > 0 && ( - -
-

- - Friends - ({socialSummary.friendsCount}) -

-
-
- {socialSummary.friends.slice(0, 8).map((friend) => ( - -
- {friend.name} -
- {friend.name} - {getCountryFlag(friend.country)} - - ))} - {socialSummary.friendsCount > 8 && ( -
- +{socialSummary.friendsCount - 8} more -
- )} -
-
- )} - - )} - - {activeTab === 'history' && currentDriver && ( - -

- - Race History -

- -
- )} - - {activeTab === 'stats' && stats && ( -
- {/* Detailed Performance Metrics */} - -

- - Detailed Performance Metrics -

- -
- {/* Performance Bars */} -
-

Results Breakdown

- -
- - {/* Key Metrics */} -
-
-
- - Win Rate -
-

- {((stats.wins / stats.totalRaces) * 100).toFixed(1)}% -

-
-
-
- - Podium Rate -
-

- {((stats.podiums / stats.totalRaces) * 100).toFixed(1)}% -

-
-
-
- - Consistency -
-

- {stats.consistency ?? 0}% -

-
-
-
- - Finish Rate -
-

- {(((stats.totalRaces - stats.dnfs) / stats.totalRaces) * 100).toFixed(1)}% -

-
-
-
-
- - {/* Position Statistics */} - -

- - Position Statistics -

- -
-
-
P{stats.bestFinish}
-
Best Finish
-
-
-
- P{(stats.avgFinish ?? 0).toFixed(1)} -
-
Avg Finish
-
-
-
P{stats.worstFinish}
-
Worst Finish
-
-
-
{stats.dnfs}
-
DNFs
-
-
-
- - {/* Global Rankings */} - -

- - Global Rankings -

- -
-
- -
#{globalRank}
-
Global Rank
-
-
- -
- {stats.rating ?? 0} -
-
Rating
-
-
- -
Top {stats.percentile}%
-
Percentile
-
-
-
-
- )} - - {activeTab === 'stats' && !stats && ( - - -

No statistics available yet

-

Join a league and complete races to see detailed stats

-
- )} -
- ); + return ; } - -// ============================================================================ -// MAIN PAGE COMPONENT -// ============================================================================ - -export default function ProfilePage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const tabParam = searchParams.get('tab') as ProfileTab | null; - - const driverService = useInject(DRIVER_SERVICE_TOKEN); - const mediaService = useInject(MEDIA_SERVICE_TOKEN); - - const effectiveDriverId = useEffectiveDriverId(); - const isOwnProfile = true; // This page is always your own profile - - // Use React-Query hook for profile data - const { data: profileData, isLoading: loading, error, refetch } = useDriverProfile(effectiveDriverId || ''); - - const [editMode, setEditMode] = useState(false); - const [activeTab, setActiveTab] = useState(tabParam || 'overview'); - const [friendRequestSent, setFriendRequestSent] = useState(false); - - const handleSaveSettings = async (updates: { bio?: string; country?: string }) => { - if (!profileData?.currentDriver) return; - - try { - const updatedProfile = await driverService.updateProfile(updates); - // Update local state - refetch(); - setEditMode(false); - } catch (error) { - console.error('Failed to update profile:', error); - } - }; - - const handleAddFriend = () => { - setFriendRequestSent(true); - // In production, this would call a use case - }; - - // Show create form if no profile exists - if (!loading && !profileData?.currentDriver && !error) { - return ( -
-
-
- -
- Create Your Driver Profile -

- Join the GridPilot community and start your racing journey -

-
- - -
-

Get Started

-

- Create your driver profile to join leagues, compete in races, and connect with other drivers. -

-
- -
-
- ); - } - - return ( - ( - setEditMode(true)} - onAddFriend={handleAddFriend} - activeTab={activeTab} - setActiveTab={setActiveTab} - friendRequestSent={friendRequestSent} - isOwnProfile={isOwnProfile} - handleSaveSettings={handleSaveSettings} - editMode={editMode} - setEditMode={setEditMode} - /> - )} - loading={{ variant: 'full-screen', message: 'Loading profile...' }} - errorConfig={{ variant: 'full-screen' }} - empty={{ - icon: User, - title: 'No profile data', - description: 'Unable to load your profile information', - action: { label: 'Retry', onClick: refetch } - }} - /> - ); -} \ No newline at end of file diff --git a/apps/website/app/profile/settings/page.tsx b/apps/website/app/profile/settings/page.tsx index 6160f75bd..9e673acfc 100644 --- a/apps/website/app/profile/settings/page.tsx +++ b/apps/website/app/profile/settings/page.tsx @@ -1,226 +1,20 @@ -'use client'; +import Link from 'next/link'; +import Button from '@/components/ui/Button'; +import Card from '@/components/ui/Card'; +import Container from '@/components/ui/Container'; +import Heading from '@/components/ui/Heading'; +import { routes } from '@/lib/routing/RouteConfig'; -import { Bell, Shield, Eye, Volume2 } from 'lucide-react'; -import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; - -interface SettingsData { - // Settings page is static, no data needed -} - -function SettingsTemplate({ data }: { data: SettingsData }) { +export default async function ProfileSettingsPage() { return ( -
-
-

Settings

- -
- {/* Notification Settings */} -
-
- -

Notifications

-
- -
-
-
-

Protest Filed Against You

-

Get notified when someone files a protest involving you

-
- -
- -
-
-

Vote Requested

-

Get notified when your vote is needed on a protest

-
- -
- -
-
-

Defense Required

-

Get notified when you need to submit a defense

-
- -
- -
-
-

Penalty Issued

-

Get notified when you receive a penalty

-
- -
- -
-
-

Race Starting Soon

-

Reminder before scheduled races begin

-
- -
- -
-
-

League Announcements

-

Updates from league administrators

-
- -
-
-
- - {/* Display Settings */} -
-
- -

Display

-
- -
-
-
-

Toast Duration

-

How long toast notifications stay visible

-
- -
- -
-
-

Toast Position

-

Where toast notifications appear on screen

-
- -
-
-
- - {/* Sound Settings */} -
-
- -

Sound

-
- -
-
-
-

Notification Sounds

-

Play sounds for new notifications

-
- -
- -
-
-

Urgent Notification Sound

-

Special sound for modal notifications

-
- -
-
-
- - {/* Privacy Settings */} -
-
- -

Privacy

-
- -
-
-
-

Show Online Status

-

Let others see when you're online

-
- -
- -
-
-

Public Profile

-

Allow non-league members to view your profile

-
- -
-
-
- - {/* Save Button */} -
- -
-
-
-
+ + Settings + +

Settings are currently unavailable.

+ + + +
+
); } - -export default function SettingsPage() { - return ( - - ); -} \ No newline at end of file diff --git a/apps/website/app/profile/sponsorship-requests/SponsorshipRequestsPageClient.tsx b/apps/website/app/profile/sponsorship-requests/SponsorshipRequestsPageClient.tsx new file mode 100644 index 000000000..2ae34dba9 --- /dev/null +++ b/apps/website/app/profile/sponsorship-requests/SponsorshipRequestsPageClient.tsx @@ -0,0 +1,25 @@ +'use client'; + +import type { Result } from '@/lib/contracts/Result'; +import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData'; +import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate'; + +interface SponsorshipRequestsPageClientProps { + viewData: SponsorshipRequestsViewData; + onAccept: (requestId: string) => Promise>; + onReject: (requestId: string, reason?: string) => Promise>; +} + +export function SponsorshipRequestsPageClient({ viewData, onAccept, onReject }: SponsorshipRequestsPageClientProps) { + return ( + { + await onAccept(requestId); + }} + onReject={async (requestId, reason) => { + await onReject(requestId, reason); + }} + /> + ); +} diff --git a/apps/website/app/profile/sponsorship-requests/actions.ts b/apps/website/app/profile/sponsorship-requests/actions.ts new file mode 100644 index 000000000..cf34b0260 --- /dev/null +++ b/apps/website/app/profile/sponsorship-requests/actions.ts @@ -0,0 +1,26 @@ +import { AcceptSponsorshipRequestMutation } from '@/lib/mutations/sponsors/AcceptSponsorshipRequestMutation'; +import { RejectSponsorshipRequestMutation } from '@/lib/mutations/sponsors/RejectSponsorshipRequestMutation'; +import type { AcceptSponsorshipRequestCommand } from '@/lib/services/sponsors/SponsorshipRequestsService'; +import type { RejectSponsorshipRequestCommand } from '@/lib/services/sponsors/SponsorshipRequestsService'; + +export async function acceptSponsorshipRequest( + command: AcceptSponsorshipRequestCommand, +): Promise { + const mutation = new AcceptSponsorshipRequestMutation(); + const result = await mutation.execute(command); + + if (result.isErr()) { + throw new Error('Failed to accept sponsorship request'); + } +} + +export async function rejectSponsorshipRequest( + command: RejectSponsorshipRequestCommand, +): Promise { + const mutation = new RejectSponsorshipRequestMutation(); + const result = await mutation.execute(command); + + if (result.isErr()) { + throw new Error('Failed to reject sponsorship request'); + } +} diff --git a/apps/website/app/profile/sponsorship-requests/page.tsx b/apps/website/app/profile/sponsorship-requests/page.tsx index 52725f319..d8d3939a7 100644 --- a/apps/website/app/profile/sponsorship-requests/page.tsx +++ b/apps/website/app/profile/sponsorship-requests/page.tsx @@ -1,48 +1,9 @@ -'use client'; - -import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate'; -import { - useSponsorshipRequestsPageData, - useSponsorshipRequestMutations -} from "@/lib/hooks/sponsor/useSponsorshipRequestsPageData"; -import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; -export default function SponsorshipRequestsPage() { - const currentDriverId = useEffectiveDriverId(); - - // Fetch data using domain hook - const { data: sections, isLoading, error, refetch } = useSponsorshipRequestsPageData(currentDriverId); - - // Mutations using domain hook - const { acceptMutation, rejectMutation } = useSponsorshipRequestMutations(currentDriverId, refetch); - - // Template needs to handle mutations - const TemplateWithMutations = ({ data }: { data: any[] }) => ( - { - await acceptMutation.mutateAsync({ requestId }); - }} - onReject={async (requestId, reason) => { - await rejectMutation.mutateAsync({ requestId, reason }); - }} - /> - ); - - return ( - - ); -} \ No newline at end of file +export default async function SponsorshipRequestsPage({ + searchParams, +}: { + searchParams: Record; +}) { + return ; +} diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index ed0ac065a..2633b18d0 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -1,10 +1,10 @@ import { notFound } from 'next/navigation'; import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens'; -import type { RaceService } from '@/lib/services/races/RaceService'; -import type { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel'; +import { RacesApiClient } from '@/lib/api/races/RacesApiClient'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; interface RaceDetailPageProps { params: { @@ -19,13 +19,20 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) { notFound(); } - // Fetch initial race data - const data = await PageDataFetcher.fetch( - RACE_SERVICE_TOKEN, - 'getRaceDetail', - raceId, - '' // currentDriverId - will be handled client-side for auth - ); + // Manual wiring: create dependencies + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + // Create API client + const apiClient = new RacesApiClient(baseUrl, errorReporter, logger); + + // Fetch initial race data (empty driverId for now, handled client-side) + const data = await apiClient.getDetail(raceId, ''); if (!data) notFound(); @@ -66,7 +73,7 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) { isPodium: data.userResult.isPodium, ratingChange: data.userResult.ratingChange, } : undefined, - canReopenRace: data.canReopenRace, + canReopenRace: false, // Not provided by API, default to false } : undefined; return ( diff --git a/apps/website/app/races/[id]/stewarding/page.tsx b/apps/website/app/races/[id]/stewarding/page.tsx index ea219795b..e1374f195 100644 --- a/apps/website/app/races/[id]/stewarding/page.tsx +++ b/apps/website/app/races/[id]/stewarding/page.tsx @@ -4,15 +4,30 @@ import { useState, useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { RACE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens'; -import { RaceStewardingService } from '@/lib/services/races/RaceStewardingService'; -import type { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel'; +import { RacesApiClient } from '@/lib/api/races/RacesApiClient'; +import { ProtestsApiClient } from '@/lib/api/protests/ProtestsApiClient'; +import { PenaltiesApiClient } from '@/lib/api/penalties/PenaltiesApiClient'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { useLeagueMemberships } from "@/lib/hooks/league/useLeagueMemberships"; import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { Gavel } from 'lucide-react'; -import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO'; + +// Define the view model structure locally to avoid type issues +interface RaceStewardingViewModel { + race: any; + league: any; + protests: any[]; + penalties: any[]; + driverMap: Record; + pendingProtests: any[]; + resolvedProtests: any[]; + pendingCount: number; + resolvedCount: number; + penaltiesCount: number; +} export default function RaceStewardingPage() { const router = useRouter(); @@ -37,12 +52,61 @@ export default function RaceStewardingPage() { setIsLoading(true); setError(null); - const data = await PageDataFetcher.fetch( - RACE_STEWARDING_SERVICE_TOKEN, - 'getRaceStewardingData', - raceId, - currentDriverId + // Manual wiring: create dependencies + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + // Create API clients + const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger); + const protestsApiClient = new ProtestsApiClient(baseUrl, errorReporter, logger); + const penaltiesApiClient = new PenaltiesApiClient(baseUrl, errorReporter, logger); + + // Fetch data in parallel + const [raceDetail, protests, penalties] = await Promise.all([ + racesApiClient.getDetail(raceId, currentDriverId), + protestsApiClient.getRaceProtests(raceId), + penaltiesApiClient.getRacePenalties(raceId), + ]); + + // Transform data to match view model structure + const data: RaceStewardingViewModel = { + race: raceDetail.race, + league: raceDetail.league, + protests: protests.protests.map(p => ({ + id: p.id, + protestingDriverId: p.protestingDriverId, + accusedDriverId: p.accusedDriverId, + incident: { + lap: p.lap, + description: p.description, + }, + filedAt: p.filedAt, + status: p.status, + })), + penalties: penalties.penalties, + driverMap: { ...protests.driverMap, ...penalties.driverMap }, + pendingProtests: [], + resolvedProtests: [], + pendingCount: 0, + resolvedCount: 0, + penaltiesCount: 0, + }; + + // Calculate derived properties + data.pendingProtests = data.protests.filter(p => p.status === 'pending' || p.status === 'under_review'); + data.resolvedProtests = data.protests.filter(p => + p.status === 'upheld' || + p.status === 'dismissed' || + p.status === 'withdrawn' ); + data.pendingCount = data.pendingProtests.length; + data.resolvedCount = data.resolvedProtests.length; + data.penaltiesCount = data.penalties.length; if (data) { setPageData(data); @@ -93,12 +157,61 @@ export default function RaceStewardingPage() { setIsLoading(true); setError(null); - const data = await PageDataFetcher.fetch( - RACE_STEWARDING_SERVICE_TOKEN, - 'getRaceStewardingData', - raceId, - currentDriverId + // Manual wiring: create dependencies + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + // Create API clients + const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger); + const protestsApiClient = new ProtestsApiClient(baseUrl, errorReporter, logger); + const penaltiesApiClient = new PenaltiesApiClient(baseUrl, errorReporter, logger); + + // Fetch data in parallel + const [raceDetail, protests, penalties] = await Promise.all([ + racesApiClient.getDetail(raceId, currentDriverId), + protestsApiClient.getRaceProtests(raceId), + penaltiesApiClient.getRacePenalties(raceId), + ]); + + // Transform data to match view model structure + const data: RaceStewardingViewModel = { + race: raceDetail.race, + league: raceDetail.league, + protests: protests.protests.map(p => ({ + id: p.id, + protestingDriverId: p.protestingDriverId, + accusedDriverId: p.accusedDriverId, + incident: { + lap: p.lap, + description: p.description, + }, + filedAt: p.filedAt, + status: p.status, + })), + penalties: penalties.penalties, + driverMap: { ...protests.driverMap, ...penalties.driverMap }, + pendingProtests: [], + resolvedProtests: [], + pendingCount: 0, + resolvedCount: 0, + penaltiesCount: 0, + }; + + // Calculate derived properties + data.pendingProtests = data.protests.filter(p => p.status === 'pending' || p.status === 'under_review'); + data.resolvedProtests = data.protests.filter(p => + p.status === 'upheld' || + p.status === 'dismissed' || + p.status === 'withdrawn' ); + data.pendingCount = data.pendingProtests.length; + data.resolvedCount = data.resolvedProtests.length; + data.penaltiesCount = data.penalties.length; if (data) { setPageData(data); diff --git a/apps/website/app/races/page.tsx b/apps/website/app/races/page.tsx index 6aae1d8bb..5b71e9a01 100644 --- a/apps/website/app/races/page.tsx +++ b/apps/website/app/races/page.tsx @@ -1,12 +1,11 @@ import { RacesTemplate } from '@/templates/RacesTemplate'; -import { RaceService } from '@/lib/services/races/RaceService'; import { RacesApiClient } from '@/lib/api/races/RacesApiClient'; import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; export default async function Page() { - // Create dependencies for API clients + // Manual wiring: create dependencies const baseUrl = getWebsiteApiBaseUrl(); const logger = new ConsoleLogger(); const errorReporter = new EnhancedErrorReporter(logger, { @@ -18,12 +17,10 @@ export default async function Page() { // Create API client const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger); - // Create service - const service = new RaceService(racesApiClient); + // Fetch data + const data = await racesApiClient.getPageData(); - const data = await service.getRacesPageData(); - - // Transform data for template + // Transform races const transformRace = (race: any) => ({ id: race.id, track: race.track, @@ -34,16 +31,16 @@ export default async function Page() { leagueId: race.leagueId, leagueName: race.leagueName, strengthOfField: race.strengthOfField ?? undefined, - isUpcoming: race.isUpcoming, - isLive: race.isLive, - isPast: race.isPast, + isUpcoming: race.status === 'scheduled', + isLive: race.status === 'running', + isPast: race.status === 'completed', }); const races = data.races.map(transformRace); - const scheduledRaces = data.scheduledRaces.map(transformRace); - const runningRaces = data.runningRaces.map(transformRace); - const completedRaces = data.completedRaces.map(transformRace); - const totalCount = data.totalCount; + const scheduledRaces = races.filter(r => r.isUpcoming); + const runningRaces = races.filter(r => r.isLive); + const completedRaces = races.filter(r => r.isPast); + const totalCount = races.length; return {children}
); -} \ No newline at end of file +} diff --git a/apps/website/app/sponsor/leagues/[id]/page.tsx b/apps/website/app/sponsor/leagues/[id]/page.tsx index 41a771d7d..12a9f5067 100644 --- a/apps/website/app/sponsor/leagues/[id]/page.tsx +++ b/apps/website/app/sponsor/leagues/[id]/page.tsx @@ -1,16 +1,26 @@ import { notFound } from 'next/navigation'; import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { SponsorLeagueDetailTemplate } from '@/templates/SponsorLeagueDetailTemplate'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens'; -import type { SponsorService } from '@/lib/services/sponsors/SponsorService'; +import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; export default async function Page({ params }: { params: { id: string } }) { - const data = await PageDataFetcher.fetch( - SPONSOR_SERVICE_TOKEN, - 'getLeagueDetail', - params.id - ); + // Manual wiring: create dependencies + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + // Create API client + const apiClient = new SponsorsApiClient(baseUrl, errorReporter, logger); + + // Fetch data + const data = await apiClient.getLeagueDetail(params.id); if (!data) notFound(); diff --git a/apps/website/app/sponsor/leagues/page.tsx b/apps/website/app/sponsor/leagues/page.tsx index 60322365a..4607ad054 100644 --- a/apps/website/app/sponsor/leagues/page.tsx +++ b/apps/website/app/sponsor/leagues/page.tsx @@ -1,15 +1,26 @@ import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { SponsorLeaguesTemplate } from '@/templates/SponsorLeaguesTemplate'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens'; -import { SponsorService } from '@/lib/services/sponsors/SponsorService'; +import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { AvailableLeaguesViewModel } from '@/lib/view-models/AvailableLeaguesViewModel'; export default async function Page() { - const leaguesData = await PageDataFetcher.fetch( - SPONSOR_SERVICE_TOKEN, - 'getAvailableLeagues' - ); + // Manual wiring: create dependencies + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + // Create API client + const apiClient = new SponsorsApiClient(baseUrl, errorReporter, logger); + + // Fetch data + const leaguesData = await apiClient.getAvailableLeagues(); // Process data with view model to calculate stats if (!leaguesData) { diff --git a/apps/website/app/teams/TeamsPageClient.tsx b/apps/website/app/teams/TeamsPageClient.tsx index 94172bb35..fd5a2d73b 100644 --- a/apps/website/app/teams/TeamsPageClient.tsx +++ b/apps/website/app/teams/TeamsPageClient.tsx @@ -1,11 +1,11 @@ 'use client'; -import { useState, useMemo } from 'react'; -import { useRouter } from 'next/navigation'; import type { TeamsPageDto } from '@/lib/page-queries/page-queries/TeamsPageQuery'; import { TeamsPresenter } from '@/lib/presenters/TeamsPresenter'; +import type { TeamSummaryData } from '@/lib/view-data/TeamsViewData'; import { TeamsTemplate } from '@/templates/TeamsTemplate'; -import type { TeamSummaryData } from '@/templates/view-data/TeamsViewData'; +import { useRouter } from 'next/navigation'; +import { useMemo, useState } from 'react'; interface TeamsPageClientProps { pageDto: TeamsPageDto; diff --git a/apps/website/app/teams/[id]/TeamDetailPageClient.tsx b/apps/website/app/teams/[id]/TeamDetailPageClient.tsx index daf6ecd1c..4af726582 100644 --- a/apps/website/app/teams/[id]/TeamDetailPageClient.tsx +++ b/apps/website/app/teams/[id]/TeamDetailPageClient.tsx @@ -3,8 +3,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import type { TeamDetailPageDto } from '@/lib/page-queries/page-queries/TeamDetailPageQuery'; -import { TeamDetailPresenter } from '@/lib/presenters/TeamDetailPresenter'; -import TeamDetailTemplate from '@/templates/TeamDetailTemplate'; +import { TeamDetailPresenter } from '@/lib/view-models/TeamDetailPresenter'; +import { TeamDetailTemplate } from '@/templates/TeamDetailTemplate'; type Tab = 'overview' | 'roster' | 'standings' | 'admin'; @@ -53,11 +53,9 @@ export function TeamDetailPageClient({ pageDto }: TeamDetailPageClientProps) { return ( ( - TEAM_SERVICE_TOKEN, - 'getAllTeams' - ); + // Manual wiring: create dependencies + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + // Create API client + const apiClient = new TeamsApiClient(baseUrl, errorReporter, logger); + + // Fetch data + const result = await apiClient.getAll(); + + // Transform DTO to ViewModel + const teamsData: TeamSummaryViewModel[] = result.teams.map(team => new TeamSummaryViewModel(team)); // Prepare data for template const data: TeamSummaryViewModel[] | null = teamsData; diff --git a/apps/website/app/teams/page.tsx b/apps/website/app/teams/page.tsx index d0a9f4a61..d4838435a 100644 --- a/apps/website/app/teams/page.tsx +++ b/apps/website/app/teams/page.tsx @@ -1,6 +1,6 @@ import { notFound } from 'next/navigation'; -import { TeamsPageQuery } from '@/lib/page-queries/TeamsPageQuery'; -import TeamsPageClient from './TeamsPageClient'; +import { TeamsPageQuery } from '@/lib/page-queries/page-queries/TeamsPageQuery'; +import { TeamsPageClient } from './TeamsPageClient'; export default async function Page() { const result = await TeamsPageQuery.execute(); diff --git a/apps/website/components/DriverRankingsFilter.tsx b/apps/website/components/DriverRankingsFilter.tsx deleted file mode 100644 index 69e02b62e..000000000 --- a/apps/website/components/DriverRankingsFilter.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React from 'react'; -import { Search, Filter, Hash, Star, Trophy, Medal, Percent } from 'lucide-react'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; - -type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; -type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate'; - -const SKILL_LEVELS: { - id: SkillLevel; - label: string; - color: string; - bgColor: string; - borderColor: string; -}[] = [ - { id: 'pro', label: 'Pro', color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' }, - { id: 'advanced', label: 'Advanced', color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' }, - { id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' }, - { id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' }, -]; - -const SORT_OPTIONS: { id: SortBy; label: string; icon: React.ElementType }[] = [ - { id: 'rank', label: 'Rank', icon: Hash }, - { id: 'rating', label: 'Rating', icon: Star }, - { id: 'wins', label: 'Wins', icon: Trophy }, - { id: 'podiums', label: 'Podiums', icon: Medal }, - { id: 'winRate', label: 'Win Rate', icon: Percent }, -]; - -interface DriverRankingsFilterProps { - searchQuery: string; - onSearchChange: (query: string) => void; - selectedSkill: 'all' | SkillLevel; - onSkillChange: (skill: 'all' | SkillLevel) => void; - sortBy: SortBy; - onSortChange: (sort: SortBy) => void; - showFilters: boolean; - onToggleFilters: () => void; -} - -export default function DriverRankingsFilter({ - searchQuery, - onSearchChange, - selectedSkill, - onSkillChange, - sortBy, - onSortChange, - showFilters, - onToggleFilters, -}: DriverRankingsFilterProps) { - return ( -
-
-
- - onSearchChange(e.target.value)} - className="pl-11" - /> -
- -
- -
- - {SKILL_LEVELS.map((level) => { - return ( - - ); - })} -
- -
- Sort by: -
- {SORT_OPTIONS.map((option) => { - const OptionIcon = option.icon; - return ( - - ); - })} -
-
-
- ); -} \ No newline at end of file diff --git a/apps/website/components/DriverTopThreePodium.tsx b/apps/website/components/DriverTopThreePodium.tsx deleted file mode 100644 index 538d725e4..000000000 --- a/apps/website/components/DriverTopThreePodium.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React from 'react'; -import { Trophy, Medal, Crown } from 'lucide-react'; -import Image from 'next/image'; -import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; - -interface DriverTopThreePodiumProps { - drivers: DriverLeaderboardItemViewModel[]; - onDriverClick: (id: string) => void; -} - -export default function DriverTopThreePodium({ drivers, onDriverClick }: DriverTopThreePodiumProps) { - if (drivers.length < 3) return null; - - const top3 = drivers.slice(0, 3) as [DriverLeaderboardItemViewModel, DriverLeaderboardItemViewModel, DriverLeaderboardItemViewModel]; - - const podiumOrder: [DriverLeaderboardItemViewModel, DriverLeaderboardItemViewModel, DriverLeaderboardItemViewModel] = [ - top3[1], - top3[0], - top3[2], - ]; // 2nd, 1st, 3rd - const podiumHeights = ['h-32', 'h-40', 'h-24']; - const podiumColors = [ - 'from-gray-400/20 to-gray-500/10 border-gray-400/40', - 'from-yellow-400/20 to-amber-500/10 border-yellow-400/40', - 'from-amber-600/20 to-amber-700/10 border-amber-600/40', - ]; - const crownColors = ['text-gray-300', 'text-yellow-400', 'text-amber-600']; - const positions = [2, 1, 3]; - - return ( -
-
- {podiumOrder.map((driver, index) => { - const position = positions[index]; - - return ( - - ); - })} -
-
- ); -} \ No newline at end of file diff --git a/apps/website/components/admin/AdminDashboardPage.tsx b/apps/website/components/admin/AdminDashboardPage.tsx deleted file mode 100644 index e7d91a5b5..000000000 --- a/apps/website/components/admin/AdminDashboardPage.tsx +++ /dev/null @@ -1,225 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { apiClient } from '@/lib/apiClient'; -import Card from '@/components/ui/Card'; -import { AdminViewModelPresenter } from '@/lib/view-models/AdminViewModelPresenter'; -import { DashboardStatsViewModel } from '@/lib/view-models/AdminUserViewModel'; -import { - Users, - Shield, - Activity, - Clock, - AlertTriangle, - RefreshCw -} from 'lucide-react'; - -export function AdminDashboardPage() { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - loadStats(); - }, []); - - const loadStats = async () => { - try { - setLoading(true); - setError(null); - - const response = await apiClient.admin.getDashboardStats(); - - // Map DTO to View Model - const viewModel = AdminViewModelPresenter.mapDashboardStats(response); - setStats(viewModel); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to load stats'; - if (message.includes('403') || message.includes('401')) { - setError('Access denied - You must be logged in as an Owner or Admin'); - } else { - setError(message); - } - } finally { - setLoading(false); - } - }; - - if (loading) { - return ( -
-
-
Loading dashboard...
-
- ); - } - - if (error) { - return ( -
-
- -
-
Error
-
{error}
-
- -
-
- ); - } - - if (!stats) { - return null; - } - - // Temporary UI fields (not yet provided by API/ViewModel) - const adminCount = stats.systemAdmins; - const recentActivity: Array<{ description: string; timestamp: string; type: string }> = []; - const systemHealth = 'Healthy'; - const totalSessions = 0; - const activeSessions = 0; - const avgSessionDuration = '—'; - - return ( -
- {/* Header */} -
-
-

Admin Dashboard

-

System overview and statistics

-
- -
- - {/* Stats Cards */} -
- -
-
-
Total Users
-
{stats.totalUsers}
-
- -
-
- - -
-
-
Admins
-
{adminCount}
-
- -
-
- - -
-
-
Active Users
-
{stats.activeUsers}
-
- -
-
- - -
-
-
Recent Logins
-
{stats.recentLogins}
-
- -
-
-
- - {/* Activity Overview */} -
- {/* Recent Activity */} - -

Recent Activity

-
- {recentActivity.length > 0 ? ( - recentActivity.map((activity, index: number) => ( -
-
-
{activity.description}
-
{activity.timestamp}
-
- - {activity.type.replace('_', ' ')} - -
- )) - ) : ( -
No recent activity
- )} -
-
- - {/* System Status */} - -

System Status

-
-
- System Health - - {systemHealth} - -
-
- Total Sessions - {totalSessions} -
-
- Active Sessions - {activeSessions} -
-
- Avg Session Duration - {avgSessionDuration} -
-
-
-
- - {/* Quick Actions */} - -

Quick Actions

-
- - - -
-
-
- ); -} \ No newline at end of file diff --git a/apps/website/components/admin/AdminLayout.tsx b/apps/website/components/admin/AdminLayout.tsx deleted file mode 100644 index 0016c51fd..000000000 --- a/apps/website/components/admin/AdminLayout.tsx +++ /dev/null @@ -1,180 +0,0 @@ -'use client'; - -import { ReactNode, useState } from 'react'; -import { - LayoutDashboard, - Users, - Settings, - LogOut, - Shield, - Activity -} from 'lucide-react'; -import { useRouter, usePathname } from 'next/navigation'; -import { logoutAction } from '@/app/actions/logoutAction'; - -interface AdminLayoutProps { - children: ReactNode; -} - -type AdminTab = 'dashboard' | 'users'; - -export function AdminLayout({ children }: AdminLayoutProps) { - const router = useRouter(); - const pathname = usePathname(); - const [isSidebarOpen, setIsSidebarOpen] = useState(true); - - // Determine current tab from pathname - const getCurrentTab = (): AdminTab => { - if (pathname === '/admin') return 'dashboard'; - if (pathname === '/admin/users') return 'users'; - return 'dashboard'; - }; - - const currentTab = getCurrentTab(); - - const navigation = [ - { - id: 'dashboard', - label: 'Dashboard', - icon: LayoutDashboard, - href: '/admin', - description: 'Overview and statistics' - }, - { - id: 'users', - label: 'User Management', - icon: Users, - href: '/admin/users', - description: 'Manage all users' - }, - { - id: 'settings', - label: 'Settings', - icon: Settings, - href: '/admin/settings', - description: 'System configuration', - disabled: true - } - ]; - - const handleNavigation = (href: string, disabled?: boolean) => { - if (!disabled) { - router.push(href); - } - }; - - return ( -
- {/* Sidebar */} - - - {/* Main Content */} -
- {/* Top Bar */} -
-
-
-
- -
-
-

- {navigation.find(n => n.id === currentTab)?.label || 'Admin'} -

-

- {navigation.find(n => n.id === currentTab)?.description} -

-
-
- -
-
- - Super Admin -
- -
-
System Administrator
-
Full Access
-
-
-
-
- - {/* Content Area */} -
- {children} -
-
-
- ); -} \ No newline at end of file diff --git a/apps/website/components/admin/AdminUsersPage.tsx b/apps/website/components/admin/AdminUsersPage.tsx deleted file mode 100644 index ec3342002..000000000 --- a/apps/website/components/admin/AdminUsersPage.tsx +++ /dev/null @@ -1,359 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { apiClient } from '@/lib/apiClient'; -import Card from '@/components/ui/Card'; -import StatusBadge from '@/components/ui/StatusBadge'; -import { AdminViewModelPresenter } from '@/lib/view-models/AdminViewModelPresenter'; -import { AdminUserViewModel, UserListViewModel } from '@/lib/view-models/AdminUserViewModel'; -import { - Search, - Filter, - RefreshCw, - Users, - Shield, - Trash2, - AlertTriangle -} from 'lucide-react'; - -export function AdminUsersPage() { - const [userList, setUserList] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [search, setSearch] = useState(''); - const [roleFilter, setRoleFilter] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); - const [deletingUser, setDeletingUser] = useState(null); - - useEffect(() => { - const timeout = setTimeout(() => { - loadUsers(); - }, 300); - - return () => clearTimeout(timeout); - }, [search, roleFilter, statusFilter]); - - const loadUsers = async () => { - try { - setLoading(true); - setError(null); - - const response = await apiClient.admin.listUsers({ - search: search || undefined, - role: roleFilter || undefined, - status: statusFilter || undefined, - page: 1, - limit: 50, - }); - - // Map DTO to View Model - const viewModel = AdminViewModelPresenter.mapUserList(response); - setUserList(viewModel); - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to load users'; - if (message.includes('403') || message.includes('401')) { - setError('Access denied - You must be logged in as an Owner or Admin'); - } else { - setError(message); - } - } finally { - setLoading(false); - } - }; - - const handleUpdateStatus = async (userId: string, newStatus: string) => { - try { - await apiClient.admin.updateUserStatus(userId, newStatus); - await loadUsers(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update status'); - } - }; - - const toStatusBadgeProps = ( - status: string, - ): { status: 'success' | 'warning' | 'error' | 'neutral'; label: string } => { - switch (status) { - case 'active': - return { status: 'success', label: 'Active' }; - case 'suspended': - return { status: 'warning', label: 'Suspended' }; - case 'deleted': - return { status: 'error', label: 'Deleted' }; - default: - return { status: 'neutral', label: status }; - } - }; - - const handleDeleteUser = async (userId: string) => { - if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) { - return; - } - - try { - setDeletingUser(userId); - await apiClient.admin.deleteUser(userId); - await loadUsers(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete user'); - } finally { - setDeletingUser(null); - } - }; - - const clearFilters = () => { - setSearch(''); - setRoleFilter(''); - setStatusFilter(''); - }; - - return ( -
- {/* Header */} -
-
-

User Management

-

Manage and monitor all system users

-
- -
- - {/* Error Banner */} - {error && ( -
- -
-
Error
-
{error}
-
- -
- )} - - {/* Filters Card */} - -
-
-
- - Filters -
- {(search || roleFilter || statusFilter) && ( - - )} -
- -
-
- - setSearch(e.target.value)} - className="w-full pl-9 pr-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue transition-colors" - /> -
- - - - -
-
-
- - {/* Users Table */} - - {loading ? ( -
-
-
Loading users...
-
- ) : !userList || !userList.hasUsers ? ( -
- -
No users found
- -
- ) : ( -
- - - - - - - - - - - - - {userList.users.map((user: AdminUserViewModel, index: number) => ( - - - - - - - - - ))} - -
UserEmailRolesStatusLast LoginActions
-
-
- -
-
-
{user.displayName}
-
ID: {user.id}
- {user.primaryDriverId && ( -
Driver: {user.primaryDriverId}
- )} -
-
-
-
{user.email}
-
-
- {user.roleBadges.map((badge: string, idx: number) => ( - - {badge} - - ))} -
-
- {(() => { - const badge = toStatusBadgeProps(user.status); - return ; - })()} - -
- {user.lastLoginFormatted} -
-
-
- {user.canSuspend && ( - - )} - {user.canActivate && ( - - )} - {user.canDelete && ( - - )} -
-
-
- )} -
- - {/* Stats Summary */} - {userList && ( -
- -
-
-
Total Users
-
{userList.total}
-
- -
-
- -
-
-
Active
-
- {userList.users.filter(u => u.status === 'active').length} -
-
-
-
-
- -
-
-
Admins
-
- {userList.users.filter(u => u.isSystemAdmin).length} -
-
- -
-
-
- )} -
- ); -} \ No newline at end of file diff --git a/apps/website/components/dashboard/FeedItemRow.tsx b/apps/website/components/dashboard/FeedItemRow.tsx deleted file mode 100644 index 577f142e4..000000000 --- a/apps/website/components/dashboard/FeedItemRow.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Activity, Trophy, Medal, UserPlus, Heart, Flag, Play } from 'lucide-react'; -import Button from '@/components/ui/Button'; -import Link from 'next/link'; -import { timeAgo } from '@/lib/utilities/time'; - -interface FeedItemData { - id: string; - type: string; - headline: string; - body?: string; - timestamp: string; - formattedTime: string; - ctaHref?: string; - ctaLabel?: string; -} - -function FeedItemRow({ item }: { item: FeedItemData }) { - const getActivityIcon = (type: string) => { - if (type.includes('win')) return { icon: Trophy, color: 'text-yellow-400 bg-yellow-400/10' }; - if (type.includes('podium')) return { icon: Medal, color: 'text-warning-amber bg-warning-amber/10' }; - if (type.includes('join')) return { icon: UserPlus, color: 'text-performance-green bg-performance-green/10' }; - if (type.includes('friend')) return { icon: Heart, color: 'text-pink-400 bg-pink-400/10' }; - if (type.includes('league')) return { icon: Flag, color: 'text-primary-blue bg-primary-blue/10' }; - if (type.includes('race')) return { icon: Play, color: 'text-red-400 bg-red-400/10' }; - return { icon: Activity, color: 'text-gray-400 bg-gray-400/10' }; - }; - - const { icon: Icon, color } = getActivityIcon(item.type); - - return ( -
-
- -
-
-

{item.headline}

- {item.body && ( -

{item.body}

- )} -

{timeAgo(item.timestamp)}

-
- {item.ctaHref && ( - - - - )} -
- ); -} - -export { FeedItemRow }; \ No newline at end of file diff --git a/apps/website/components/dashboard/FriendItem.tsx b/apps/website/components/dashboard/FriendItem.tsx deleted file mode 100644 index 3b25667d6..000000000 --- a/apps/website/components/dashboard/FriendItem.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Link from 'next/link'; -import Image from 'next/image'; -import { getCountryFlag } from '@/lib/utilities/country'; - -interface FriendItemProps { - id: string; - name: string; - avatarUrl: string; - country: string; -} - -export function FriendItem({ id, name, avatarUrl, country }: FriendItemProps) { - return ( - -
- {name} -
-
-

{name}

-

{getCountryFlag(country)}

-
- - ); -} \ No newline at end of file diff --git a/apps/website/components/dashboard/LeagueStandingItem.tsx b/apps/website/components/dashboard/LeagueStandingItem.tsx deleted file mode 100644 index 87e9615db..000000000 --- a/apps/website/components/dashboard/LeagueStandingItem.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import Link from 'next/link'; -import { Crown, ChevronRight } from 'lucide-react'; - -interface LeagueStandingItemProps { - leagueId: string; - leagueName: string; - position: number; - points: number; - totalDrivers: number; - className?: string; -} - -export function LeagueStandingItem({ - leagueId, - leagueName, - position, - points, - totalDrivers, - className, -}: LeagueStandingItemProps) { - return ( - -
- {position > 0 ? `P${position}` : '-'} -
-
-

- {leagueName} -

-

- {points} points • {totalDrivers} drivers -

-
-
- {position <= 3 && position > 0 && ( - - )} - -
- - ); -} \ No newline at end of file diff --git a/apps/website/components/dashboard/StatCard.tsx b/apps/website/components/dashboard/StatCard.tsx deleted file mode 100644 index 7972e3f10..000000000 --- a/apps/website/components/dashboard/StatCard.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { LucideIcon } from 'lucide-react'; - -interface StatCardProps { - icon: LucideIcon; - value: string | number; - label: string; - color: string; - className?: string; -} - -export function StatCard({ icon: Icon, value, label, color, className }: StatCardProps) { - return ( -
-
-
- -
-
-

{value}

-

{label}

-
-
-
- ); -} \ No newline at end of file diff --git a/apps/website/components/dashboard/UpcomingRaceItem.tsx b/apps/website/components/dashboard/UpcomingRaceItem.tsx deleted file mode 100644 index ea6772452..000000000 --- a/apps/website/components/dashboard/UpcomingRaceItem.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import Link from 'next/link'; -import { timeUntil } from '@/lib/utilities/time'; - -interface UpcomingRaceItemProps { - id: string; - track: string; - car: string; - scheduledAt: Date; - isMyLeague: boolean; -} - -export function UpcomingRaceItem({ - id, - track, - car, - scheduledAt, - isMyLeague, -}: UpcomingRaceItemProps) { - return ( - -
-

{track}

- {isMyLeague && ( - - )} -
-

{car}

-
- - {scheduledAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - - {timeUntil(scheduledAt)} -
- - ); -} \ No newline at end of file diff --git a/apps/website/components/drivers/DriverCard.tsx b/apps/website/components/drivers/DriverCard.tsx index 5e9306d7a..af88d3342 100644 --- a/apps/website/components/drivers/DriverCard.tsx +++ b/apps/website/components/drivers/DriverCard.tsx @@ -1,6 +1,6 @@ import Card from '@/components/ui/Card'; import RankBadge from '@/components/drivers/RankBadge'; -import DriverIdentity from '@/components/drivers/DriverIdentity'; +import { DriverIdentity } from '@/components/drivers/DriverIdentity'; import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; export interface DriverCardProps { diff --git a/apps/website/components/drivers/DriverIdentity.tsx b/apps/website/components/drivers/DriverIdentity.tsx index efba6017a..e7edd7f8a 100644 --- a/apps/website/components/drivers/DriverIdentity.tsx +++ b/apps/website/components/drivers/DriverIdentity.tsx @@ -1,17 +1,20 @@ import Link from 'next/link'; import Image from 'next/image'; import PlaceholderImage from '@/components/ui/PlaceholderImage'; -import type { DriverViewModel } from '@/lib/view-models/DriverViewModel'; export interface DriverIdentityProps { - driver: DriverViewModel; + driver: { + id: string; + name: string; + avatarUrl: string | null; + }; href?: string; contextLabel?: React.ReactNode; meta?: React.ReactNode; size?: 'sm' | 'md'; } -export default function DriverIdentity(props: DriverIdentityProps) { +export function DriverIdentity(props: DriverIdentityProps) { const { driver, href, contextLabel, meta, size = 'md' } = props; const avatarSize = size === 'sm' ? 40 : 48; diff --git a/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx b/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx index 5e210e391..fe6d1fba0 100644 --- a/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx +++ b/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx @@ -3,10 +3,19 @@ import { useRouter } from 'next/navigation'; import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react'; import Button from '@/components/ui/Button'; import Image from 'next/image'; -import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; interface DriverLeaderboardPreviewProps { - drivers: DriverLeaderboardItemViewModel[]; + drivers: { + id: string; + name: string; + rating: number; + skillLevel: string; + nationality: string; + wins: number; + rank: number; + avatarUrl: string; + position: number; + }[]; onDriverClick: (id: string) => void; } diff --git a/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx b/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx index 558149f86..660b31c6a 100644 --- a/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx +++ b/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx @@ -3,11 +3,19 @@ import { useRouter } from 'next/navigation'; import Image from 'next/image'; import { Users, Crown, Shield, ChevronRight } from 'lucide-react'; import Button from '@/components/ui/Button'; -import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; import { getMediaUrl } from '@/lib/utilities/media'; interface TeamLeaderboardPreviewProps { - teams: TeamSummaryViewModel[]; + teams: { + id: string; + name: string; + tag: string; + memberCount: number; + category?: string; + totalWins: number; + logoUrl: string; + position: number; + }[]; onTeamClick: (id: string) => void; } @@ -68,7 +76,7 @@ export default function TeamLeaderboardPreview({ teams, onTeamClick }: TeamLeade {/* Leaderboard Rows */}
{top5.map((team, index) => { - const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel); + const levelConfig = SKILL_LEVELS.find((l) => l.id === team.category); const LevelIcon = levelConfig?.icon || Shield; const position = index + 1; diff --git a/apps/website/components/leagues/LeagueChampionshipStats.tsx b/apps/website/components/leagues/LeagueChampionshipStats.tsx index 730206259..fcf26e61c 100644 --- a/apps/website/components/leagues/LeagueChampionshipStats.tsx +++ b/apps/website/components/leagues/LeagueChampionshipStats.tsx @@ -1,18 +1,24 @@ import React from 'react'; import Card from '@/components/ui/Card'; -import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel'; -import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; interface LeagueChampionshipStatsProps { - standings: StandingEntryViewModel[]; - drivers: DriverViewModel[]; + standings: Array<{ + driverId: string; + position: number; + totalPoints: number; + racesFinished: number; + }>; + drivers: Array<{ + id: string; + name: string; + }>; } -export default function LeagueChampionshipStats({ standings, drivers }: LeagueChampionshipStatsProps) { +export function LeagueChampionshipStats({ standings, drivers }: LeagueChampionshipStatsProps) { if (standings.length === 0) return null; const leader = standings[0]; - const totalRaces = Math.max(...standings.map(s => s.races), 0); + const totalRaces = Math.max(...standings.map(s => s.racesFinished), 0); return (
@@ -24,7 +30,7 @@ export default function LeagueChampionshipStats({ standings, drivers }: LeagueCh

Championship Leader

{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}

-

{leader?.points || 0} points

+

{leader?.totalPoints || 0} points

diff --git a/apps/website/components/leagues/StandingsTable.tsx b/apps/website/components/leagues/StandingsTable.tsx index 8911df55c..91cb08a52 100644 --- a/apps/website/components/leagues/StandingsTable.tsx +++ b/apps/website/components/leagues/StandingsTable.tsx @@ -3,14 +3,29 @@ import { useState, useRef, useEffect } from 'react'; import Link from 'next/link'; import Image from 'next/image'; -import { Star } from 'lucide-react'; -import type { DriverViewModel } from '@/lib/view-models/DriverViewModel'; -import type { LeagueMembership } from '@/lib/types/LeagueMembership'; -import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay'; import CountryFlag from '@/components/ui/CountryFlag'; -import { getMediaUrl } from '@/lib/utilities/media'; import PlaceholderImage from '@/components/ui/PlaceholderImage'; +// League role display data +const leagueRoleDisplay = { + owner: { + text: 'Owner', + badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30', + }, + admin: { + text: 'Admin', + badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30', + }, + steward: { + text: 'Steward', + badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30', + }, + member: { + text: 'Member', + badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30', + }, +} as const; + // Position background colors const getPositionBgColor = (position: number): string => { switch (position) { @@ -23,7 +38,6 @@ const getPositionBgColor = (position: number): string => { interface StandingsTableProps { standings: Array<{ - leagueId: string; driverId: string; position: number; totalPoints: number; @@ -34,19 +48,29 @@ interface StandingsTableProps { bonusPoints: number; teamName?: string; }>; - drivers: DriverViewModel[]; - leagueId: string; - memberships?: LeagueMembership[]; + drivers: Array<{ + id: string; + name: string; + avatarUrl: string | null; + iracingId?: string; + rating?: number; + country?: string; + }>; + memberships?: Array<{ + driverId: string; + role: 'owner' | 'admin' | 'steward' | 'member'; + joinedAt: string; + status: 'active' | 'pending' | 'banned'; + }>; currentDriverId?: string; isAdmin?: boolean; onRemoveMember?: (driverId: string) => void; onUpdateRole?: (driverId: string, role: string) => void; } -export default function StandingsTable({ +export function StandingsTable({ standings, drivers, - leagueId, memberships = [], currentDriverId, isAdmin = false, @@ -68,11 +92,11 @@ export default function StandingsTable({ return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - const getDriver = (driverId: string): DriverViewModel | undefined => { + const getDriver = (driverId: string) => { return drivers.find((d) => d.id === driverId); }; - const getMembership = (driverId: string): LeagueMembership | undefined => { + const getMembership = (driverId: string) => { return memberships.find((m) => m.driverId === driverId); }; @@ -216,7 +240,7 @@ export default function StandingsTable({ ); }; - const PointsActionMenu = ({ driverId }: { driverId: string }) => { + const PointsActionMenu = () => { return (
{ const driver = getDriver(row.driverId); const membership = getMembership(row.driverId); - const roleDisplay = membership ? LeagueRoleDisplay.getLeagueRoleDisplay(membership.role) : null; + const roleDisplay = membership ? leagueRoleDisplay[membership.role] : null; const canModify = canModifyMember(row.driverId); - // TODO: Hook up real driver stats once API provides it - const driverStatsData: null = null; const isRowHovered = hoveredRow === row.driverId; const isMemberMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'member'; const isPointsMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'points'; @@ -292,7 +314,7 @@ export default function StandingsTable({ return ( setHoveredRow(row.driverId)} onMouseLeave={() => { @@ -407,7 +429,7 @@ export default function StandingsTable({ )}
- {isPointsMenuOpen && } + {isPointsMenuOpen && } {/* Races (Finished/Started) */} diff --git a/apps/website/components/onboarding/AvatarStep.tsx b/apps/website/components/onboarding/AvatarStep.tsx new file mode 100644 index 000000000..993d62611 --- /dev/null +++ b/apps/website/components/onboarding/AvatarStep.tsx @@ -0,0 +1,279 @@ +import { useRef, ChangeEvent } from 'react'; +import { Camera, Upload, Loader2, Sparkles, Palette, Check, User } from 'lucide-react'; +import Button from '@/components/ui/Button'; +import Heading from '@/components/ui/Heading'; + +export type RacingSuitColor = + | 'red' + | 'blue' + | 'green' + | 'yellow' + | 'orange' + | 'purple' + | 'black' + | 'white' + | 'pink' + | 'cyan'; + +export interface AvatarInfo { + facePhoto: string | null; + suitColor: RacingSuitColor; + generatedAvatars: string[]; + selectedAvatarIndex: number | null; + isGenerating: boolean; + isValidating: boolean; +} + +interface FormErrors { + [key: string]: string | undefined; +} + +interface AvatarStepProps { + avatarInfo: AvatarInfo; + setAvatarInfo: (info: AvatarInfo) => void; + errors: FormErrors; + setErrors: (errors: FormErrors) => void; + onGenerateAvatars: () => void; +} + +const SUIT_COLORS: { value: RacingSuitColor; label: string; hex: string }[] = [ + { value: 'red', label: 'Racing Red', hex: '#EF4444' }, + { value: 'blue', label: 'Motorsport Blue', hex: '#3B82F6' }, + { value: 'green', label: 'Racing Green', hex: '#22C55E' }, + { value: 'yellow', label: 'Championship Yellow', hex: '#EAB308' }, + { value: 'orange', label: 'Papaya Orange', hex: '#F97316' }, + { value: 'purple', label: 'Royal Purple', hex: '#A855F7' }, + { value: 'black', label: 'Stealth Black', hex: '#1F2937' }, + { value: 'white', label: 'Clean White', hex: '#F9FAFB' }, + { value: 'pink', label: 'Hot Pink', hex: '#EC4899' }, + { value: 'cyan', label: 'Electric Cyan', hex: '#06B6D4' }, +]; + +export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGenerateAvatars }: AvatarStepProps) { + const fileInputRef = useRef(null); + + const handleFileSelect = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Validate file type + if (!file.type.startsWith('image/')) { + setErrors({ ...errors, facePhoto: 'Please upload an image file' }); + return; + } + + // Validate file size (max 5MB) + if (file.size > 5 * 1024 * 1024) { + setErrors({ ...errors, facePhoto: 'Image must be less than 5MB' }); + return; + } + + // Convert to base64 + const reader = new FileReader(); + reader.onload = async (event) => { + const base64 = event.target?.result as string; + setAvatarInfo({ + ...avatarInfo, + facePhoto: base64, + generatedAvatars: [], + selectedAvatarIndex: null, + }); + const newErrors = { ...errors }; + delete newErrors.facePhoto; + setErrors(newErrors); + }; + reader.readAsDataURL(file); + }; + + return ( +
+
+ + + Create Your Racing Avatar + +

+ Upload a photo and we will generate a unique racing avatar for you +

+
+ + {/* Photo Upload */} +
+ +
+ {/* Upload Area */} +
fileInputRef.current?.click()} + className={`relative flex-1 flex flex-col items-center justify-center p-6 rounded-xl border-2 border-dashed cursor-pointer transition-all ${ + avatarInfo.facePhoto + ? 'border-performance-green bg-performance-green/5' + : errors.facePhoto + ? 'border-red-500 bg-red-500/5' + : 'border-charcoal-outline hover:border-primary-blue hover:bg-primary-blue/5' + }`} + > + + + {avatarInfo.isValidating ? ( + <> + +

Validating photo...

+ + ) : avatarInfo.facePhoto ? ( + <> +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Your photo +
+

+ + Photo uploaded +

+

Click to change

+ + ) : ( + <> + +

+ Drop your photo here or click to upload +

+

+ JPEG or PNG, max 5MB +

+ + )} +
+ + {/* Preview area */} +
+
+ {(() => { + const selectedAvatarUrl = + avatarInfo.selectedAvatarIndex !== null + ? avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex] + : undefined; + if (!selectedAvatarUrl) { + return ; + } + return ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + Selected avatar + + ); + })()} +
+

Your avatar

+
+
+ {errors.facePhoto && ( +

{errors.facePhoto}

+ )} +
+ + {/* Suit Color Selection */} +
+ +
+ {SUIT_COLORS.map((color) => ( + + ))} +
+

+ Selected: {SUIT_COLORS.find(c => c.value === avatarInfo.suitColor)?.label} +

+
+ + {/* Generate Button */} + {avatarInfo.facePhoto && !errors.facePhoto && ( +
+ +
+ )} + + {/* Generated Avatars */} + {avatarInfo.generatedAvatars.length > 0 && ( +
+ +
+ {avatarInfo.generatedAvatars.map((url, index) => ( + + ))} +
+ {errors.avatar && ( +

{errors.avatar}

+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/onboarding/OnboardingWizard.tsx b/apps/website/components/onboarding/OnboardingWizard.tsx index b29d66f74..6c38a5811 100644 --- a/apps/website/components/onboarding/OnboardingWizard.tsx +++ b/apps/website/components/onboarding/OnboardingWizard.tsx @@ -1,56 +1,14 @@ -'use client'; - -import { useState, useRef, FormEvent, ChangeEvent } from 'react'; -import { useRouter } from 'next/navigation'; -import Image from 'next/image'; -import { - User, - Flag, - Camera, - Clock, - Check, - ChevronRight, - ChevronLeft, - AlertCircle, - Upload, - Loader2, - Sparkles, - Palette, -} from 'lucide-react'; +import { useState, FormEvent } from 'react'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; -import CountrySelect from '@/components/ui/CountrySelect'; -import { useAuth } from '@/lib/auth/AuthContext'; -import { useCompleteOnboarding } from "@/lib/hooks/onboarding/useCompleteOnboarding"; -import { useGenerateAvatars } from "@/lib/hooks/onboarding/useGenerateAvatars"; -import { useValidateFacePhoto } from "@/lib/hooks/onboarding/useValidateFacePhoto"; - -// ============================================================================ -// TYPES -// ============================================================================ +import { StepIndicator } from '@/ui/StepIndicator'; +import { PersonalInfoStep, PersonalInfo } from './PersonalInfoStep'; +import { AvatarStep, AvatarInfo } from './AvatarStep'; type OnboardingStep = 1 | 2; -interface PersonalInfo { - firstName: string; - lastName: string; - displayName: string; - country: string; - timezone: string; -} - -interface AvatarInfo { - facePhoto: string | null; - suitColor: RacingSuitColor; - generatedAvatars: string[]; - selectedAvatarIndex: number | null; - isGenerating: boolean; - isValidating: boolean; -} - interface FormErrors { + [key: string]: string | undefined; firstName?: string; lastName?: string; displayName?: string; @@ -60,113 +18,22 @@ interface FormErrors { submit?: string; } -type RacingSuitColor = - | 'red' - | 'blue' - | 'green' - | 'yellow' - | 'orange' - | 'purple' - | 'black' - | 'white' - | 'pink' - | 'cyan'; - -// ============================================================================ -// CONSTANTS -// ============================================================================ - -const TIMEZONES = [ - { value: 'America/New_York', label: 'Eastern Time (ET)' }, - { value: 'America/Chicago', label: 'Central Time (CT)' }, - { value: 'America/Denver', label: 'Mountain Time (MT)' }, - { value: 'America/Los_Angeles', label: 'Pacific Time (PT)' }, - { value: 'Europe/London', label: 'Greenwich Mean Time (GMT)' }, - { value: 'Europe/Berlin', label: 'Central European Time (CET)' }, - { value: 'Europe/Paris', label: 'Central European Time (CET)' }, - { value: 'Australia/Sydney', label: 'Australian Eastern Time (AET)' }, - { value: 'Asia/Tokyo', label: 'Japan Standard Time (JST)' }, - { value: 'America/Sao_Paulo', label: 'Brasília Time (BRT)' }, -]; - -const SUIT_COLORS: { value: RacingSuitColor; label: string; hex: string }[] = [ - { value: 'red', label: 'Racing Red', hex: '#EF4444' }, - { value: 'blue', label: 'Motorsport Blue', hex: '#3B82F6' }, - { value: 'green', label: 'Racing Green', hex: '#22C55E' }, - { value: 'yellow', label: 'Championship Yellow', hex: '#EAB308' }, - { value: 'orange', label: 'Papaya Orange', hex: '#F97316' }, - { value: 'purple', label: 'Royal Purple', hex: '#A855F7' }, - { value: 'black', label: 'Stealth Black', hex: '#1F2937' }, - { value: 'white', label: 'Clean White', hex: '#F9FAFB' }, - { value: 'pink', label: 'Hot Pink', hex: '#EC4899' }, - { value: 'cyan', label: 'Electric Cyan', hex: '#06B6D4' }, -]; - -// ============================================================================ -// HELPER COMPONENTS -// ============================================================================ - -function StepIndicator({ currentStep }: { currentStep: number }) { - const steps = [ - { id: 1, label: 'Personal', icon: User }, - { id: 2, label: 'Avatar', icon: Camera }, - ]; - - return ( -
- {steps.map((step, index) => { - const Icon = step.icon; - const isCompleted = step.id < currentStep; - const isCurrent = step.id === currentStep; - - return ( -
-
-
- {isCompleted ? ( - - ) : ( - - )} -
- - {step.label} - -
- {index < steps.length - 1 && ( -
- )} -
- ); - })} -
- ); +interface OnboardingWizardProps { + onCompleted: () => void; + onCompleteOnboarding: (data: { + firstName: string; + lastName: string; + displayName: string; + country: string; + timezone?: string; + }) => Promise<{ success: boolean; error?: string }>; + onGenerateAvatars: (params: { + facePhotoData: string; + suitColor: string; + }) => Promise<{ success: boolean; data?: { success: boolean; avatarUrls?: string[]; errorMessage?: string }; error?: string }>; } -// ============================================================================ -// MAIN COMPONENT -// ============================================================================ - -export default function OnboardingWizard() { - const router = useRouter(); - const fileInputRef = useRef(null); - const { session } = useAuth(); +export function OnboardingWizard({ onCompleted, onCompleteOnboarding, onGenerateAvatars }: OnboardingWizardProps) { const [step, setStep] = useState(1); const [errors, setErrors] = useState({}); @@ -235,139 +102,39 @@ export default function OnboardingWizard() { } }; - const handleFileSelect = async (e: ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - // Validate file type - if (!file.type.startsWith('image/')) { - setErrors({ ...errors, facePhoto: 'Please upload an image file' }); - return; - } - - // Validate file size (max 5MB) - if (file.size > 5 * 1024 * 1024) { - setErrors({ ...errors, facePhoto: 'Image must be less than 5MB' }); - return; - } - - // Convert to base64 - const reader = new FileReader(); - reader.onload = async (event) => { - const base64 = event.target?.result as string; - setAvatarInfo({ - ...avatarInfo, - facePhoto: base64, - generatedAvatars: [], - selectedAvatarIndex: null, - }); - setErrors((prev) => { - const { facePhoto, ...rest } = prev; - return rest; - }); - - // Validate face - await validateFacePhoto(base64); - }; - reader.readAsDataURL(file); - }; - - const validateFacePhotoMutation = useValidateFacePhoto({ - onSuccess: () => { - setAvatarInfo(prev => ({ ...prev, isValidating: false })); - }, - onError: (error) => { - setErrors(prev => ({ - ...prev, - facePhoto: error.message || 'Face validation failed' - })); - setAvatarInfo(prev => ({ ...prev, facePhoto: null, isValidating: false })); - }, - }); - - const validateFacePhoto = async (photoData: string) => { - setAvatarInfo(prev => ({ ...prev, isValidating: true })); - setErrors(prev => { - const { facePhoto, ...rest } = prev; - return rest; - }); - - try { - const result = await validateFacePhotoMutation.mutateAsync(photoData); - - if (!result.isValid) { - setErrors(prev => ({ - ...prev, - facePhoto: result.errorMessage || 'Face validation failed' - })); - setAvatarInfo(prev => ({ ...prev, facePhoto: null, isValidating: false })); - } - } catch (error) { - // For now, just accept the photo if validation fails - setAvatarInfo(prev => ({ ...prev, isValidating: false })); - } - }; - - const generateAvatarsMutation = useGenerateAvatars({ - onSuccess: (result) => { - if (result.success && result.avatarUrls) { - setAvatarInfo(prev => ({ - ...prev, - generatedAvatars: result.avatarUrls, - isGenerating: false, - })); - } else { - setErrors(prev => ({ ...prev, avatar: result.errorMessage || 'Failed to generate avatars' })); - setAvatarInfo(prev => ({ ...prev, isGenerating: false })); - } - }, - onError: () => { - setErrors(prev => ({ ...prev, avatar: 'Failed to generate avatars. Please try again.' })); - setAvatarInfo(prev => ({ ...prev, isGenerating: false })); - }, - }); - const generateAvatars = async () => { if (!avatarInfo.facePhoto) { setErrors({ ...errors, facePhoto: 'Please upload a photo first' }); return; } - if (!session?.user?.userId) { - setErrors({ ...errors, submit: 'User not authenticated' }); - return; - } - setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null })); - setErrors(prev => { - const { avatar, ...rest } = prev; - return rest; - }); + const newErrors = { ...errors }; + delete newErrors.avatar; + setErrors(newErrors); try { - await generateAvatarsMutation.mutateAsync({ - userId: session.user.userId, + const result = await onGenerateAvatars({ facePhotoData: avatarInfo.facePhoto, suitColor: avatarInfo.suitColor, }); + + if (result.success && result.data?.success && result.data.avatarUrls) { + setAvatarInfo(prev => ({ + ...prev, + generatedAvatars: result.data!.avatarUrls!, + isGenerating: false, + })); + } else { + setErrors(prev => ({ ...prev, avatar: result.data?.errorMessage || result.error || 'Failed to generate avatars' })); + setAvatarInfo(prev => ({ ...prev, isGenerating: false })); + } } catch (error) { - // Error handling is done in the mutation's onError callback + setErrors(prev => ({ ...prev, avatar: 'Failed to generate avatars. Please try again.' })); + setAvatarInfo(prev => ({ ...prev, isGenerating: false })); } }; - const completeOnboardingMutation = useCompleteOnboarding({ - onSuccess: () => { - // TODO: Handle avatar assignment separately if needed - router.push('/dashboard'); - router.refresh(); - }, - onError: (error) => { - setErrors({ - submit: error.message || 'Failed to create profile', - }); - }, - }); - const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -384,42 +151,37 @@ export default function OnboardingWizard() { setErrors({}); try { - await completeOnboardingMutation.mutateAsync({ + const result = await onCompleteOnboarding({ firstName: personalInfo.firstName.trim(), lastName: personalInfo.lastName.trim(), displayName: personalInfo.displayName.trim(), country: personalInfo.country, timezone: personalInfo.timezone || undefined, }); + + if (result.success) { + onCompleted(); + } else { + setErrors({ submit: result.error || 'Failed to create profile' }); + } } catch (error) { - // Error handling is done in the mutation's onError callback + setErrors({ submit: 'Failed to create profile' }); } }; // Loading state comes from the mutations - const loading = completeOnboardingMutation.isPending || - generateAvatarsMutation.isPending || - validateFacePhotoMutation.isPending; - - const getCountryFlag = (countryCode: string): string => { - const code = countryCode.toUpperCase(); - if (code.length === 2) { - const codePoints = [...code].map(char => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); - } - return '🏁'; - }; + const loading = false; // This would be managed by the parent component return (
{/* Header */}
- + 🏁
- Welcome to GridPilot +

Welcome to GridPilot

- Let's set up your racing profile + Let us set up your racing profile

@@ -434,323 +196,29 @@ export default function OnboardingWizard() {
{/* Step 1: Personal Information */} {step === 1 && ( -
-
- - - Personal Information - -

- Tell us a bit about yourself -

-
- -
-
- - - setPersonalInfo({ ...personalInfo, firstName: e.target.value }) - } - error={!!errors.firstName} - errorMessage={errors.firstName} - placeholder="John" - disabled={loading} - /> -
- -
- - - setPersonalInfo({ ...personalInfo, lastName: e.target.value }) - } - error={!!errors.lastName} - errorMessage={errors.lastName} - placeholder="Racer" - disabled={loading} - /> -
-
- -
- - - setPersonalInfo({ ...personalInfo, displayName: e.target.value }) - } - error={!!errors.displayName} - errorMessage={errors.displayName} - placeholder="SpeedyRacer42" - disabled={loading} - /> -
- -
-
- - - setPersonalInfo({ ...personalInfo, country: value }) - } - error={!!errors.country} - errorMessage={errors.country ?? ''} - disabled={loading} - /> -
- -
- -
- - - -
-
-
-
+ )} {/* Step 2: Avatar Generation */} {step === 2 && ( -
-
- - - Create Your Racing Avatar - -

- Upload a photo and we'll generate a unique racing avatar for you -

-
- - {/* Photo Upload */} -
- -
- {/* Upload Area */} -
fileInputRef.current?.click()} - className={`relative flex-1 flex flex-col items-center justify-center p-6 rounded-xl border-2 border-dashed cursor-pointer transition-all ${ - avatarInfo.facePhoto - ? 'border-performance-green bg-performance-green/5' - : errors.facePhoto - ? 'border-red-500 bg-red-500/5' - : 'border-charcoal-outline hover:border-primary-blue hover:bg-primary-blue/5' - }`} - > - - - {avatarInfo.isValidating ? ( - <> - -

Validating photo...

- - ) : avatarInfo.facePhoto ? ( - <> -
- Your photo -
-

- - Photo uploaded -

-

Click to change

- - ) : ( - <> - -

- Drop your photo here or click to upload -

-

- JPEG or PNG, max 5MB -

- - )} -
- - {/* Preview area */} -
-
- {(() => { - const selectedAvatarUrl = - avatarInfo.selectedAvatarIndex !== null - ? avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex] - : undefined; - if (!selectedAvatarUrl) { - return ; - } - return ( - Selected avatar - ); - })()} -
-

Your avatar

-
-
- {errors.facePhoto && ( -

{errors.facePhoto}

- )} -
- - {/* Suit Color Selection */} -
- -
- {SUIT_COLORS.map((color) => ( - - ))} -
-

- Selected: {SUIT_COLORS.find(c => c.value === avatarInfo.suitColor)?.label} -

-
- - {/* Generate Button */} - {avatarInfo.facePhoto && !errors.facePhoto && ( -
- -
- )} - - {/* Generated Avatars */} - {avatarInfo.generatedAvatars.length > 0 && ( -
- -
- {avatarInfo.generatedAvatars.map((url, index) => ( - - ))} -
- {errors.avatar && ( -

{errors.avatar}

- )} -
- )} -
+ )} {/* Error Message */} {errors.submit && (
- +

{errors.submit}

)} @@ -764,7 +232,7 @@ export default function OnboardingWizard() { disabled={step === 1 || loading} className="flex items-center gap-2" > - + Back @@ -777,7 +245,7 @@ export default function OnboardingWizard() { className="flex items-center gap-2" > Continue - + ) : ( - - - - -
-
- - {/* Quick Stats Row */} -
- - - - -
+
+
+
+

Good morning,

+

+ {currentDriver.name} + {currentDriver.country} +

+
+
+ {currentDriver.rating} +
+
+ #{currentDriver.rank} +
+ {currentDriver.totalRaces} races completed
- +
+
- {/* Main Content */} -
-
- {/* Left Column - Main Content */} -
- {/* Next Race Card */} - {nextRace && ( - -
-
-
-
- - Next Race -
- {nextRace.isMyLeague && ( - - Your League - - )} -
- -
-
-

{nextRace.track}

-

{nextRace.car}

-
- - - {nextRace.formattedDate} - - - - {nextRace.formattedTime} - -
-
- -
-
-

Starts in

-

{nextRace.timeUntil}

-
- - - -
-
-
- - )} + {/* Quick Actions */} + +
- {/* League Standings Preview */} - {leagueStandingsSummaries.length > 0 && ( - -
-

- - Your Championship Standings -

- - View all - -
-
- {leagueStandingsSummaries.map((summary: any) => ( - - ))} -
-
- )} - - {/* Activity Feed */} - -
-

- - Recent Activity -

-
- {feedSummary.items.length > 0 ? ( -
- {feedSummary.items.slice(0, 5).map((item: any) => ( - - ))} -
- ) : ( -
- -

No activity yet

-

Join leagues and add friends to see activity here

-
- )} -
-
- - {/* Right Column - Sidebar */} -
- {/* Upcoming Races */} - -
-

- - Upcoming Races -

- - View all - -
- {upcomingRaces.length > 0 ? ( -
- {upcomingRaces.slice(0, 5).map((race: any) => ( - - ))} -
- ) : ( -

No upcoming races

- )} -
- - {/* Friends */} - -
-

- - Friends -

- {friends.length} friends -
- {friends.length > 0 ? ( -
- {friends.slice(0, 6).map((friend: any) => ( - - ))} - {friends.length > 6 && ( - - +{friends.length - 6} more - - )} -
- ) : ( -
- -

No friends yet

- - - -
- )} -
-
+ {/* Quick Stats Row */} +
+
+
+
+ Trophy
-
- - ); -} +
+

{currentDriver.wins}

+

Wins

+
+
+
+
+
+
+ Medal +
+
+

{currentDriver.podiums}

+

Podiums

+
+
+
+
+
+
+ Target +
+
+

{currentDriver.consistency}

+

Consistency

+
+
+
+
+
+
+ Users +
+
+

{activeLeaguesCount}

+

Active Leagues

+
+
+
+
+
+ + + {/* Main Content */} +
+
+ {/* Left Column - Main Content */} +
+ {/* Next Race Card */} + {nextRace && ( +
+
+
+
+
+ Next Race +
+ {nextRace.isMyLeague && ( + + Your League + + )} +
+ +
+
+

{nextRace.track}

+

{nextRace.car}

+
+ + Calendar + {nextRace.formattedDate} + + + Clock + {nextRace.formattedTime} + +
+
+ +
+
+

Starts in

+

{nextRace.timeUntil}

+
+ + View Details + ChevronRight + +
+
+
+
+ )} + + {/* League Standings Preview */} + {hasLeagueStandings && ( +
+
+

+ Award + Your Championship Standings +

+ + View all ChevronRight + +
+
+ {leagueStandings.map((summary) => ( +
+
+

{summary.leagueName}

+

Position {summary.position} • {summary.points} points

+
+ {summary.totalDrivers} drivers +
+ ))} +
+
+ )} + + {/* Activity Feed */} +
+
+

+ Activity + Recent Activity +

+
+ {hasFeedItems ? ( +
+ {feedItems.slice(0, 5).map((item) => ( +
+
+

{item.headline}

+ {item.body &&

{item.body}

} +

{item.formattedTime}

+
+ {item.ctaHref && item.ctaLabel && ( + + {item.ctaLabel} + + )} +
+ ))} +
+ ) : ( +
+ Activity +

No activity yet

+

Join leagues and add friends to see activity here

+
+ )} +
+
+ + {/* Right Column - Sidebar */} +
+ {/* Upcoming Races */} +
+
+

+ Calendar + Upcoming Races +

+ + View all + +
+ {hasUpcomingRaces ? ( +
+ {upcomingRaces.slice(0, 5).map((race) => ( +
+

{race.track}

+

{race.car}

+
+ {race.formattedDate} + + {race.formattedTime} +
+ {race.isMyLeague && ( + + Your League + + )} +
+ ))} +
+ ) : ( +

No upcoming races

+ )} +
+ + {/* Friends */} +
+
+

+ Users + Friends +

+ {friends.length} friends +
+ {hasFriends ? ( +
+ {friends.slice(0, 6).map((friend) => ( +
+
+ {friend.name} +
+
+

{friend.name}

+

{friend.country}

+
+
+ ))} + {friends.length > 6 && ( + + +{friends.length - 6} more + + )} +
+ ) : ( +
+ UserPlus +

No friends yet

+ + Find Drivers + +
+ )} +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/apps/website/templates/DriverRankingsTemplate.tsx b/apps/website/templates/DriverRankingsTemplate.tsx index 2bb642efe..4fe086954 100644 --- a/apps/website/templates/DriverRankingsTemplate.tsx +++ b/apps/website/templates/DriverRankingsTemplate.tsx @@ -1,32 +1,45 @@ 'use client'; import React from 'react'; -import { Trophy, Search, ArrowLeft, Medal } from 'lucide-react'; +import { Trophy, ArrowLeft, Medal } from 'lucide-react'; import Button from '@/components/ui/Button'; import Heading from '@/components/ui/Heading'; -import DriverRankingsFilter from '@/components/DriverRankingsFilter'; -import { DriverTopThreePodium } from '@/components/DriverTopThreePodium'; import Image from 'next/image'; import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData'; +// ============================================================================ +// TYPES +// ============================================================================ + +interface DriverRankingsTemplateProps { + viewData: DriverRankingsViewData; + onDriverClick?: (id: string) => void; + onBackToLeaderboards?: () => void; +} + // ============================================================================ // MAIN TEMPLATE COMPONENT // ============================================================================ -export function DriverRankingsTemplate(props: { viewData: DriverRankingsViewData }): React.ReactElement { - const { viewData } = props; +export function DriverRankingsTemplate({ + viewData, + onDriverClick, + onBackToLeaderboards, +}: DriverRankingsTemplateProps): React.ReactElement { return (
{/* Header */}
- + {onBackToLeaderboards && ( + + )}
@@ -43,20 +56,71 @@ export function DriverRankingsTemplate(props: { viewData: DriverRankingsViewData {/* Top 3 Podium */} {viewData.podium.length > 0 && ( - - )} +
+
+ {[1, 0, 2].map((index) => { + const driver = viewData.podium[index]; + if (!driver) return null; + + const position = index === 1 ? 1 : index === 0 ? 2 : 3; + const config = { + 1: { height: 'h-40', color: 'from-yellow-400/20 to-amber-500/10 border-yellow-400/40', crown: 'text-yellow-400', text: 'text-xl text-yellow-400' }, + 2: { height: 'h-32', color: 'from-gray-400/20 to-gray-500/10 border-gray-400/40', crown: 'text-gray-300', text: 'text-lg text-gray-300' }, + 3: { height: 'h-24', color: 'from-amber-600/20 to-amber-700/10 border-amber-600/40', crown: 'text-amber-600', text: 'text-base text-amber-600' }, + }[position]; - {/* Filters */} - + return ( + + ); + })} +
+
+ )} {/* Leaderboard Table */}
@@ -73,89 +137,94 @@ export function DriverRankingsTemplate(props: { viewData: DriverRankingsViewData {/* Table Body */}
- {viewData.drivers.map((driver) => ( -
- {/* Races */} -
- {driver.racesCompleted} -
+ {/* Driver Info */} +
+
+ {driver.name} +
+
+

+ {driver.name} +

+
+ + {driver.nationality} + + + {driver.skillLevel} + +
+
+
- {/* Rating */} -
- - {driver.rating.toLocaleString()} - -
+ {/* Races */} +
+ {driver.racesCompleted} +
- {/* Wins */} -
- - {driver.wins} - -
+ {/* Rating */} +
+ + {driver.rating.toLocaleString()} + +
- {/* Podiums */} -
- - {driver.podiums} - -
+ {/* Wins */} +
+ + {driver.wins} + +
- {/* Win Rate */} -
- - {driver.winRate}% - -
- - ))} + {/* Podiums */} +
+ + {driver.podiums} + +
+ + {/* Win Rate */} +
+ + {driver.winRate}% + +
+ + ); + })}
{/* Empty State */} {viewData.drivers.length === 0 && (
- + 🔍

No drivers found

-

Try adjusting your filters or search query

- +

There are no drivers in the system yet

)}
diff --git a/apps/website/templates/LeaderboardsTemplate.tsx b/apps/website/templates/LeaderboardsTemplate.tsx index 745e19562..f9270bd9a 100644 --- a/apps/website/templates/LeaderboardsTemplate.tsx +++ b/apps/website/templates/LeaderboardsTemplate.tsx @@ -6,16 +6,33 @@ import Button from '@/components/ui/Button'; import Heading from '@/components/ui/Heading'; import DriverLeaderboardPreview from '@/components/leaderboards/DriverLeaderboardPreview'; import TeamLeaderboardPreview from '@/components/leaderboards/TeamLeaderboardPreview'; -import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; -import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; // ============================================================================ // TYPES // ============================================================================ interface LeaderboardsTemplateProps { - drivers: DriverLeaderboardItemViewModel[]; - teams: TeamSummaryViewModel[]; + drivers: { + id: string; + name: string; + rating: number; + skillLevel: string; + nationality: string; + wins: number; + rank: number; + avatarUrl: string; + position: number; + }[]; + teams: { + id: string; + name: string; + tag: string; + memberCount: number; + category?: string; + totalWins: number; + logoUrl: string; + position: number; + }[]; onDriverClick: (driverId: string) => void; onTeamClick: (teamId: string) => void; onNavigateToDrivers: () => void; diff --git a/apps/website/templates/LeaguesTemplate.tsx b/apps/website/templates/LeaguesTemplate.tsx index 8d9ba182f..da515c559 100644 --- a/apps/website/templates/LeaguesTemplate.tsx +++ b/apps/website/templates/LeaguesTemplate.tsx @@ -23,6 +23,7 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; +import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData'; import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; // ============================================================================ @@ -49,7 +50,7 @@ interface Category { label: string; icon: React.ElementType; description: string; - filter: (league: LeagueSummaryViewModel) => boolean; + filter: (league: LeaguesViewData['leagues'][number]) => boolean; color?: string; } @@ -57,17 +58,15 @@ interface LeagueSliderProps { title: string; icon: React.ElementType; description: string; - leagues: LeagueSummaryViewModel[]; + leagues: LeaguesViewData['leagues']; autoScroll?: boolean; iconColor?: string; scrollSpeedMultiplier?: number; scrollDirection?: 'left' | 'right'; } -import Link from 'next/link'; - interface LeaguesTemplateProps { - data: LeagueSummaryViewModel[]; + data: LeaguesViewData; } // ============================================================================ @@ -367,13 +366,36 @@ function LeagueSlider({ display: none; } `} - {leagues.map((league) => ( -
- - - -
- ))} + {leagues.map((league) => { + // Convert ViewData to ViewModel for LeagueCard + const viewModel: LeagueSummaryViewModel = { + id: league.id, + name: league.name, + description: league.description ?? '', + logoUrl: league.logoUrl, + ownerId: league.ownerId, + createdAt: league.createdAt, + maxDrivers: league.maxDrivers, + usedDriverSlots: league.usedDriverSlots, + maxTeams: league.maxTeams ?? 0, + usedTeamSlots: league.usedTeamSlots ?? 0, + structureSummary: league.structureSummary, + timingSummary: league.timingSummary, + category: league.category ?? undefined, + scoring: league.scoring ? { + ...league.scoring, + primaryChampionshipType: league.scoring.primaryChampionshipType as 'driver' | 'team' | 'nations' | 'trophy', + } : undefined, + }; + + return ( +
+ + + +
+ ); + })}
@@ -392,7 +414,7 @@ export function LeaguesTemplate({ const [showFilters, setShowFilters] = useState(false); // Filter by search query - const searchFilteredLeagues = data.filter((league: LeagueSummaryViewModel) => { + const searchFilteredLeagues = data.leagues.filter((league) => { if (!searchQuery) return true; const query = searchQuery.toLowerCase(); return ( @@ -412,7 +434,7 @@ export function LeaguesTemplate({ const leaguesByCategory = CATEGORIES.reduce( (acc, category) => { // First try to use the dedicated category field, fall back to scoring-based filtering - acc[category.id] = searchFilteredLeagues.filter((league: LeagueSummaryViewModel) => { + acc[category.id] = searchFilteredLeagues.filter((league) => { // If league has a category field, use it directly if (league.category) { return league.category === category.id; @@ -422,7 +444,7 @@ export function LeaguesTemplate({ }); return acc; }, - {} as Record, + {} as Record, ); // Featured categories to show as sliders with different scroll speeds and alternating directions @@ -463,7 +485,7 @@ export function LeaguesTemplate({
- {data.length} active leagues + {data.leagues.length} active leagues
@@ -483,10 +505,10 @@ export function LeaguesTemplate({ {/* CTA */}
- + Create League - +

Set up your own racing series

@@ -553,7 +575,7 @@ export function LeaguesTemplate({
{/* Content */} - {data.length === 0 ? ( + {data.leagues.length === 0 ? ( /* Empty State */
@@ -566,10 +588,10 @@ export function LeaguesTemplate({

Be the first to create a racing series. Start your own league and invite drivers to compete for glory.

- + Create Your First League - +
) : activeCategory === 'all' && !searchQuery ? ( @@ -613,11 +635,34 @@ export function LeaguesTemplate({

- {categoryFilteredLeagues.map((league) => ( - - - - ))} + {categoryFilteredLeagues.map((league) => { + // Convert ViewData to ViewModel for LeagueCard + const viewModel: LeagueSummaryViewModel = { + id: league.id, + name: league.name, + description: league.description ?? '', + logoUrl: league.logoUrl, + ownerId: league.ownerId, + createdAt: league.createdAt, + maxDrivers: league.maxDrivers, + usedDriverSlots: league.usedDriverSlots, + maxTeams: league.maxTeams ?? 0, + usedTeamSlots: league.usedTeamSlots ?? 0, + structureSummary: league.structureSummary, + timingSummary: league.timingSummary, + category: league.category ?? undefined, + scoring: league.scoring ? { + ...league.scoring, + primaryChampionshipType: league.scoring.primaryChampionshipType as 'driver' | 'team' | 'nations' | 'trophy', + } : undefined, + }; + + return ( + + + + ); + })}
) : ( diff --git a/apps/website/templates/ProfileLeaguesTemplate.tsx b/apps/website/templates/ProfileLeaguesTemplate.tsx index 28a7b04bb..d17b28da5 100644 --- a/apps/website/templates/ProfileLeaguesTemplate.tsx +++ b/apps/website/templates/ProfileLeaguesTemplate.tsx @@ -1,4 +1,4 @@ -import type { ProfileLeaguesViewData } from './ProfileLeaguesViewData'; +import type { ProfileLeaguesViewData } from '@/lib/view-data/ProfileLeaguesViewData'; interface ProfileLeaguesTemplateProps { viewData: ProfileLeaguesViewData; @@ -31,7 +31,7 @@ export function ProfileLeaguesTemplate({ viewData }: ProfileLeaguesTemplateProps

) : (
- {viewData.ownedLeagues.map((league) => ( + {viewData.ownedLeagues.map((league: ProfileLeaguesViewData['ownedLeagues'][number]) => (
) : (
- {viewData.memberLeagues.map((league) => ( + {viewData.memberLeagues.map((league: ProfileLeaguesViewData['memberLeagues'][number]) => (
Promise; +} + +function getAchievementIcon(icon: NonNullable['achievements'][number]['icon']) { + switch (icon) { + case 'trophy': + return Trophy; + case 'medal': + return Medal; + case 'star': + return Star; + case 'crown': + return Crown; + case 'target': + return Target; + case 'zap': + return Zap; + } +} + +function getSocialIcon(platformLabel: string) { + switch (platformLabel) { + case 'twitter': + return Twitter; + case 'youtube': + return Youtube; + case 'twitch': + return Twitch; + case 'discord': + return MessageCircle; + default: + return Globe; + } +} + +export function ProfileTemplate({ viewData, mode, onSaveSettings }: ProfileTemplateProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const tabParam = searchParams.get('tab') as ProfileTab | null; + + const [editMode, setEditMode] = useState(false); + const [activeTab, setActiveTab] = useState(tabParam || 'overview'); + const [friendRequestSent, setFriendRequestSent] = useState(false); + + useEffect(() => { + if (searchParams.get('tab') !== activeTab) { + const params = new URLSearchParams(searchParams.toString()); + if (activeTab === 'overview') { + params.delete('tab'); + } else { + params.set('tab', activeTab); + } + const query = params.toString(); + router.replace(`/profile${query ? `?${query}` : ''}`, { scroll: false }); + } + }, [activeTab, searchParams, router]); + + useEffect(() => { + const tab = searchParams.get('tab') as ProfileTab | null; + if (tab && tab !== activeTab) { + setActiveTab(tab); + } + }, [searchParams]); + + if (mode === 'needs-profile') { + return ( +
+
+
+ +
+ Create Your Driver Profile +

Join the GridPilot community and start your racing journey

+
+ + +
+

Get Started

+

+ Create your driver profile to join leagues, compete in races, and connect with other drivers. +

+
+ +
+
+ ); + } + + if (!viewData) { + return ( +
+ + +

Unable to load profile

+
+
+ ); + } + + if (editMode) { + return ( +
+
+ Edit Profile + +
+ + {/* ProfileSettings expects a DriverProfileDriverSummaryViewModel; keep existing component usage by passing a minimal compatible shape */} + { + await onSaveSettings(updates); + setEditMode(false); + }} + /> +
+ ); + } + + return ( +
+ {/* Hero */} +
+
+
+
+
+
+ {viewData.driver.name} +
+
+
+
+ +
+
+

{viewData.driver.name}

+ {viewData.driver.countryFlag} + {viewData.teamMemberships[0] && ( + + [{viewData.teamMemberships[0].teamTag || 'TEAM'}] + + )} +
+ + {viewData.stats && ( +
+
+ + {viewData.stats.ratingLabel} + Rating +
+
+ + {viewData.stats.globalRankLabel} + Global +
+
+ )} + +
+ + + iRacing: {viewData.driver.iracingId ?? '—'} + + + + Joined {viewData.driver.joinedAtLabel} + + {viewData.extendedProfile && ( + + + {viewData.extendedProfile.timezone} + + )} +
+
+ +
+ + + + + +
+
+ + {viewData.extendedProfile && viewData.extendedProfile.socialHandles.length > 0 && ( +
+
+ Connect: + {viewData.extendedProfile.socialHandles.map((social) => { + const Icon = getSocialIcon(social.platformLabel); + return ( + + + {social.handle} + + + ); + })} +
+
+ )} +
+
+ + {viewData.driver.bio && ( + +

+ + About +

+

{viewData.driver.bio}

+
+ )} + + {viewData.teamMemberships.length > 0 && ( + +

+ + Team Memberships + ({viewData.teamMemberships.length}) +

+
+ {viewData.teamMemberships.map((membership) => ( + +
+ +
+
+

{membership.teamName}

+
+ {membership.roleLabel} + Since {membership.joinedAtLabel} +
+
+ + + ))} +
+
+ )} + + {/* Tabs */} +
+ + + +
+ + {activeTab === 'history' && ( + +

+ + Race History +

+ +
+ )} + + {activeTab === 'stats' && viewData.stats && ( +
+ +

+ + Performance Overview +

+
+
+
{viewData.stats.totalRacesLabel}
+
Races
+
+
+
{viewData.stats.winsLabel}
+
Wins
+
+
+
{viewData.stats.podiumsLabel}
+
Podiums
+
+
+
{viewData.stats.consistencyLabel}
+
Consistency
+
+
+
+
+ )} + + {activeTab === 'overview' && viewData.extendedProfile && ( + +

+ + Achievements + {viewData.extendedProfile.achievements.length} earned +

+
+ {viewData.extendedProfile.achievements.map((achievement) => { + const Icon = getAchievementIcon(achievement.icon); + return ( +
+
+
+ +
+
+

{achievement.title}

+

{achievement.description}

+

{achievement.earnedAtLabel}

+
+
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/apps/website/templates/SponsorshipRequestsTemplate.tsx b/apps/website/templates/SponsorshipRequestsTemplate.tsx index ebf9e480a..3fac8cd50 100644 --- a/apps/website/templates/SponsorshipRequestsTemplate.tsx +++ b/apps/website/templates/SponsorshipRequestsTemplate.tsx @@ -1,158 +1,35 @@ -'use client'; - -import Breadcrumbs from '@/components/layout/Breadcrumbs'; -import PendingSponsorshipRequests from '@/components/sponsors/PendingSponsorshipRequests'; -import Card from '@/components/ui/Card'; -import { AlertTriangle, Building, ChevronRight, Handshake, Trophy, User, Users } from 'lucide-react'; -import Link from 'next/link'; - -export interface EntitySection { - entityType: 'driver' | 'team' | 'race' | 'season'; - entityId: string; - entityName: string; - requests: any[]; -} +import { SponsorshipRequestsPageViewDataBuilder } from '@/lib/builders/view-data/SponsorshipRequestsPageViewDataBuilder'; +import { AcceptSponsorshipRequestMutation } from '@/lib/mutations/sponsors/AcceptSponsorshipRequestMutation'; +import { RejectSponsorshipRequestMutation } from '@/lib/mutations/sponsors/RejectSponsorshipRequestMutation'; +import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/page-queries/SponsorshipRequestsPageQuery'; +import { SponsorshipRequestsClient } from '@/app/profile/sponsorship-requests/SponsorshipRequestsClient'; export interface SponsorshipRequestsTemplateProps { - data: EntitySection[]; - onAccept: (requestId: string) => Promise; - onReject: (requestId: string, reason?: string) => Promise; + searchParams: Record; } -export function SponsorshipRequestsTemplate({ data, onAccept, onReject }: SponsorshipRequestsTemplateProps) { - const totalRequests = data.reduce((sum, s) => sum + s.requests.length, 0); +export async function SponsorshipRequestsTemplate({ + searchParams, +}: SponsorshipRequestsTemplateProps) { + const pageQuery = new SponsorshipRequestsPageQuery(); + const viewDataBuilder = new SponsorshipRequestsPageViewDataBuilder(); + const acceptMutation = new AcceptSponsorshipRequestMutation(); + const rejectMutation = new RejectSponsorshipRequestMutation(); - const getEntityIcon = (type: 'driver' | 'team' | 'race' | 'season') => { - switch (type) { - case 'driver': - return User; - case 'team': - return Users; - case 'race': - return Trophy; - case 'season': - return Trophy; - default: - return Building; - } - }; - - const getEntityLink = (type: 'driver' | 'team' | 'race' | 'season', id: string) => { - switch (type) { - case 'driver': - return `/drivers/${id}`; - case 'team': - return `/teams/${id}`; - case 'race': - return `/races/${id}`; - case 'season': - return `/leagues/${id}/sponsorships`; - default: - return '#'; - } - }; + const queryResult = await pageQuery.execute(searchParams); + + if (queryResult.isErr()) { + // Handle error - redirect or show error page + throw new Error('Failed to load sponsorship requests'); + } + + const viewData = viewDataBuilder.build(queryResult.unwrap()); return ( -
- - - {/* Header */} -
-
- -
-
-

Sponsorship Requests

-

- Manage sponsorship requests for your profile, teams, and leagues -

-
- {totalRequests > 0 && ( -
- {totalRequests} pending -
- )} -
- - {data.length === 0 ? ( - -
-
- -
-

No Pending Requests

-

- You don't have any pending sponsorship requests at the moment. -

-

- Sponsors can apply to sponsor your profile, teams, or leagues you manage. -

-
-
- ) : ( -
- {data.map((section) => { - const Icon = getEntityIcon(section.entityType); - const entityLink = getEntityLink(section.entityType, section.entityId); - - return ( - - {/* Section Header */} -
-
-
- -
-
-

{section.entityName}

-

{section.entityType}

-
-
- - View {section.entityType === 'season' ? 'Sponsorships' : section.entityType} - - -
- - {/* Requests */} - -
- ); - })} -
- )} - - {/* Info Card */} - -
-
- -
-
-

How Sponsorships Work

-

- Sponsors can apply to sponsor your driver profile, teams you manage, or leagues you administer. - Review each request carefully - accepting will activate the sponsorship and the sponsor will be - charged. You'll receive the payment minus a 10% platform fee. -

-
-
-
-
+ ); -} \ No newline at end of file +} diff --git a/apps/website/templates/auth/ForgotPasswordTemplate.tsx b/apps/website/templates/auth/ForgotPasswordTemplate.tsx new file mode 100644 index 000000000..6741d5851 --- /dev/null +++ b/apps/website/templates/auth/ForgotPasswordTemplate.tsx @@ -0,0 +1,194 @@ +/** + * Forgot Password Template + * + * Pure presentation component that accepts ViewData only. + * No business logic, no state management. + */ + +'use client'; + +import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { + Mail, + ArrowLeft, + AlertCircle, + Flag, + Shield, + CheckCircle2, +} from 'lucide-react'; + +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import Heading from '@/components/ui/Heading'; +import { ForgotPasswordViewData } from '@/lib/builders/view-data/ForgotPasswordViewDataBuilder'; + +interface ForgotPasswordTemplateProps { + viewData: ForgotPasswordViewData; + formActions: { + setFormData: React.Dispatch>; + handleSubmit: (e: React.FormEvent) => Promise; + setShowSuccess: (show: boolean) => void; + }; + mutationState: { + isPending: boolean; + error: string | null; + }; +} + +export function ForgotPasswordTemplate({ viewData, formActions, mutationState }: ForgotPasswordTemplateProps) { + return ( +
+ {/* Background Pattern */} +
+
+
+
+ +
+ {/* Header */} +
+
+ +
+ Reset Password +

+ Enter your email and we will send you a reset link +

+
+ + + {/* Background accent */} +
+ + {!viewData.showSuccess ? ( + + {/* Email */} +
+ +
+ + formActions.setFormData({ email: e.target.value })} + error={!!viewData.formState.fields.email.error} + errorMessage={viewData.formState.fields.email.error} + placeholder="you@example.com" + disabled={mutationState.isPending} + className="pl-10" + autoComplete="email" + /> +
+
+ + {/* Error Message */} + {mutationState.error && ( + + +

{mutationState.error}

+
+ )} + + {/* Submit Button */} + + + {/* Back to Login */} +
+ + + Back to Login + +
+ + ) : ( + +
+ +
+

{viewData.successMessage}

+ {viewData.magicLink && ( +
+

Development Mode - Magic Link:

+
+ + {viewData.magicLink} + +
+

+ In production, this would be sent via email +

+
+ )} +
+
+ + +
+ )} + + + {/* Trust Indicators */} +
+
+ + Secure reset process +
+
+ + 15 minute expiration +
+
+ + {/* Footer */} +

+ Need help?{' '} + + Contact support + +

+
+
+ ); +} diff --git a/apps/website/templates/auth/LoginTemplate.tsx b/apps/website/templates/auth/LoginTemplate.tsx new file mode 100644 index 000000000..7d1daa487 --- /dev/null +++ b/apps/website/templates/auth/LoginTemplate.tsx @@ -0,0 +1,299 @@ +/** + * Login Template + * + * Pure presentation component that accepts ViewData only. + * No business logic, no state management. + */ + +'use client'; + +import Link from 'next/link'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Mail, + Lock, + Eye, + EyeOff, + LogIn, + AlertCircle, + Flag, + Shield, +} from 'lucide-react'; + +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import Heading from '@/components/ui/Heading'; +import { EnhancedFormError } from '@/components/errors/EnhancedFormError'; +import UserRolesPreview from '@/components/auth/UserRolesPreview'; +import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup'; +import { LoginViewData, FormState } from '@/lib/builders/view-data/LoginViewDataBuilder'; + +interface LoginTemplateProps { + viewData: LoginViewData; + formActions: { + handleChange: (e: React.ChangeEvent) => void; + handleSubmit: (e: React.FormEvent) => Promise; + setFormState: React.Dispatch>; + setShowPassword: (show: boolean) => void; + setShowErrorDetails: (show: boolean) => void; + }; + mutationState: { + isPending: boolean; + error: string | null; + }; +} + +export function LoginTemplate({ viewData, formActions, mutationState }: LoginTemplateProps) { + return ( +
+ {/* Background Pattern */} +
+
+
+
+ + {/* Left Side - Info Panel (Hidden on mobile) */} +
+
+ {/* Logo */} +
+
+ +
+ GridPilot +
+ + + Your Sim Racing Infrastructure + + +

+ Manage leagues, track performance, join teams, and compete with drivers worldwide. One account, multiple roles. +

+ + {/* Role Cards */} + + + {/* Workflow Mockup */} + + + {/* Trust Indicators */} +
+
+ + Secure login +
+
+ iRacing verified +
+
+
+
+ + {/* Right Side - Login Form */} +
+
+ {/* Mobile Logo/Header */} +
+
+ +
+ Welcome Back +

+ Sign in to continue to GridPilot +

+
+ + {/* Desktop Header */} +
+ Welcome Back +

+ Sign in to access your racing dashboard +

+
+ + + {/* Background accent */} +
+ +
+ {/* Email */} +
+ +
+ + +
+
+ + {/* Password */} +
+
+ + + Forgot password? + +
+
+ + + +
+
+ + {/* Remember Me */} +
+ +
+ + {/* Insufficient Permissions Message */} + + {viewData.hasInsufficientPermissions && ( + +
+ +
+ Insufficient Permissions +

+ You don't have permission to access that page. Please log in with an account that has the required role. +

+
+
+
+ )} +
+ + {/* Enhanced Error Display */} + + {viewData.submitError && ( + { + formActions.setFormState((prev: FormState) => ({ ...prev, submitError: undefined })); + }} + showDeveloperDetails={viewData.showErrorDetails} + /> + )} + + + {/* Submit Button */} + +
+ + {/* Divider */} +
+
+
+
+
+ or continue with +
+
+ + {/* Sign Up Link */} +

+ Don't have an account?{''} + + Create one + +

+ + + {/* Name Immutability Notice */} +
+
+ +
+ Note: Your display name cannot be changed after signup. Please ensure it's correct when creating your account. +
+
+
+ + {/* Footer */} +

+ By signing in, you agree to our{''} + Terms of Service + {''}and{''} + Privacy Policy +

+ + {/* Mobile Role Info */} + +
+
+
+ ); +} diff --git a/apps/website/templates/auth/ResetPasswordTemplate.tsx b/apps/website/templates/auth/ResetPasswordTemplate.tsx new file mode 100644 index 000000000..8b3663128 --- /dev/null +++ b/apps/website/templates/auth/ResetPasswordTemplate.tsx @@ -0,0 +1,231 @@ +/** + * Reset Password Template + * + * Pure presentation component that accepts ViewData only. + * No business logic, no state management. + */ + +'use client'; + +import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { + Lock, + Eye, + EyeOff, + AlertCircle, + Flag, + Shield, + CheckCircle2, + ArrowLeft, +} from 'lucide-react'; + +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import Heading from '@/components/ui/Heading'; +import { ResetPasswordViewData } from '@/lib/builders/view-data/ResetPasswordViewDataBuilder'; + +interface ResetPasswordTemplateProps extends ResetPasswordViewData { + formActions: { + setFormData: React.Dispatch>; + handleSubmit: (e: React.FormEvent) => Promise; + setShowSuccess: (show: boolean) => void; + setShowPassword: (show: boolean) => void; + setShowConfirmPassword: (show: boolean) => void; + }; + uiState: { + showPassword: boolean; + showConfirmPassword: boolean; + }; + mutationState: { + isPending: boolean; + error: string | null; + }; +} + +export function ResetPasswordTemplate(props: ResetPasswordTemplateProps) { + const { formActions, uiState, mutationState, ...viewData } = props; + + return ( +
+ {/* Background Pattern */} +
+
+
+
+ +
+ {/* Header */} +
+
+ +
+ Reset Password +

+ Create a new secure password for your account +

+
+ + + {/* Background accent */} +
+ + {!viewData.showSuccess ? ( +
+ {/* New Password */} +
+ +
+ + formActions.setFormData(prev => ({ ...prev, newPassword: e.target.value }))} + error={!!viewData.formState.fields.newPassword.error} + errorMessage={viewData.formState.fields.newPassword.error} + placeholder="••••••••" + disabled={mutationState.isPending} + className="pl-10 pr-10" + autoComplete="new-password" + /> + +
+
+ + {/* Confirm Password */} +
+ +
+ + formActions.setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))} + error={!!viewData.formState.fields.confirmPassword.error} + errorMessage={viewData.formState.fields.confirmPassword.error} + placeholder="••••••••" + disabled={mutationState.isPending} + className="pl-10 pr-10" + autoComplete="new-password" + /> + +
+
+ + {/* Error Message */} + {mutationState.error && ( + + +

{mutationState.error}

+
+ )} + + {/* Submit Button */} + + + {/* Back to Login */} +
+ + + Back to Login + +
+
+ ) : ( + +
+ +
+

{viewData.successMessage}

+

+ Your password has been successfully reset +

+
+
+ + +
+ )} + + + {/* Trust Indicators */} +
+
+ + Secure password reset +
+
+ + Encrypted transmission +
+
+ + {/* Footer */} +

+ Need help?{' '} + + Contact support + +

+
+
+ ); +} diff --git a/apps/website/templates/auth/SignupTemplate.tsx b/apps/website/templates/auth/SignupTemplate.tsx new file mode 100644 index 000000000..d098ddffd --- /dev/null +++ b/apps/website/templates/auth/SignupTemplate.tsx @@ -0,0 +1,454 @@ +/** + * Signup Template + * + * Pure presentation component that accepts ViewData only. + * No business logic, no state management. + */ + +'use client'; + +import Link from 'next/link'; +import { motion } from 'framer-motion'; +import { + Mail, + Lock, + Eye, + EyeOff, + UserPlus, + AlertCircle, + Flag, + User, + Check, + X, + Car, + Users, + Trophy, + Shield, + Sparkles, +} from 'lucide-react'; + +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import Input from '@/components/ui/Input'; +import Heading from '@/components/ui/Heading'; +import { SignupViewData } from '@/lib/builders/view-data/SignupViewDataBuilder'; +import { checkPasswordStrength } from '@/lib/utils/validation'; + +interface SignupTemplateProps { + viewData: SignupViewData; + formActions: { + setFormData: React.Dispatch>; + handleSubmit: (e: React.FormEvent) => Promise; + setShowPassword: (show: boolean) => void; + setShowConfirmPassword: (show: boolean) => void; + }; + uiState: { + showPassword: boolean; + showConfirmPassword: boolean; + }; + mutationState: { + isPending: boolean; + error: string | null; + }; +} + +const USER_ROLES = [ + { + icon: Car, + title: 'Driver', + description: 'Race, track stats, join teams', + color: 'primary-blue', + }, + { + icon: Trophy, + title: 'League Admin', + description: 'Organize leagues and events', + color: 'performance-green', + }, + { + icon: Users, + title: 'Team Manager', + description: 'Manage team and drivers', + color: 'purple-400', + }, +]; + +const FEATURES = [ + 'Track your racing statistics and progress', + 'Join or create competitive leagues', + 'Build or join racing teams', + 'Connect your iRacing account', + 'Compete in organized events', + 'Access detailed performance analytics', +]; + +export function SignupTemplate({ viewData, formActions, uiState, mutationState }: SignupTemplateProps) { + const passwordStrength = checkPasswordStrength(viewData.formState.fields.password.value); + + const passwordRequirements = [ + { met: viewData.formState.fields.password.value.length >= 8, label: 'At least 8 characters' }, + { met: /[a-z]/.test(viewData.formState.fields.password.value) && /[A-Z]/.test(viewData.formState.fields.password.value), label: 'Upper and lowercase letters' }, + { met: /\d/.test(viewData.formState.fields.password.value), label: 'At least one number' }, + { met: /[^a-zA-Z\d]/.test(viewData.formState.fields.password.value), label: 'At least one special character' }, + ]; + + return ( +
+ {/* Background Pattern */} +
+
+
+
+ + {/* Left Side - Info Panel (Hidden on mobile) */} +
+
+ {/* Logo */} +
+
+ +
+ GridPilot +
+ + + Start Your Racing Journey + + +

+ Join thousands of sim racers. One account gives you access to all roles - race as a driver, organize leagues, or manage teams. +

+ + {/* Role Cards */} +
+ {USER_ROLES.map((role, index) => ( + +
+ +
+
+

{role.title}

+

{role.description}

+
+
+ ))} +
+ + {/* Features List */} +
+
+ + What you'll get +
+
    + {FEATURES.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+ + {/* Trust Indicators */} +
+
+ + Secure signup +
+
+ iRacing integration +
+
+
+
+ + {/* Right Side - Signup Form */} +
+
+ {/* Mobile Logo/Header */} +
+
+ +
+ Join GridPilot +

+ Create your account and start racing +

+
+ + {/* Desktop Header */} +
+ Create Account +

+ Get started with your free account +

+
+ + + {/* Background accent */} +
+ +
+ {/* First Name */} +
+ +
+ + formActions.setFormData(prev => ({ ...prev, firstName: e.target.value }))} + error={!!viewData.formState.fields.firstName.error} + errorMessage={viewData.formState.fields.firstName.error} + placeholder="John" + disabled={mutationState.isPending} + className="pl-10" + autoComplete="given-name" + /> +
+
+ + {/* Last Name */} +
+ +
+ + formActions.setFormData(prev => ({ ...prev, lastName: e.target.value }))} + error={!!viewData.formState.fields.lastName.error} + errorMessage={viewData.formState.fields.lastName.error} + placeholder="Smith" + disabled={mutationState.isPending} + className="pl-10" + autoComplete="family-name" + /> +
+

Your name will be used as-is and cannot be changed later

+
+ + {/* Name Immutability Warning */} +
+ +
+ Important: Your name cannot be changed after signup. Please ensure it's correct. +
+
+ + {/* Email */} +
+ +
+ + formActions.setFormData(prev => ({ ...prev, email: e.target.value }))} + error={!!viewData.formState.fields.email.error} + errorMessage={viewData.formState.fields.email.error} + placeholder="you@example.com" + disabled={mutationState.isPending} + className="pl-10" + autoComplete="email" + /> +
+
+ + {/* Password */} +
+ +
+ + formActions.setFormData(prev => ({ ...prev, password: e.target.value }))} + error={!!viewData.formState.fields.password.error} + errorMessage={viewData.formState.fields.password.error} + placeholder="••••••••" + disabled={mutationState.isPending} + className="pl-10 pr-10" + autoComplete="new-password" + /> + +
+ + {/* Password Strength */} + {viewData.formState.fields.password.value && ( +
+
+
+ +
+ + {passwordStrength.label} + +
+
+ {passwordRequirements.map((req, index) => ( +
+ {req.met ? ( + + ) : ( + + )} + + {req.label} + +
+ ))} +
+
+ )} +
+ + {/* Confirm Password */} +
+ +
+ + formActions.setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))} + error={!!viewData.formState.fields.confirmPassword.error} + errorMessage={viewData.formState.fields.confirmPassword.error} + placeholder="••••••••" + disabled={mutationState.isPending} + className="pl-10 pr-10" + autoComplete="new-password" + /> + +
+ {viewData.formState.fields.confirmPassword.value && viewData.formState.fields.password.value === viewData.formState.fields.confirmPassword.value && ( +

+ Passwords match +

+ )} +
+ + {/* Submit Button */} + +
+ + {/* Divider */} +
+
+
+
+
+ or continue with +
+
+ + {/* Login Link */} +

+ Already have an account?{' '} + + Sign in + +

+ + + {/* Footer */} +

+ By creating an account, you agree to our{' '} + Terms of Service + {' '}and{' '} + Privacy Policy +

+ + {/* Mobile Role Info */} +
+

One account for all roles

+
+ {USER_ROLES.map((role) => ( +
+
+ +
+ {role.title} +
+ ))} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/tsconfig.profile.json b/apps/website/tsconfig.profile.json new file mode 100644 index 000000000..3a67ccd5e --- /dev/null +++ b/apps/website/tsconfig.profile.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "app/profile/**/*.ts", + "app/profile/**/*.tsx", + "lib/page-queries/page-queries/ProfilePageQuery.ts", + "lib/page-queries/page-queries/SponsorshipRequestsPageQuery.ts", + "lib/builders/view-data/ProfileViewDataBuilder.ts", + "lib/builders/view-data/SponsorshipRequestsViewDataBuilder.ts", + "lib/view-data/ProfileViewData.ts", + "lib/view-data/SponsorshipRequestsViewData.ts", + "lib/services/drivers/DriverProfileService.ts", + "lib/mutations/drivers/UpdateDriverProfileMutation.ts", + "lib/mutations/sponsors/AcceptSponsorshipRequestMutation.ts", + "lib/mutations/sponsors/RejectSponsorshipRequestMutation.ts", + "lib/services/sponsors/SponsorshipRequestsService.ts" + ] +} diff --git a/apps/website/ui/StepIndicator.tsx b/apps/website/ui/StepIndicator.tsx new file mode 100644 index 000000000..469292342 --- /dev/null +++ b/apps/website/ui/StepIndicator.tsx @@ -0,0 +1,65 @@ +import { User, Camera, Check } from 'lucide-react'; + +interface Step { + id: number; + label: string; + icon: React.ElementType; +} + +interface StepIndicatorProps { + currentStep: number; + steps?: Step[]; +} + +const DEFAULT_STEPS: Step[] = [ + { id: 1, label: 'Personal', icon: User }, + { id: 2, label: 'Avatar', icon: Camera }, +]; + +export function StepIndicator({ currentStep, steps = DEFAULT_STEPS }: StepIndicatorProps) { + return ( +
+ {steps.map((step, index) => { + const Icon = step.icon; + const isCompleted = step.id < currentStep; + const isCurrent = step.id === currentStep; + + return ( +
+
+
+ {isCompleted ? ( + + ) : ( + + )} +
+ + {step.label} + +
+ {index < steps.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5d1c34186..5915404f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -301,20 +301,26 @@ "eslint-config-next": "15.5.7", "eslint-import-resolver-typescript": "2.7.1", "eslint-plugin-boundaries": "^5.3.1", + "eslint-plugin-gridpilot-rules": "file:eslint-rules", "eslint-plugin-import": "^2.32.0", "eslint-plugin-unused-imports": "^3.0.0", - "eslint-plugin-website-guardrails": "file:./eslint-guardrails", "typescript": "^5.6.0" } }, "apps/website/eslint-guardrails": { "name": "eslint-plugin-website-guardrails", "version": "1.0.0", - "dev": true, + "extraneous": true, "peerDependencies": { "eslint": ">=8.0.0" } }, + "apps/website/eslint-rules": { + "name": "eslint-plugin-gridpilot-rules", + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, "apps/website/node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -8503,6 +8509,10 @@ "eslint": ">=6.0.0" } }, + "node_modules/eslint-plugin-gridpilot-rules": { + "resolved": "apps/website/eslint-rules", + "link": true + }, "node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", @@ -8791,10 +8801,6 @@ } } }, - "node_modules/eslint-plugin-website-guardrails": { - "resolved": "apps/website/eslint-guardrails", - "link": true - }, "node_modules/eslint-rule-composer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz", diff --git a/vitest.website.config.ts b/vitest.website.config.ts index fc7308591..4b25d79d0 100644 --- a/vitest.website.config.ts +++ b/vitest.website.config.ts @@ -11,6 +11,8 @@ export default defineConfig({ 'apps/website/lib/gateways/**/*.test.ts', 'apps/website/lib/blockers/**/*.test.ts', 'apps/website/lib/auth/**/*.test.ts', + 'apps/website/lib/services/**/*.test.ts', + 'apps/website/lib/adapters/**/*.test.ts', 'apps/website/tests/guardrails/**/*.test.ts', ], exclude: ['node_modules/**', 'apps/website/.next/**', 'dist/**'],