diff --git a/apps/website/.eslintrc.json b/apps/website/.eslintrc.json index 02c1055cf..ea8181fec 100644 --- a/apps/website/.eslintrc.json +++ b/apps/website/.eslintrc.json @@ -29,13 +29,29 @@ }, { "files": [ - "lib/presenters/*.ts", - "lib/presenters/*.tsx" + "lib/builders/view-models/*.ts", + "lib/builders/view-models/*.tsx" ], "rules": { - "gridpilot-rules/presenter-contract": "error", - "gridpilot-rules/presenter-purity": "error", - "gridpilot-rules/filename-presenter-match": "error" + "gridpilot-rules/view-model-builder-contract": "error" + } + }, + { + "files": [ + "lib/builders/view-data/*.ts", + "lib/builders/view-data/*.tsx" + ], + "rules": { + "gridpilot-rules/view-data-builder-contract": "error" + } + }, + { + "files": [ + "lib/mutations/**/*.ts" + ], + "rules": { + "gridpilot-rules/mutation-contract": "error", + "gridpilot-rules/filename-service-match": "error" } }, { @@ -104,12 +120,20 @@ "gridpilot-rules/page-query-return-type": "error" } }, + { + "files": [ + "templates/**/*.ts", + "templates/**/*.tsx" + ], + "rules": { + "gridpilot-rules/view-data-location": "error" + } + }, { "files": [ "lib/services/**/*.ts" ], "rules": { - "gridpilot-rules/services-must-be-marked": "error", "gridpilot-rules/services-no-external-api": "error", "gridpilot-rules/services-must-be-pure": "error", "gridpilot-rules/filename-service-match": "error" @@ -117,11 +141,13 @@ }, { "files": [ - "app/**/*.tsx" + "app/**/*.tsx", + "app/**/*.ts" ], "rules": { "gridpilot-rules/client-only-no-server-code": "error", - "gridpilot-rules/client-only-must-have-directive": "error" + "gridpilot-rules/client-only-must-have-directive": "error", + "gridpilot-rules/server-actions-must-use-mutations": "error" } }, { diff --git a/apps/website/app/admin/AdminDashboardClient.tsx b/apps/website/app/admin/AdminDashboardClient.tsx new file mode 100644 index 000000000..29bd2bc25 --- /dev/null +++ b/apps/website/app/admin/AdminDashboardClient.tsx @@ -0,0 +1,58 @@ +'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 new file mode 100644 index 000000000..49ee195c1 --- /dev/null +++ b/apps/website/app/admin/AdminUsersClient.tsx @@ -0,0 +1,115 @@ +'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 new file mode 100644 index 000000000..017a62885 --- /dev/null +++ b/apps/website/app/admin/actions.ts @@ -0,0 +1,45 @@ +'use server'; + +import { UpdateUserStatusMutation } from '@/lib/mutations/admin/UpdateUserStatusMutation'; +import { DeleteUserMutation } from '@/lib/mutations/admin/DeleteUserMutation'; +import { revalidatePath } from 'next/cache'; + +/** + * Server actions for admin operations + * + * All write operations must enter through server actions. + * Actions are thin wrappers that handle framework concerns (revalidation). + * Business logic is handled by Mutations. + */ + +/** + * 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); + throw new Error('Failed to update user status'); + } +} + +/** + * 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); + throw new Error('Failed to delete user'); + } +} \ No newline at end of file diff --git a/apps/website/app/admin/layout.tsx b/apps/website/app/admin/layout.tsx index 1621af2d8..5c1bf47c9 100644 --- a/apps/website/app/admin/layout.tsx +++ b/apps/website/app/admin/layout.tsx @@ -12,15 +12,11 @@ interface AdminLayoutProps { * Uses RouteGuard to enforce access control server-side. */ export default async function AdminLayout({ children }: AdminLayoutProps) { - console.log('[ADMIN LAYOUT] ========== ADMIN LAYOUT CALLED =========='); const headerStore = await headers(); const pathname = headerStore.get('x-pathname') || '/'; - console.log('[ADMIN LAYOUT] Pathname:', pathname); const guard = createRouteGuard(); - console.log('[ADMIN LAYOUT] About to call guard.enforce'); await guard.enforce({ pathname }); - console.log('[ADMIN LAYOUT] guard.enforce completed successfully'); return (
diff --git a/apps/website/app/admin/page.tsx b/apps/website/app/admin/page.tsx index c42a4e755..90cc7571e 100644 --- a/apps/website/app/admin/page.tsx +++ b/apps/website/app/admin/page.tsx @@ -1,10 +1,5 @@ -import { AdminLayout } from '@/components/admin/AdminLayout'; -import { AdminDashboardPage } from '@/components/admin/AdminDashboardPage'; +import { AdminDashboardClient } from './AdminDashboardClient'; export default function AdminPage() { - return ( - - - - ); + 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 f2cfe80e6..2d7427186 100644 --- a/apps/website/app/admin/users/page.tsx +++ b/apps/website/app/admin/users/page.tsx @@ -1,10 +1,5 @@ -import { AdminLayout } from '@/components/admin/AdminLayout'; -import { AdminUsersPage } from '@/components/admin/AdminUsersPage'; +import { AdminUsersClient } from '../AdminUsersClient'; export default function AdminUsers() { - return ( - - - - ); + return ; } \ No newline at end of file diff --git a/apps/website/eslint-rules/index.js b/apps/website/eslint-rules/index.js index 409e554b4..91c583189 100644 --- a/apps/website/eslint-rules/index.js +++ b/apps/website/eslint-rules/index.js @@ -26,6 +26,9 @@ const modelTaxonomyRules = require('./model-taxonomy-rules'); const filenameRules = require('./filename-rules'); const componentNoDataManipulation = require('./component-no-data-manipulation'); const presenterPurity = require('./presenter-purity'); +const mutationContract = require('./mutation-contract'); +const serverActionsMustUseMutations = require('./server-actions-must-use-mutations'); +const viewDataLocation = require('./view-data-location'); module.exports = { rules: { @@ -90,6 +93,15 @@ module.exports = { // Component Data Manipulation Rules 'component-no-data-manipulation': componentNoDataManipulation, + + // Mutation Rules + 'mutation-contract': mutationContract, + + // Server Actions Rules + 'server-actions-must-use-mutations': serverActionsMustUseMutations, + + // View Data Rules + 'view-data-location': viewDataLocation, }, // Configurations for different use cases @@ -155,6 +167,15 @@ module.exports = { // Filename 'gridpilot-rules/filename-presenter-match': 'error', 'gridpilot-rules/filename-service-match': 'error', + + // Mutations + 'gridpilot-rules/mutation-contract': 'error', + + // Server Actions + 'gridpilot-rules/server-actions-must-use-mutations': 'error', + + // View Data + 'gridpilot-rules/view-data-location': 'error', }, }, diff --git a/apps/website/eslint-rules/mutation-contract.js b/apps/website/eslint-rules/mutation-contract.js new file mode 100644 index 000000000..01b200048 --- /dev/null +++ b/apps/website/eslint-rules/mutation-contract.js @@ -0,0 +1,132 @@ +/** + * ESLint Rule: Mutation Contract + * + * Ensures mutations implement the Mutation contract + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Ensure mutations implement the Mutation contract', + category: 'Mutation Contract', + recommended: true, + }, + messages: { + noMutationContract: 'Mutations must implement the Mutation interface from lib/contracts/mutations/Mutation.ts', + missingExecute: 'Mutations must have an execute method that takes input and returns Promise', + wrongExecuteSignature: 'Execute method must have signature: execute(input: TInput): Promise', + }, + schema: [], + }, + + create(context) { + const filename = context.getFilename(); + + // Only apply to mutation files + if (!filename.includes('/lib/mutations/') || !filename.endsWith('.ts')) { + return {}; + } + + let hasMutationImport = false; + let hasExecuteMethod = false; + let implementsMutation = false; + let executeMethodNode = null; + + return { + // Check for Mutation import + ImportDeclaration(node) { + if (node.source.value === '@/lib/contracts/mutations/Mutation') { + hasMutationImport = true; + } + }, + + // Check for implements clause + ClassDeclaration(node) { + if (node.implements) { + node.implements.forEach(impl => { + if (impl.type === 'Identifier' && impl.name === 'Mutation') { + implementsMutation = true; + } + if (impl.type === 'TSExpressionWithTypeArguments' && + impl.expression.type === 'Identifier' && + impl.expression.name === 'Mutation') { + implementsMutation = true; + } + }); + } + }, + + // Check for execute method + MethodDefinition(node) { + if (node.key.type === 'Identifier' && node.key.name === 'execute') { + hasExecuteMethod = true; + executeMethodNode = node; + } + }, + + 'Program:exit'() { + // Skip if file doesn't look like a mutation + const isMutationFile = filename.includes('/lib/mutations/') && + filename.endsWith('.ts') && + !filename.endsWith('.test.ts'); + + if (!isMutationFile) return; + + // Check if it's a class-based mutation + const hasClass = filename.includes('/lib/mutations/') && + !filename.endsWith('.test.ts'); + + if (hasClass && !hasExecuteMethod) { + if (executeMethodNode) { + context.report({ + node: executeMethodNode, + messageId: 'missingExecute', + }); + } else { + // Find the class node to report on + const sourceCode = context.getSourceCode(); + const classNode = sourceCode.ast.body.find(node => + node.type === 'ClassDeclaration' && + node.id && + node.id.name.endsWith('Mutation') + ); + if (classNode) { + context.report({ + node: classNode, + messageId: 'missingExecute', + }); + } + } + return; + } + + // Check for contract implementation (if it's a class) + if (hasClass && !implementsMutation && hasMutationImport) { + // Find the class node to report on + const sourceCode = context.getSourceCode(); + const classNode = sourceCode.ast.body.find(node => + node.type === 'ClassDeclaration' && + node.id && + node.id.name.endsWith('Mutation') + ); + if (classNode) { + context.report({ + node: classNode, + messageId: 'noMutationContract', + }); + } + return; + } + + // Check execute method signature + if (executeMethodNode && executeMethodNode.value.params.length === 0) { + context.report({ + node: executeMethodNode, + messageId: 'wrongExecuteSignature', + }); + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/page-query-rules.js b/apps/website/eslint-rules/page-query-rules.js index d7a558951..8299c52f5 100644 --- a/apps/website/eslint-rules/page-query-rules.js +++ b/apps/website/eslint-rules/page-query-rules.js @@ -75,10 +75,18 @@ module.exports = { ClassDeclaration(node) { const className = node.id?.name; if (className && className.endsWith('PageQuery')) { - if (!node.implements || - !node.implements.some(impl => - impl.expression.type === 'GenericIdentifier' && - impl.expression.name === 'PageQuery')) { + const hasPageQueryImpl = node.implements && node.implements.some(impl => { + // Handle different AST node types for generic interfaces + if (impl.expression.type === 'TSExpressionWithTypeArguments') { + return impl.expression.expression.name === 'PageQuery'; + } + if (impl.expression.type === 'Identifier') { + return impl.expression.name === 'PageQuery'; + } + return false; + }); + + if (!hasPageQueryImpl) { context.report({ node, messageId: 'message', diff --git a/apps/website/eslint-rules/page-query-use-builder.js b/apps/website/eslint-rules/page-query-use-builder.js new file mode 100644 index 000000000..14abfb77f --- /dev/null +++ b/apps/website/eslint-rules/page-query-use-builder.js @@ -0,0 +1,89 @@ +/** + * ESLint Rule: Page Query Must Use Builder + * + * Ensures page queries use builders to map their results + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Ensure page queries use builders to map their results', + category: 'Page Query', + recommended: true, + }, + messages: { + mustUseBuilder: 'Page queries must use Builders to map Page DTO to View Data/View Model. See apps/website/docs/architecture/write/BUILDERS.md', + }, + schema: [], + }, + + create(context) { + const filename = context.getFilename(); + + // Only apply to page query files + if (!filename.includes('/lib/page-queries/') || !filename.endsWith('.ts')) { + return {}; + } + + let hasBuilderImport = false; + let hasReturnStatement = false; + + return { + // Check imports for builder + ImportDeclaration(node) { + const importPath = node.source.value; + if (importPath.includes('/lib/builders/')) { + hasBuilderImport = true; + } + }, + + // Check for return statements + ReturnStatement(node) { + hasReturnStatement = true; + }, + + 'Program:exit'() { + // Skip if file doesn't look like a page query + const isPageQueryFile = filename.includes('/lib/page-queries/') && + filename.endsWith('.ts') && + !filename.endsWith('.test.ts') && + !filename.includes('/result/'); + + if (!isPageQueryFile) return; + + // Check if it's a class-based page query + const sourceCode = context.getSourceCode(); + const classNode = sourceCode.ast.body.find(node => + node.type === 'ClassDeclaration' && + node.id && + node.id.name.endsWith('PageQuery') + ); + + if (!classNode) return; + + // Check if the class has an execute method + const executeMethod = classNode.body.body.find(member => + member.type === 'MethodDefinition' && + member.key.type === 'Identifier' && + member.key.name === 'execute' + ); + + if (!executeMethod) return; + + // Check if the execute method uses a builder + // Look for builder usage in the method body + const methodBody = executeMethod.value.body; + if (!methodBody) return; + + // Check if there's a builder import + if (!hasBuilderImport) { + context.report({ + node: classNode, + messageId: 'mustUseBuilder', + }); + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/rsc-boundary-rules.js b/apps/website/eslint-rules/rsc-boundary-rules.js index fc5f79d83..32df410de 100644 --- a/apps/website/eslint-rules/rsc-boundary-rules.js +++ b/apps/website/eslint-rules/rsc-boundary-rules.js @@ -275,7 +275,16 @@ module.exports = { create(context) { return { FunctionDeclaration(node) { - if (!node.id.name.startsWith('assert') && + // Skip if this is the main component (default export or ends with Page/Template) + const filename = context.getFilename(); + const isMainComponent = + (node.parent && node.parent.type === 'ExportDefaultDeclaration') || + (node.id && node.id.name && (node.id.name.endsWith('Page') || node.id.name.endsWith('Template') || node.id.name.endsWith('Component'))); + + if (isMainComponent) return; + + // Only flag nested helper functions + if (!node.id.name.startsWith('assert') && !node.id.name.startsWith('invariant') && !isInComment(node)) { context.report({ @@ -285,9 +294,16 @@ module.exports = { } }, VariableDeclarator(node) { - if (node.init && + // Skip if this is the main component + const isMainComponent = + (node.parent && node.parent.parent && node.parent.parent.type === 'ExportDefaultDeclaration') || + (node.id && node.id.name && (node.id.name.endsWith('Page') || node.id.name.endsWith('Template') || node.id.name.endsWith('Component'))); + + if (isMainComponent) return; + + if (node.init && (node.init.type === 'FunctionExpression' || node.init.type === 'ArrowFunctionExpression') && - !node.id.name.startsWith('assert') && + !node.id.name.startsWith('assert') && !node.id.name.startsWith('invariant') && !isInComment(node)) { context.report({ diff --git a/apps/website/eslint-rules/server-actions-must-use-mutations.js b/apps/website/eslint-rules/server-actions-must-use-mutations.js new file mode 100644 index 000000000..271608e39 --- /dev/null +++ b/apps/website/eslint-rules/server-actions-must-use-mutations.js @@ -0,0 +1,130 @@ +/** + * ESLint Rule: Server Actions Must Use Mutations + * + * Ensures server actions use mutations instead of direct service calls + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Ensure server actions use mutations instead of direct service calls', + category: 'Server Actions', + recommended: true, + }, + messages: { + mustUseMutations: 'Server actions must use Mutations, not direct Service or API Client calls. See apps/website/docs/architecture/write/MUTATIONS.md', + noDirectService: 'Direct service calls in server actions are not allowed', + noMutationUsage: 'Server actions should instantiate and call mutations', + }, + schema: [], + }, + + create(context) { + const filename = context.getFilename(); + const isServerAction = filename.includes('/app/') && + (filename.endsWith('.ts') || filename.endsWith('.tsx')) && + !filename.endsWith('.test.ts') && + !filename.endsWith('.test.tsx'); + + if (!isServerAction) { + return {}; + } + + let hasUseServerDirective = false; + let hasServiceImport = false; + let hasApiClientImport = false; + let hasMutationImport = false; + const newExpressions = []; + const callExpressions = []; + + return { + // Check for 'use server' directive + ExpressionStatement(node) { + if (node.expression.type === 'Literal' && node.expression.value === 'use server') { + hasUseServerDirective = true; + } + }, + + // Check imports + ImportDeclaration(node) { + const importPath = node.source.value; + + if (importPath.includes('/lib/services/')) { + hasServiceImport = true; + } + if (importPath.includes('/lib/api/') || importPath.includes('/api/')) { + hasApiClientImport = true; + } + if (importPath.includes('/lib/mutations/')) { + hasMutationImport = true; + } + }, + + // Track all NewExpression (instantiations) + NewExpression(node) { + newExpressions.push(node); + }, + + // Track all CallExpression (method calls) + CallExpression(node) { + callExpressions.push(node); + }, + + 'Program:exit'() { + // Only check files with 'use server' directive + if (!hasUseServerDirective) return; + + // Check for direct service/API client instantiation + const hasDirectServiceInstantiation = newExpressions.some(node => { + if (node.callee.type === 'Identifier') { + const calleeName = node.callee.name; + return calleeName.endsWith('Service') || calleeName.endsWith('ApiClient') || calleeName.endsWith('Client'); + } + return false; + }); + + // Check for direct service method calls + const hasDirectServiceCall = callExpressions.some(node => { + if (node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier') { + const objName = node.callee.object.name; + return objName.endsWith('Service') || objName.endsWith('ApiClient'); + } + return false; + }); + + // Check if mutations are being used + const hasMutationUsage = newExpressions.some(node => { + if (node.callee.type === 'Identifier') { + return node.callee.name.endsWith('Mutation'); + } + return false; + }); + + // Report violations + if (hasDirectServiceInstantiation && !hasMutationUsage) { + context.report({ + node: null, + messageId: 'mustUseMutations', + }); + } + + if (hasDirectServiceCall) { + context.report({ + node: null, + messageId: 'noDirectService', + }); + } + + // If imports exist but no mutation usage + if ((hasServiceImport || hasApiClientImport) && !hasMutationImport) { + context.report({ + node: null, + messageId: 'mustUseMutations', + }); + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/template-purity-rules.js b/apps/website/eslint-rules/template-purity-rules.js index 8df02da73..1e9cd6034 100644 --- a/apps/website/eslint-rules/template-purity-rules.js +++ b/apps/website/eslint-rules/template-purity-rules.js @@ -140,11 +140,59 @@ module.exports = { }, }, create(context) { + // Helper to recursively check if type contains ViewData + function typeContainsViewData(typeNode) { + if (!typeNode) return false; + + // Check direct type name + if (typeNode.type === 'TSTypeReference' && + typeNode.typeName && + typeNode.typeName.name && + typeNode.typeName.name.includes('ViewData')) { + return true; + } + + // Check nested in object type + if (typeNode.type === 'TSTypeLiteral' && typeNode.members) { + for (const member of typeNode.members) { + if (member.type === 'TSPropertySignature' && + member.typeAnnotation && + typeContainsViewData(member.typeAnnotation.typeAnnotation)) { + return true; + } + } + } + + // Check union/intersection types + if (typeNode.type === 'TSUnionType' || typeNode.type === 'TSIntersectionType') { + return typeNode.types.some(t => typeContainsViewData(t)); + } + + return false; + } + return { FunctionDeclaration(node) { - if (node.params.length === 0 || - !node.params[0].typeAnnotation || - !node.params[0].typeAnnotation.typeAnnotation.type.includes('ViewData')) { + if (node.params.length === 0) { + context.report({ + node, + messageId: 'message', + }); + return; + } + + const firstParam = node.params[0]; + if (!firstParam.typeAnnotation || !firstParam.typeAnnotation.typeAnnotation) { + context.report({ + node, + messageId: 'message', + }); + return; + } + + const typeAnnotation = firstParam.typeAnnotation.typeAnnotation; + + if (!typeContainsViewData(typeAnnotation)) { context.report({ node, messageId: 'message', @@ -170,9 +218,24 @@ module.exports = { create(context) { return { ExportNamedDeclaration(node) { - if (node.declaration && - (node.declaration.type === 'FunctionDeclaration' || + if (node.declaration && + (node.declaration.type === 'FunctionDeclaration' || node.declaration.type === 'VariableDeclaration')) { + + // Get the function/variable name + const name = node.declaration.id?.name; + + // Allow the main template component (ends with Template) + if (name && name.endsWith('Template')) { + return; + } + + // Allow default exports + if (node.declaration.type === 'VariableDeclaration' && + node.declaration.declarations.some(d => d.id.type === 'Identifier' && d.id.name.endsWith('Template'))) { + return; + } + context.report({ node, messageId: 'message', diff --git a/apps/website/eslint-rules/view-data-location.js b/apps/website/eslint-rules/view-data-location.js new file mode 100644 index 000000000..5407ce5a2 --- /dev/null +++ b/apps/website/eslint-rules/view-data-location.js @@ -0,0 +1,50 @@ +/** + * ESLint Rule: ViewData Location + * + * Ensures ViewData types are in lib/view-data/, not templates/ + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Ensure ViewData types are in lib/view-data/, not templates/', + category: 'File Structure', + recommended: true, + }, + messages: { + wrongLocation: 'ViewData types must be in lib/view-data/, not templates/. See apps/website/docs/architecture/website/VIEW_DATA.md', + }, + schema: [], + }, + + create(context) { + const filename = context.getFilename(); + + return { + ImportDeclaration(node) { + const importPath = node.source.value; + + // Check for ViewData imports from templates + if (importPath.includes('/templates/') && + importPath.includes('ViewData')) { + context.report({ + node, + messageId: 'wrongLocation', + }); + } + }, + + // Also check if the file itself is a ViewData in wrong location + Program(node) { + if (filename.includes('/templates/') && + filename.endsWith('ViewData.ts')) { + context.report({ + node, + messageId: 'wrongLocation', + }); + } + }, + }; + }, +}; diff --git a/apps/website/lib/builders/view-data/AdminDashboardViewDataBuilder.ts b/apps/website/lib/builders/view-data/AdminDashboardViewDataBuilder.ts new file mode 100644 index 000000000..513662db4 --- /dev/null +++ b/apps/website/lib/builders/view-data/AdminDashboardViewDataBuilder.ts @@ -0,0 +1,26 @@ +import { DashboardStats } from '@/lib/api/admin/AdminApiClient'; +import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData'; + +/** + * AdminDashboardViewDataBuilder + * + * Server-side builder that transforms API DashboardStats DTO + * directly into ViewData for the AdminDashboardTemplate. + * + * Deterministic, side-effect free. + */ +export class AdminDashboardViewDataBuilder { + static build(apiStats: DashboardStats): AdminDashboardViewData { + return { + stats: { + totalUsers: apiStats.totalUsers, + activeUsers: apiStats.activeUsers, + suspendedUsers: apiStats.suspendedUsers, + deletedUsers: apiStats.deletedUsers, + systemAdmins: apiStats.systemAdmins, + recentLogins: apiStats.recentLogins, + newUsersToday: apiStats.newUsersToday, + }, + }; + } +} diff --git a/apps/website/lib/builders/view-models/AdminDashboardViewModelBuilder.ts b/apps/website/lib/builders/view-models/AdminDashboardViewModelBuilder.ts new file mode 100644 index 000000000..5c69156b3 --- /dev/null +++ b/apps/website/lib/builders/view-models/AdminDashboardViewModelBuilder.ts @@ -0,0 +1,15 @@ +'use client'; + +import { AdminDashboardPageDto } from '@/lib/page-queries/AdminDashboardPageQuery'; +import { AdminDashboardViewModel } from '@/lib/view-models/AdminDashboardViewModel'; + +/** + * AdminDashboardViewModelBuilder + * + * Transforms AdminDashboardPageDto into AdminDashboardViewModel + */ +export class AdminDashboardViewModelBuilder { + static build(dto: AdminDashboardPageDto): AdminDashboardViewModel { + return new AdminDashboardViewModel(dto); + } +} \ No newline at end of file diff --git a/apps/website/lib/contracts/builders/ViewDataBuilder.ts b/apps/website/lib/contracts/builders/ViewDataBuilder.ts new file mode 100644 index 000000000..b7b556804 --- /dev/null +++ b/apps/website/lib/contracts/builders/ViewDataBuilder.ts @@ -0,0 +1,25 @@ +/** + * ViewData Builder Contract + * + * Purpose: Transform ViewModels into ViewData for templates + * + * Rules: + * - Deterministic and side-effect free + * - No HTTP/API calls + * - Input: ViewModel + * - Output: ViewData (JSON-serializable) + * - Must be in lib/builders/view-data/ + * - Must be named *ViewDataBuilder + * - Must have 'use client' directive + * - Must implement static build() method + */ + +export interface ViewDataBuilder { + /** + * Transform ViewModel into ViewData + * + * @param viewModel - Client-side ViewModel + * @returns ViewData for template + */ + build(viewModel: TInput): TOutput; +} \ No newline at end of file diff --git a/apps/website/lib/contracts/builders/ViewModelBuilder.ts b/apps/website/lib/contracts/builders/ViewModelBuilder.ts new file mode 100644 index 000000000..fd9686838 --- /dev/null +++ b/apps/website/lib/contracts/builders/ViewModelBuilder.ts @@ -0,0 +1,25 @@ +/** + * ViewModel Builder Contract + * + * Purpose: Transform API Transport DTOs into ViewModels + * + * Rules: + * - Deterministic and side-effect free + * - No HTTP/API calls + * - Input: API Transport DTO + * - Output: ViewModel + * - Must be in lib/builders/view-models/ + * - Must be named *ViewModelBuilder + * - Must have 'use client' directive + * - Must implement static build() method + */ + +export interface ViewModelBuilder { + /** + * Transform DTO into ViewModel + * + * @param dto - API Transport DTO + * @returns ViewModel + */ + build(dto: TInput): TOutput; +} \ No newline at end of file diff --git a/apps/website/lib/contracts/mutations/Mutation.ts b/apps/website/lib/contracts/mutations/Mutation.ts new file mode 100644 index 000000000..4424d6c2a --- /dev/null +++ b/apps/website/lib/contracts/mutations/Mutation.ts @@ -0,0 +1,33 @@ +/** + * Mutation Contract + * + * Purpose: Framework-agnostic write operations + * + * Rules: + * - Orchestrates services for writes + * - No HTTP/API calls directly + * - No 'use client' directive + * - No 'use server' directive + * - Must be in lib/mutations/ + * - Must be named *Mutation + * - Can be called from Server Actions + * - Single responsibility: ONE operation per mutation + * + * Pattern: + * Server Action → Mutation → Service → API Client + * + * Design Principle: + * Each mutation does ONE thing. If you need multiple operations, + * create multiple mutation classes (e.g., UpdateUserStatusMutation, DeleteUserMutation). + * This follows the same pattern as Page Queries. + */ + +export interface Mutation { + /** + * Execute the mutation + * + * @param input - Mutation input + * @returns Output (optional) + */ + execute(input: TInput): Promise; +} \ No newline at end of file diff --git a/apps/website/lib/contracts/page-queries/PageQuery.ts b/apps/website/lib/contracts/page-queries/PageQuery.ts index 3e7c81dba..5fd499890 100644 --- a/apps/website/lib/contracts/page-queries/PageQuery.ts +++ b/apps/website/lib/contracts/page-queries/PageQuery.ts @@ -1,4 +1,4 @@ -import { PageQueryResult } from "@/lib/page-queries/page-query-result/PageQueryResult"; +import { PageQueryResult } from "./PageQueryResult"; /** diff --git a/apps/website/lib/page-queries/page-query-result/PageQueryResult.ts b/apps/website/lib/contracts/page-queries/PageQueryResult.ts similarity index 100% rename from apps/website/lib/page-queries/page-query-result/PageQueryResult.ts rename to apps/website/lib/contracts/page-queries/PageQueryResult.ts diff --git a/apps/website/lib/mutations/admin/DeleteUserMutation.ts b/apps/website/lib/mutations/admin/DeleteUserMutation.ts new file mode 100644 index 000000000..c9f6f00fe --- /dev/null +++ b/apps/website/lib/mutations/admin/DeleteUserMutation.ts @@ -0,0 +1,37 @@ +import { AdminApiClient } from '@/lib/api/admin/AdminApiClient'; +import { AdminService } from '@/lib/services/admin/AdminService'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { Mutation } from '@/lib/contracts/mutations/Mutation'; + +/** + * DeleteUserMutation + * + * Framework-agnostic mutation for deleting users. + * Called from Server Actions. + * + * Input: { userId: string } + * Output: void + * + * Pattern: Server Action → Mutation → Service → API Client + */ +export class DeleteUserMutation implements Mutation<{ userId: string }, void> { + private service: AdminService; + + constructor() { + // Manual DI for serverless + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + const apiClient = new AdminApiClient(baseUrl, errorReporter, logger); + this.service = new AdminService(apiClient); + } + + async execute(input: { userId: string }): Promise { + await this.service.deleteUser(input.userId); + } +} diff --git a/apps/website/lib/mutations/admin/UpdateUserStatusMutation.ts b/apps/website/lib/mutations/admin/UpdateUserStatusMutation.ts new file mode 100644 index 000000000..48dafd3a6 --- /dev/null +++ b/apps/website/lib/mutations/admin/UpdateUserStatusMutation.ts @@ -0,0 +1,37 @@ +import { AdminApiClient } from '@/lib/api/admin/AdminApiClient'; +import { AdminService } from '@/lib/services/admin/AdminService'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { Mutation } from '@/lib/contracts/mutations/Mutation'; + +/** + * UpdateUserStatusMutation + * + * Framework-agnostic mutation for updating user status. + * Called from Server Actions. + * + * Input: { userId: string; status: string } + * Output: void + * + * Pattern: Server Action → Mutation → Service → API Client + */ +export class UpdateUserStatusMutation implements Mutation<{ userId: string; status: string }, void> { + private service: AdminService; + + constructor() { + // Manual DI for serverless + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + const apiClient = new AdminApiClient(baseUrl, errorReporter, logger); + this.service = new AdminService(apiClient); + } + + async execute(input: { userId: string; status: string }): Promise { + await this.service.updateUserStatus(input.userId, input.status); + } +} diff --git a/apps/website/lib/page-queries/AdminDashboardPageQuery.ts b/apps/website/lib/page-queries/AdminDashboardPageQuery.ts new file mode 100644 index 000000000..96b1961fc --- /dev/null +++ b/apps/website/lib/page-queries/AdminDashboardPageQuery.ts @@ -0,0 +1,50 @@ +import { AdminApiClient } from '@/lib/api/admin/AdminApiClient'; +import { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; +import { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { AdminService } from '@/lib/services/admin/AdminService'; +import { AdminDashboardViewDataBuilder } from '@/lib/builders/view-data/AdminDashboardViewDataBuilder'; +import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData'; + +/** + * AdminDashboardPageQuery + * + * Server-side composition for admin dashboard page. + * Fetches dashboard statistics from API and transforms to View Data using builders. + * + * Follows Clean Architecture: DTOs never leak into application code. + */ +export class AdminDashboardPageQuery implements PageQuery { + async execute(): Promise> { + try { + // Create required dependencies + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: false, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + const apiClient = new AdminApiClient(baseUrl, errorReporter, logger); + const adminService = new AdminService(apiClient); + + // Fetch dashboard stats (API DTO) + const apiDto = await adminService.getDashboardStats(); + + // Transform API DTO to View Data using builder + const viewData = AdminDashboardViewDataBuilder.build(apiDto); + + return { status: 'ok', dto: viewData }; + } catch (error) { + console.error('AdminDashboardPageQuery failed:', error); + + if (error instanceof Error && (error.message.includes('403') || error.message.includes('401'))) { + return { status: 'notFound' }; + } + + return { status: 'error', errorId: 'admin_dashboard_fetch_failed' }; + } + } +} diff --git a/apps/website/lib/page-queries/AdminUsersPageQuery.ts b/apps/website/lib/page-queries/AdminUsersPageQuery.ts new file mode 100644 index 000000000..5a1e8acab --- /dev/null +++ b/apps/website/lib/page-queries/AdminUsersPageQuery.ts @@ -0,0 +1,93 @@ +import { AdminApiClient } from '@/lib/api/admin/AdminApiClient'; +import { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { AdminService } from '@/lib/services/admin/AdminService'; + +export interface AdminUsersPageDto { + users: Array<{ + id: string; + email: string; + displayName: string; + roles: string[]; + status: string; + isSystemAdmin: boolean; + createdAt: string; + updatedAt: string; + lastLoginAt?: string; + primaryDriverId?: string; + }>; + total: number; + page: number; + limit: number; + totalPages: number; +} + +/** + * AdminUsersPageQuery + * + * Server-side composition for admin users page. + * Fetches user list from API with filtering and assembles Page DTO. + */ +export class AdminUsersPageQuery { + async execute(query: { + search?: string; + role?: string; + status?: string; + page?: number; + limit?: number; + }): Promise> { + try { + // Create required dependencies + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: false, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + const apiClient = new AdminApiClient(baseUrl, errorReporter, logger); + const adminService = new AdminService(apiClient); + + // Fetch user list via service + const apiDto = await adminService.listUsers({ + search: query.search, + role: query.role, + status: query.status, + page: query.page || 1, + limit: query.limit || 50, + }); + + // Assemble Page DTO (raw values only) + const pageDto: AdminUsersPageDto = { + users: apiDto.users.map(user => ({ + id: user.id, + email: user.email, + displayName: user.displayName, + roles: user.roles, + status: user.status, + isSystemAdmin: user.isSystemAdmin, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + lastLoginAt: user.lastLoginAt?.toISOString(), + primaryDriverId: user.primaryDriverId, + })), + total: apiDto.total, + page: apiDto.page, + limit: apiDto.limit, + totalPages: apiDto.totalPages, + }; + + return { status: 'ok', dto: pageDto }; + } catch (error) { + console.error('AdminUsersPageQuery failed:', error); + + if (error instanceof Error && (error.message.includes('403') || error.message.includes('401'))) { + return { status: 'notFound' }; + } + + return { status: 'error', errorId: 'admin_users_fetch_failed' }; + } + } +} \ No newline at end of file diff --git a/apps/website/lib/view-data/AdminDashboardViewData.ts b/apps/website/lib/view-data/AdminDashboardViewData.ts new file mode 100644 index 000000000..1fca33260 --- /dev/null +++ b/apps/website/lib/view-data/AdminDashboardViewData.ts @@ -0,0 +1,17 @@ +/** + * AdminDashboardViewData + * + * ViewData for AdminDashboardTemplate. + * Template-ready data structure with only primitives. + */ +export interface AdminDashboardViewData { + stats: { + totalUsers: number; + activeUsers: number; + suspendedUsers: number; + deletedUsers: number; + systemAdmins: number; + recentLogins: number; + newUsersToday: number; + }; +} \ No newline at end of file diff --git a/apps/website/lib/view-data/LeagueDetailViewData.ts b/apps/website/lib/view-data/LeagueDetailViewData.ts index ff47312ef..380610d06 100644 --- a/apps/website/lib/view-data/LeagueDetailViewData.ts +++ b/apps/website/lib/view-data/LeagueDetailViewData.ts @@ -45,6 +45,24 @@ export interface DriverSummaryData { profileUrl: string; } +export interface SponsorMetric { + icon: any; // React component (lucide-react icon) + label: string; + value: string | number; + color?: string; + trend?: { + value: number; + isPositive: boolean; + }; +} + +export interface SponsorshipSlot { + tier: 'main' | 'secondary'; + available: boolean; + price: number; + benefits: string[]; +} + export interface LeagueDetailViewData { // Basic info leagueId: string; @@ -79,5 +97,7 @@ export interface LeagueDetailViewData { mainSponsorPrice: number; secondaryPrice: number; totalImpressions: number; + metrics: SponsorMetric[]; + slots: SponsorshipSlot[]; } | null; } \ No newline at end of file diff --git a/apps/website/lib/view-data/LeagueStandingsViewData.ts b/apps/website/lib/view-data/LeagueStandingsViewData.ts index ff64e10d5..30f4f53c9 100644 --- a/apps/website/lib/view-data/LeagueStandingsViewData.ts +++ b/apps/website/lib/view-data/LeagueStandingsViewData.ts @@ -6,10 +6,13 @@ export interface StandingEntryData { driverId: string; position: number; - points: number; - wins: number; - podiums: number; - races: number; + totalPoints: number; + racesFinished: number; + racesStarted: number; + avgFinish: number | null; + penaltyPoints: number; + bonusPoints: number; + teamName?: string; } export interface DriverData { diff --git a/apps/website/lib/view-data/TeamDetailViewData.ts b/apps/website/lib/view-data/TeamDetailViewData.ts index db0cae77b..a5f028d4a 100644 --- a/apps/website/lib/view-data/TeamDetailViewData.ts +++ b/apps/website/lib/view-data/TeamDetailViewData.ts @@ -3,6 +3,17 @@ * Contains only raw serializable data, no methods or computed properties */ +export interface SponsorMetric { + icon: any; // React component (lucide-react icon) + label: string; + value: string | number; + color?: string; + trend?: { + value: number; + isPositive: boolean; + }; +} + export interface TeamDetailData { id: string; name: string; @@ -32,9 +43,17 @@ export interface TeamMemberData { avatarUrl: string; } +export interface TeamTab { + id: 'overview' | 'roster' | 'standings' | 'admin'; + label: string; + visible: boolean; +} + export interface TeamDetailViewData { team: TeamDetailData; memberships: TeamMemberData[]; currentDriverId: string; isAdmin: boolean; + teamMetrics: SponsorMetric[]; + tabs: TeamTab[]; } diff --git a/apps/website/package.json b/apps/website/package.json index a520db2c3..d7ba70c57 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -8,7 +8,7 @@ "start": "next start", "lint": "eslint . --ext .ts,.tsx --max-warnings 0", "lint:presenters": "eslint lib/presenters/*.ts", - "lint:all": "eslint .", + "lint:all": "eslint . --max-warnings 0", "type-check": "npx tsc --noEmit", "clean": "rm -rf .next" }, diff --git a/apps/website/templates/AdminDashboardTemplate.tsx b/apps/website/templates/AdminDashboardTemplate.tsx new file mode 100644 index 000000000..779431bd9 --- /dev/null +++ b/apps/website/templates/AdminDashboardTemplate.tsx @@ -0,0 +1,136 @@ +import Card from '@/components/ui/Card'; +import { + Users, + Shield, + Activity, + Clock, + AlertTriangle, + RefreshCw +} from 'lucide-react'; +import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData'; + +interface AdminDashboardTemplateProps { + viewData: AdminDashboardViewData; + onRefresh: () => void; + isLoading: boolean; +} + +/** + * AdminDashboardTemplate + * + * Pure template for admin dashboard. + * Accepts ViewData only, no business logic. + */ +export function AdminDashboardTemplate({ + viewData, + onRefresh, + isLoading +}: AdminDashboardTemplateProps) { + // Temporary UI fields (not yet provided by API/ViewModel) + const adminCount = viewData.stats.systemAdmins; + const systemHealth = 'Healthy'; + + return ( +
+ {/* Header */} +
+
+

Admin Dashboard

+

System overview and statistics

+
+ +
+ + {/* Stats Cards */} +
+ +
+
+
Total Users
+
{viewData.stats.totalUsers}
+
+ +
+
+ + +
+
+
Admins
+
{adminCount}
+
+ +
+
+ + +
+
+
Active Users
+
{viewData.stats.activeUsers}
+
+ +
+
+ + +
+
+
Recent Logins
+
{viewData.stats.recentLogins}
+
+ +
+
+
+ + {/* System Status */} + +

System Status

+
+
+ System Health + + {systemHealth} + +
+
+ Suspended Users + {viewData.stats.suspendedUsers} +
+
+ Deleted Users + {viewData.stats.deletedUsers} +
+
+ New Users Today + {viewData.stats.newUsersToday} +
+
+
+ + {/* Quick Actions */} + +

Quick Actions

+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/templates/AdminUsersTemplate.tsx b/apps/website/templates/AdminUsersTemplate.tsx new file mode 100644 index 000000000..9669ca7bf --- /dev/null +++ b/apps/website/templates/AdminUsersTemplate.tsx @@ -0,0 +1,335 @@ +import Card from '@/components/ui/Card'; +import StatusBadge from '@/components/ui/StatusBadge'; +import { + Search, + Filter, + RefreshCw, + Users, + Shield, + Trash2, + AlertTriangle +} from 'lucide-react'; +import { AdminUsersViewData } from './AdminUsersViewData'; + +interface AdminUsersTemplateProps { + viewData: AdminUsersViewData; + onRefresh: () => void; + onSearch: (search: string) => void; + onFilterRole: (role: string) => void; + onFilterStatus: (status: string) => void; + onClearFilters: () => void; + onUpdateStatus: (userId: string, status: string) => void; + onDeleteUser: (userId: string) => void; + search: string; + roleFilter: string; + statusFilter: string; + loading: boolean; + error: string | null; + deletingUser: string | null; +} + +/** + * AdminUsersTemplate + * + * Pure template for admin users page. + * Accepts ViewData only, no business logic. + */ +export function AdminUsersTemplate({ + viewData, + onRefresh, + onSearch, + onFilterRole, + onFilterStatus, + onClearFilters, + onUpdateStatus, + onDeleteUser, + search, + roleFilter, + statusFilter, + loading, + error, + deletingUser +}: AdminUsersTemplateProps) { + 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 getRoleBadgeClass = (role: string) => { + switch (role) { + case 'owner': + return 'bg-purple-500/20 text-purple-300 border border-purple-500/30'; + case 'admin': + return 'bg-blue-500/20 text-blue-300 border border-blue-500/30'; + default: + return 'bg-gray-500/20 text-gray-300 border border-gray-500/30'; + } + }; + + const getRoleBadgeLabel = (role: string) => { + switch (role) { + case 'owner': + return 'Owner'; + case 'admin': + return 'Admin'; + case 'user': + return 'User'; + default: + return role; + } + }; + + return ( +
+ {/* Header */} +
+
+

User Management

+

Manage and monitor all system users

+
+ +
+ + {/* Error Banner */} + {error && ( +
+ +
+
Error
+
{error}
+
+ +
+ )} + + {/* Filters Card */} + +
+
+
+ + Filters +
+ {(search || roleFilter || statusFilter) && ( + + )} +
+ +
+
+ + onSearch(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...
+
+ ) : !viewData.users || viewData.users.length === 0 ? ( +
+ +
No users found
+ +
+ ) : ( +
+ + + + + + + + + + + + + {viewData.users.map((user, index: number) => ( + + + + + + + + + ))} + +
UserEmailRolesStatusLast LoginActions
+
+
+ +
+
+
{user.displayName}
+
ID: {user.id}
+ {user.primaryDriverId && ( +
Driver: {user.primaryDriverId}
+ )} +
+
+
+
{user.email}
+
+
+ {user.roles.map((role: string, idx: number) => ( + + {getRoleBadgeLabel(role)} + + ))} +
+
+ {(() => { + const badge = toStatusBadgeProps(user.status); + return ; + })()} + +
+ {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'} +
+
+
+ {user.status === 'active' && ( + + )} + {user.status === 'suspended' && ( + + )} + {user.status !== 'deleted' && ( + + )} +
+
+
+ )} +
+ + {/* Stats Summary */} + {viewData.users.length > 0 && ( +
+ +
+
+
Total Users
+
{viewData.total}
+
+ +
+
+ +
+
+
Active
+
+ {viewData.users.filter(u => u.status === 'active').length} +
+
+
+
+
+ +
+
+
Admins
+
+ {viewData.users.filter(u => u.isSystemAdmin).length} +
+
+ +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/templates/AdminUsersViewData.ts b/apps/website/templates/AdminUsersViewData.ts new file mode 100644 index 000000000..d28cdb6f7 --- /dev/null +++ b/apps/website/templates/AdminUsersViewData.ts @@ -0,0 +1,24 @@ +/** + * AdminUsersViewData + * + * ViewData for AdminUsersTemplate. + * Template-ready data structure with only primitives. + */ +export interface AdminUsersViewData { + users: Array<{ + id: string; + email: string; + displayName: string; + roles: string[]; + status: string; + isSystemAdmin: boolean; + createdAt: string; + updatedAt: string; + lastLoginAt?: string; + primaryDriverId?: string; + }>; + total: number; + page: number; + limit: number; + totalPages: number; +} \ No newline at end of file diff --git a/apps/website/templates/DriverRankingsTemplate.tsx b/apps/website/templates/DriverRankingsTemplate.tsx index 73bb2aab1..2bb642efe 100644 --- a/apps/website/templates/DriverRankingsTemplate.tsx +++ b/apps/website/templates/DriverRankingsTemplate.tsx @@ -1,113 +1,27 @@ 'use client'; import React from 'react'; -import { Trophy, Medal, Search, ArrowLeft } from 'lucide-react'; +import { Trophy, Search, ArrowLeft, Medal } from 'lucide-react'; import Button from '@/components/ui/Button'; import Heading from '@/components/ui/Heading'; -import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; import DriverRankingsFilter from '@/components/DriverRankingsFilter'; -import DriverTopThreePodium from '@/components/DriverTopThreePodium'; +import { DriverTopThreePodium } from '@/components/DriverTopThreePodium'; import Image from 'next/image'; - -// ============================================================================ -// TYPES -// ============================================================================ - -type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; -type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate'; - -interface DriverRankingsTemplateProps { - drivers: DriverLeaderboardItemViewModel[]; - searchQuery: string; - selectedSkill: 'all' | SkillLevel; - sortBy: SortBy; - showFilters: boolean; - onSearchChange: (query: string) => void; - onSkillChange: (skill: 'all' | SkillLevel) => void; - onSortChange: (sort: SortBy) => void; - onToggleFilters: () => void; - onDriverClick: (id: string) => void; - onBackToLeaderboards: () => void; -} - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -const getMedalColor = (position: number) => { - switch (position) { - case 1: return 'text-yellow-400'; - case 2: return 'text-gray-300'; - case 3: return 'text-amber-600'; - default: return 'text-gray-500'; - } -}; - -const getMedalBg = (position: number) => { - switch (position) { - case 1: return 'bg-gradient-to-br from-yellow-400/20 to-yellow-600/10 border-yellow-400/40'; - case 2: return 'bg-gradient-to-br from-gray-300/20 to-gray-400/10 border-gray-300/40'; - case 3: return 'bg-gradient-to-br from-amber-600/20 to-amber-700/10 border-amber-600/40'; - default: return 'bg-iron-gray/50 border-charcoal-outline'; - } -}; +import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData'; // ============================================================================ // MAIN TEMPLATE COMPONENT // ============================================================================ -export default function DriverRankingsTemplate({ - drivers, - searchQuery, - selectedSkill, - sortBy, - showFilters, - onSearchChange, - onSkillChange, - onSortChange, - onToggleFilters, - onDriverClick, - onBackToLeaderboards, -}: DriverRankingsTemplateProps) { - // Filter drivers - const filteredDrivers = drivers.filter((driver) => { - const matchesSearch = driver.name.toLowerCase().includes(searchQuery.toLowerCase()) || - driver.nationality.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesSkill = selectedSkill === 'all' || driver.skillLevel === selectedSkill; - return matchesSearch && matchesSkill; - }); - - // Sort drivers - const sortedDrivers = [...filteredDrivers].sort((a, b) => { - const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY; - const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY; - - switch (sortBy) { - case 'rank': - return rankA - rankB || b.rating - a.rating || a.name.localeCompare(b.name); - case 'rating': - return b.rating - a.rating; - case 'wins': - return b.wins - a.wins; - case 'podiums': - return b.podiums - a.podiums; - case 'winRate': { - const aRate = a.racesCompleted > 0 ? a.wins / a.racesCompleted : 0; - const bRate = b.racesCompleted > 0 ? b.wins / b.racesCompleted : 0; - return bRate - aRate; - } - default: - return 0; - } - }); - +export function DriverRankingsTemplate(props: { viewData: DriverRankingsViewData }): React.ReactElement { + const { viewData } = props; return (
{/* Header */}
{/* Top 3 Podium */} - {!searchQuery && sortBy === 'rank' && } + {viewData.podium.length > 0 && ( + + )} {/* Filters */} {/* Leaderboard Table */} @@ -157,93 +73,85 @@ export default function DriverRankingsTemplate({ {/* Table Body */}
- {sortedDrivers.map((driver, index) => { - const winRate = driver.racesCompleted > 0 ? ((driver.wins / driver.racesCompleted) * 100).toFixed(1) : '0.0'; - const position = index + 1; + {viewData.drivers.map((driver) => ( + - ); - })} + {/* Win Rate */} +
+ + {driver.winRate}% + +
+ + ))}
{/* Empty State */} - {sortedDrivers.length === 0 && ( + {viewData.drivers.length === 0 && (

No drivers found

Try adjusting your filters or search query