website refactor

This commit is contained in:
2026-01-12 19:24:59 +01:00
parent 1f0c4f7fa6
commit 5ea95eaf51
54 changed files with 2894 additions and 2342 deletions

View File

@@ -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"
}
},
{

View File

@@ -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<AdminDashboardViewData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col items-center justify-center py-20 space-y-3">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-blue"></div>
<div className="text-gray-400">Loading dashboard...</div>
</div>
);
}
return (
<AdminDashboardTemplate
viewData={viewData}
onRefresh={loadStats}
isLoading={loading}
/>
);
}

View File

@@ -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<AdminUsersViewData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [deletingUser, setDeletingUser] = useState<string | null>(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 (
<div className="flex flex-col items-center justify-center py-20 space-y-3">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-blue"></div>
<div className="text-gray-400">Loading users...</div>
</div>
);
}
return (
<AdminUsersTemplate
viewData={viewData}
onRefresh={loadUsers}
onSearch={setSearch}
onFilterRole={setRoleFilter}
onFilterStatus={setStatusFilter}
onClearFilters={handleClearFilters}
onUpdateStatus={handleUpdateStatus}
onDeleteUser={handleDeleteUser}
search={search}
roleFilter={roleFilter}
statusFilter={statusFilter}
loading={loading}
error={error}
deletingUser={deletingUser}
/>
);
}

View File

@@ -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<void> {
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<void> {
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');
}
}

View File

@@ -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 (
<div className="min-h-screen bg-deep-graphite">

View File

@@ -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 (
<AdminLayout>
<AdminDashboardPage />
</AdminLayout>
);
return <AdminDashboardClient />;
}

View File

@@ -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 (
<AdminLayout>
<AdminUsersPage />
</AdminLayout>
);
return <AdminUsersClient />;
}

View File

@@ -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',
},
},

View File

@@ -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<TOutput>',
},
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',
});
}
},
};
},
};

View File

@@ -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',

View File

@@ -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',
});
}
},
};
},
};

View File

@@ -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({

View File

@@ -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',
});
}
},
};
},
};

View File

@@ -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',

View File

@@ -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',
});
}
},
};
},
};

View File

@@ -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,
},
};
}
}

View File

@@ -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);
}
}

View File

@@ -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<TInput, TOutput> {
/**
* Transform ViewModel into ViewData
*
* @param viewModel - Client-side ViewModel
* @returns ViewData for template
*/
build(viewModel: TInput): TOutput;
}

View File

@@ -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<TInput, TOutput> {
/**
* Transform DTO into ViewModel
*
* @param dto - API Transport DTO
* @returns ViewModel
*/
build(dto: TInput): TOutput;
}

View File

@@ -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<TInput = void, TOutput = void> {
/**
* Execute the mutation
*
* @param input - Mutation input
* @returns Output (optional)
*/
execute(input: TInput): Promise<TOutput>;
}

View File

@@ -1,4 +1,4 @@
import { PageQueryResult } from "@/lib/page-queries/page-query-result/PageQueryResult";
import { PageQueryResult } from "./PageQueryResult";
/**

View File

@@ -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<void> {
await this.service.deleteUser(input.userId);
}
}

View File

@@ -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<void> {
await this.service.updateUserStatus(input.userId, input.status);
}
}

View File

@@ -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<AdminDashboardViewData, void> {
async execute(): Promise<PageQueryResult<AdminDashboardViewData>> {
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' };
}
}
}

View File

@@ -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<PageQueryResult<AdminUsersPageDto>> {
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' };
}
}
}

View File

@@ -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;
};
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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[];
}

View File

@@ -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"
},

View File

@@ -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 (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Admin Dashboard</h1>
<p className="text-gray-400 mt-1">System overview and statistics</p>
</div>
<button
onClick={onRefresh}
disabled={isLoading}
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:bg-iron-gray/80 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Total Users</div>
<div className="text-3xl font-bold text-white">{viewData.stats.totalUsers}</div>
</div>
<Users className="w-8 h-8 text-blue-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Admins</div>
<div className="text-3xl font-bold text-white">{adminCount}</div>
</div>
<Shield className="w-8 h-8 text-purple-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Active Users</div>
<div className="text-3xl font-bold text-white">{viewData.stats.activeUsers}</div>
</div>
<Activity className="w-8 h-8 text-green-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-orange-900/20 to-orange-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Recent Logins</div>
<div className="text-3xl font-bold text-white">{viewData.stats.recentLogins}</div>
</div>
<Clock className="w-8 h-8 text-orange-400" />
</div>
</Card>
</div>
{/* System Status */}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">System Status</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">System Health</span>
<span className="px-2 py-1 text-xs rounded-full bg-performance-green/20 text-performance-green">
{systemHealth}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Suspended Users</span>
<span className="text-white font-medium">{viewData.stats.suspendedUsers}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Deleted Users</span>
<span className="text-white font-medium">{viewData.stats.deletedUsers}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">New Users Today</span>
<span className="text-white font-medium">{viewData.stats.newUsersToday}</span>
</div>
</div>
</Card>
{/* Quick Actions */}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Quick Actions</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<a href="/admin/users" className="px-4 py-3 bg-primary-blue/20 border border-primary-blue/30 text-primary-blue rounded-lg hover:bg-primary-blue/30 transition-colors text-sm font-medium text-center">
View All Users
</a>
<a href="/admin" className="px-4 py-3 bg-purple-500/20 border border-purple-500/30 text-purple-300 rounded-lg hover:bg-purple-500/30 transition-colors text-sm font-medium text-center">
Manage Admins
</a>
<a href="/admin" className="px-4 py-3 bg-orange-500/20 border border-orange-500/30 text-orange-300 rounded-lg hover:bg-orange-500/30 transition-colors text-sm font-medium text-center">
View Audit Log
</a>
</div>
</Card>
</div>
);
}

View File

@@ -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 (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">User Management</h1>
<p className="text-gray-400 mt-1">Manage and monitor all system users</p>
</div>
<button
onClick={onRefresh}
disabled={loading}
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:bg-iron-gray/80 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Error Banner */}
{error && (
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="font-medium">Error</div>
<div className="text-sm opacity-90">{error}</div>
</div>
<button
onClick={() => {}}
className="text-racing-red hover:opacity-70"
>
×
</button>
</div>
)}
{/* Filters Card */}
<Card>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">Filters</span>
</div>
{(search || roleFilter || statusFilter) && (
<button
onClick={onClearFilters}
className="text-xs text-primary-blue hover:text-blue-400"
>
Clear all
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => 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"
/>
</div>
<select
value={roleFilter}
onChange={(e) => onFilterRole(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors"
>
<option value="">All Roles</option>
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<select
value={statusFilter}
onChange={(e) => onFilterStatus(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="deleted">Deleted</option>
</select>
</div>
</div>
</Card>
{/* Users Table */}
<Card>
{loading ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-blue"></div>
<div className="text-gray-400">Loading users...</div>
</div>
) : !viewData.users || viewData.users.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3">
<Users className="w-12 h-12 text-gray-600" />
<div className="text-gray-400">No users found</div>
<button
onClick={onClearFilters}
className="text-primary-blue hover:text-blue-400 text-sm"
>
Clear filters
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">User</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Email</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Roles</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Status</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Last Login</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Actions</th>
</tr>
</thead>
<tbody>
{viewData.users.map((user, index: number) => (
<tr
key={user.id}
className={`border-b border-charcoal-outline/50 hover:bg-iron-gray/30 transition-colors ${index % 2 === 0 ? 'bg-transparent' : 'bg-iron-gray/10'}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary-blue/20 flex items-center justify-center">
<Shield className="w-4 h-4 text-primary-blue" />
</div>
<div>
<div className="font-medium text-white">{user.displayName}</div>
<div className="text-xs text-gray-500">ID: {user.id}</div>
{user.primaryDriverId && (
<div className="text-xs text-gray-500">Driver: {user.primaryDriverId}</div>
)}
</div>
</div>
</td>
<td className="py-3 px-4">
<div className="text-sm text-gray-300">{user.email}</div>
</td>
<td className="py-3 px-4">
<div className="flex flex-wrap gap-1">
{user.roles.map((role: string, idx: number) => (
<span
key={idx}
className={`px-2 py-1 text-xs rounded-full font-medium ${getRoleBadgeClass(role)}`}
>
{getRoleBadgeLabel(role)}
</span>
))}
</div>
</td>
<td className="py-3 px-4">
{(() => {
const badge = toStatusBadgeProps(user.status);
return <StatusBadge status={badge.status} label={badge.label} />;
})()}
</td>
<td className="py-3 px-4">
<div className="text-sm text-gray-400">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
</div>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
{user.status === 'active' && (
<button
onClick={() => onUpdateStatus(user.id, 'suspended')}
className="px-3 py-1 text-xs rounded bg-yellow-500/20 text-yellow-300 hover:bg-yellow-500/30 transition-colors"
>
Suspend
</button>
)}
{user.status === 'suspended' && (
<button
onClick={() => onUpdateStatus(user.id, 'active')}
className="px-3 py-1 text-xs rounded bg-performance-green/20 text-performance-green hover:bg-performance-green/30 transition-colors"
>
Activate
</button>
)}
{user.status !== 'deleted' && (
<button
onClick={() => onDeleteUser(user.id)}
disabled={deletingUser === user.id}
className="px-3 py-1 text-xs rounded bg-racing-red/20 text-racing-red hover:bg-racing-red/30 transition-colors flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
{deletingUser === user.id ? 'Deleting...' : 'Delete'}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{/* Stats Summary */}
{viewData.users.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Total Users</div>
<div className="text-2xl font-bold text-white">{viewData.total}</div>
</div>
<Users className="w-6 h-6 text-blue-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Active</div>
<div className="text-2xl font-bold text-white">
{viewData.users.filter(u => u.status === 'active').length}
</div>
</div>
<div className="w-6 h-6 text-green-400"></div>
</div>
</Card>
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Admins</div>
<div className="text-2xl font-bold text-white">
{viewData.users.filter(u => u.isSystemAdmin).length}
</div>
</div>
<Shield className="w-6 h-6 text-purple-400" />
</div>
</Card>
</div>
)}
</div>
);
}

View File

@@ -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;
}

View File

@@ -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 (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Header */}
<div className="mb-8">
<Button
variant="secondary"
onClick={onBackToLeaderboards}
onClick={viewData.onBackToLeaderboards}
className="flex items-center gap-2 mb-6"
>
<ArrowLeft className="w-4 h-4" />
@@ -128,18 +42,20 @@ export default function DriverRankingsTemplate({
</div>
{/* Top 3 Podium */}
{!searchQuery && sortBy === 'rank' && <DriverTopThreePodium drivers={sortedDrivers} onDriverClick={onDriverClick} />}
{viewData.podium.length > 0 && (
<DriverTopThreePodium podium={viewData.podium} onDriverClick={viewData.onDriverClick} />
)}
{/* Filters */}
<DriverRankingsFilter
searchQuery={searchQuery}
onSearchChange={onSearchChange}
selectedSkill={selectedSkill}
onSkillChange={onSkillChange}
sortBy={sortBy}
onSortChange={onSortChange}
showFilters={showFilters}
onToggleFilters={onToggleFilters}
searchQuery={viewData.searchQuery}
onSearchChange={viewData.onSearchChange}
selectedSkill={viewData.selectedSkill}
onSkillChange={viewData.onSkillChange}
sortBy={viewData.sortBy}
onSortChange={viewData.onSortChange}
showFilters={viewData.showFilters}
onToggleFilters={viewData.onToggleFilters}
/>
{/* Leaderboard Table */}
@@ -157,93 +73,85 @@ export default function DriverRankingsTemplate({
{/* Table Body */}
<div className="divide-y divide-charcoal-outline/50">
{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) => (
<button
key={driver.id}
type="button"
onClick={() => viewData.onDriverClick(driver.id)}
className="grid grid-cols-12 gap-4 px-4 py-4 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className="col-span-1 flex items-center justify-center">
<div className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${driver.medalBg} ${driver.medalColor}`}>
{driver.rank <= 3 ? <Medal className="w-4 h-4" /> : driver.rank}
</div>
</div>
return (
<button
key={driver.id}
type="button"
onClick={() => onDriverClick(driver.id)}
className="grid grid-cols-12 gap-4 px-4 py-4 w-full text-left hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<div className="col-span-1 flex items-center justify-center">
<div className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold border ${getMedalBg(position)} ${getMedalColor(position)}`}>
{position <= 3 ? <Medal className="w-4 h-4" /> : position}
{/* Driver Info */}
<div className="col-span-5 lg:col-span-4 flex items-center gap-3">
<div className="relative w-10 h-10 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" />
</div>
<div className="min-w-0">
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
{driver.nationality}
</span>
<span className="flex items-center gap-1">
{driver.skillLevel}
</span>
</div>
</div>
</div>
{/* Driver Info */}
<div className="col-span-5 lg:col-span-4 flex items-center gap-3">
<div className="relative w-10 h-10 rounded-full overflow-hidden border-2 border-charcoal-outline">
<Image src={driver.avatarUrl} alt={driver.name} fill className="object-cover" />
</div>
<div className="min-w-0">
<p className="text-white font-semibold truncate group-hover:text-primary-blue transition-colors">
{driver.name}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
{driver.nationality}
</span>
<span className="flex items-center gap-1">
{driver.skillLevel}
</span>
</div>
</div>
</div>
{/* Races */}
<div className="col-span-2 items-center justify-center hidden md:flex">
<span className="text-gray-400">{driver.racesCompleted}</span>
</div>
{/* Races */}
<div className="col-span-2 items-center justify-center hidden md:flex">
<span className="text-gray-400">{driver.racesCompleted}</span>
</div>
{/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${viewData.sortBy === 'rating' ? 'text-primary-blue' : 'text-white'}`}>
{driver.rating.toLocaleString()}
</span>
</div>
{/* Rating */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'rating' ? 'text-primary-blue' : 'text-white'}`}>
{driver.rating.toLocaleString()}
</span>
</div>
{/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${viewData.sortBy === 'wins' ? 'text-primary-blue' : 'text-performance-green'}`}>
{driver.wins}
</span>
</div>
{/* Wins */}
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-primary-blue' : 'text-performance-green'}`}>
{driver.wins}
</span>
</div>
{/* Podiums */}
<div className="col-span-1 items-center justify-center hidden lg:flex">
<span className={`font-mono font-semibold ${viewData.sortBy === 'podiums' ? 'text-primary-blue' : 'text-warning-amber'}`}>
{driver.podiums}
</span>
</div>
{/* Podiums */}
<div className="col-span-1 items-center justify-center hidden lg:flex">
<span className={`font-mono font-semibold ${sortBy === 'podiums' ? 'text-primary-blue' : 'text-warning-amber'}`}>
{driver.podiums}
</span>
</div>
{/* Win Rate */}
<div className="col-span-2 flex items-center justify-center">
<span className={`font-mono font-semibold ${sortBy === 'winRate' ? 'text-primary-blue' : 'text-white'}`}>
{winRate}%
</span>
</div>
</button>
);
})}
{/* Win Rate */}
<div className="col-span-2 flex items-center justify-center">
<span className={`font-mono font-semibold ${viewData.sortBy === 'winRate' ? 'text-primary-blue' : 'text-white'}`}>
{driver.winRate}%
</span>
</div>
</button>
))}
</div>
{/* Empty State */}
{sortedDrivers.length === 0 && (
{viewData.drivers.length === 0 && (
<div className="py-16 text-center">
<Search className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No drivers found</p>
<p className="text-sm text-gray-500">Try adjusting your filters or search query</p>
<Button
variant="secondary"
onClick={() => {
onSearchChange('');
onSkillChange('all');
}}
onClick={viewData.onClearFilters}
className="mt-4"
>
Clear Filters

View File

@@ -1,19 +1,14 @@
'use client';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import JoinLeagueButton from '@/components/leagues/JoinLeagueButton';
import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed';
import SponsorInsightsCard, {
MetricBuilders,
SlotTemplates,
type SponsorMetric,
} from '@/components/sponsors/SponsorInsightsCard';
import SponsorInsightsCard from '@/components/sponsors/SponsorInsightsCard';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
import type { LeagueDetailPageViewModel, DriverSummary } from '@/lib/view-models/LeagueDetailPageViewModel';
import type { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import type { DriverSummaryData, LeagueDetailViewData, LeagueInfoData, LiveRaceData, SponsorInfo } from '@/lib/view-data/LeagueDetailViewData';
import { Calendar, ExternalLink, Star, Trophy, Users } from 'lucide-react';
import Image from 'next/image';
import { ReactNode } from 'react';
// ============================================================================
@@ -21,45 +16,35 @@ import { ReactNode } from 'react';
// ============================================================================
interface LeagueDetailTemplateProps {
viewModel: LeagueDetailPageViewModel;
viewData: LeagueDetailViewData;
leagueId: string;
isSponsor: boolean;
membership: { role: string } | null;
currentDriverId: string | null;
onMembershipChange: () => void;
onEndRaceModalOpen: (raceId: string) => void;
onLiveRaceClick: (raceId: string) => void;
onBackToLeagues: () => void;
children?: ReactNode;
}
interface LiveRaceCardProps {
races: RaceViewModel[];
races: LiveRaceData[];
membership: { role: string } | null;
onLiveRaceClick: (raceId: string) => void;
onEndRaceModalOpen: (raceId: string) => void;
}
interface LeagueInfoCardProps {
viewModel: LeagueDetailPageViewModel;
info: LeagueInfoData;
}
interface SponsorsSectionProps {
sponsors: Array<{
id: string;
name: string;
tier: 'main' | 'secondary';
logoUrl?: string;
tagline?: string;
websiteUrl?: string;
}>;
sponsors: SponsorInfo[];
}
interface ManagementSectionProps {
ownerSummary?: DriverSummary | null;
adminSummaries: DriverSummary[];
stewardSummaries: DriverSummary[];
leagueId: string;
ownerSummary: DriverSummaryData | null;
adminSummaries: DriverSummaryData[];
stewardSummaries: DriverSummaryData[];
}
// ============================================================================
@@ -140,7 +125,7 @@ function LiveRaceCard({ races, membership, onLiveRaceClick, onEndRaceModalOpen }
// LEAGUE INFO CARD COMPONENT
// ============================================================================
function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
function LeagueInfoCard({ info }: LeagueInfoCardProps) {
return (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">About</h3>
@@ -148,15 +133,15 @@ function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
{/* Stats Grid */}
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-white">{viewModel.memberships.length}</div>
<div className="text-xl font-bold text-white">{info.membersCount}</div>
<div className="text-xs text-gray-500">Members</div>
</div>
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-white">{viewModel.completedRacesCount}</div>
<div className="text-xl font-bold text-white">{info.racesCount}</div>
<div className="text-xs text-gray-500">Races</div>
</div>
<div className="bg-deep-graphite rounded-lg p-3 text-center border border-charcoal-outline">
<div className="text-xl font-bold text-warning-amber">{viewModel.averageSOF ?? '—'}</div>
<div className="text-xl font-bold text-warning-amber">{info.avgSOF ?? '—'}</div>
<div className="text-xs text-gray-500">Avg SOF</div>
</div>
</div>
@@ -165,16 +150,16 @@ function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
<span className="text-gray-500">Structure</span>
<span className="text-white">Solo {viewModel.settings.maxDrivers ?? 32} max</span>
<span className="text-white">{info.structure}</span>
</div>
<div className="flex items-center justify-between py-1.5 border-b border-charcoal-outline/50">
<span className="text-gray-500">Scoring</span>
<span className="text-white">{viewModel.scoringConfig?.scoringPresetName ?? 'Standard'}</span>
<span className="text-white">{info.scoring}</span>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-gray-500">Created</span>
<span className="text-white">
{new Date(viewModel.createdAt).toLocaleDateString('en-US', {
{new Date(info.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric'
})}
@@ -182,12 +167,12 @@ function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
</div>
</div>
{viewModel.socialLinks && (
{(info.discordUrl || info.youtubeUrl || info.websiteUrl) && (
<div className="mt-4 pt-4 border-t border-charcoal-outline">
<div className="flex flex-wrap gap-2">
{viewModel.socialLinks.discordUrl && (
{info.discordUrl && (
<a
href={viewModel.socialLinks.discordUrl}
href={info.discordUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-primary-blue/40 bg-primary-blue/10 px-2 py-1 text-xs text-primary-blue hover:bg-primary-blue/20 transition-colors"
@@ -195,9 +180,9 @@ function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
Discord
</a>
)}
{viewModel.socialLinks.youtubeUrl && (
{info.youtubeUrl && (
<a
href={viewModel.socialLinks.youtubeUrl}
href={info.youtubeUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs text-red-400 hover:bg-red-500/20 transition-colors"
@@ -205,9 +190,9 @@ function LeagueInfoCard({ viewModel }: LeagueInfoCardProps) {
YouTube
</a>
)}
{viewModel.socialLinks.websiteUrl && (
{info.websiteUrl && (
<a
href={viewModel.socialLinks.websiteUrl}
href={info.websiteUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-charcoal-outline bg-iron-gray/70 px-2 py-1 text-xs text-gray-100 hover:bg-iron-gray transition-colors"
@@ -244,9 +229,11 @@ function SponsorsSection({ sponsors }: SponsorsSectionProps) {
<div className="flex items-center gap-3">
{sponsor.logoUrl ? (
<div className="w-12 h-12 rounded-lg bg-white flex items-center justify-center overflow-hidden">
<img
<Image
src={sponsor.logoUrl}
alt={sponsor.name}
width={40}
height={40}
className="w-10 h-10 object-contain"
/>
</div>
@@ -291,9 +278,11 @@ function SponsorsSection({ sponsors }: SponsorsSectionProps) {
<div className="flex items-center gap-2">
{sponsor.logoUrl ? (
<div className="w-8 h-8 rounded bg-white flex items-center justify-center overflow-hidden flex-shrink-0">
<img
<Image
src={sponsor.logoUrl}
alt={sponsor.name}
width={24}
height={24}
className="w-6 h-6 object-contain"
/>
</div>
@@ -329,82 +318,78 @@ function SponsorsSection({ sponsors }: SponsorsSectionProps) {
// MANAGEMENT SECTION COMPONENT
// ============================================================================
function ManagementSection({ ownerSummary, adminSummaries, stewardSummaries, leagueId }: ManagementSectionProps) {
function ManagementSection({ ownerSummary, adminSummaries, stewardSummaries }: ManagementSectionProps) {
if (!ownerSummary && adminSummaries.length === 0 && stewardSummaries.length === 0) return null;
return (
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Management</h3>
<div className="space-y-2">
{ownerSummary && (() => {
const summary = ownerSummary;
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('owner');
const meta = summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return (
<div className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={summary.driver}
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
{ownerSummary && (
<div className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={{
id: ownerSummary.driverId,
name: ownerSummary.driverName,
avatarUrl: ownerSummary.avatarUrl,
}}
href={ownerSummary.profileUrl}
meta={ownerSummary.rating !== null
? `Rating ${ownerSummary.rating}${ownerSummary.rank ? ` • Rank ${ownerSummary.rank}` : ''}`
: undefined}
size="sm"
/>
</div>
);
})()}
<span className={`px-2 py-1 text-xs font-medium rounded border ${ownerSummary.roleBadgeClasses}`}>
{ownerSummary.roleBadgeText}
</span>
</div>
)}
{adminSummaries.map((summary) => {
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('admin');
const meta = summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return (
<div key={summary.driver.id} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={summary.driver}
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
{adminSummaries.map((summary) => (
<div key={summary.driverId} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={{
id: summary.driverId,
name: summary.driverName,
avatarUrl: summary.avatarUrl,
}}
href={summary.profileUrl}
meta={summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: undefined}
size="sm"
/>
</div>
);
})}
<span className={`px-2 py-1 text-xs font-medium rounded border ${summary.roleBadgeClasses}`}>
{summary.roleBadgeText}
</span>
</div>
))}
{stewardSummaries.map((summary) => {
const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('steward');
const meta = summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: null;
return (
<div key={summary.driver.id} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={summary.driver}
href={`/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`}
meta={meta}
size="sm"
/>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded border ${roleDisplay.badgeClasses}`}>
{roleDisplay.text}
</span>
{stewardSummaries.map((summary) => (
<div key={summary.driverId} className="flex items-center gap-2">
<div className="flex-1">
<DriverIdentity
driver={{
id: summary.driverId,
name: summary.driverName,
avatarUrl: summary.avatarUrl,
}}
href={summary.profileUrl}
meta={summary.rating !== null
? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}`
: undefined}
size="sm"
/>
</div>
);
})}
<span className={`px-2 py-1 text-xs font-medium rounded border ${summary.roleBadgeClasses}`}>
{summary.roleBadgeText}
</span>
</div>
))}
</div>
</Card>
);
@@ -415,59 +400,44 @@ function ManagementSection({ ownerSummary, adminSummaries, stewardSummaries, lea
// ============================================================================
export function LeagueDetailTemplate({
viewModel,
viewData,
leagueId,
isSponsor,
membership,
currentDriverId,
onMembershipChange,
onEndRaceModalOpen,
onLiveRaceClick,
onBackToLeagues,
children,
}: LeagueDetailTemplateProps) {
// Build metrics for SponsorInsightsCard
const leagueMetrics: SponsorMetric[] = [
MetricBuilders.views(viewModel.sponsorInsights.avgViewsPerRace, 'Avg Views/Race'),
MetricBuilders.engagement(viewModel.sponsorInsights.engagementRate),
MetricBuilders.reach(viewModel.sponsorInsights.estimatedReach),
MetricBuilders.sof(viewModel.averageSOF ?? '—'),
];
return (
<>
{/* Sponsor Insights Card - Only shown to sponsors, at top of page */}
{isSponsor && viewModel && (
{isSponsor && viewData.sponsorInsights && (
<SponsorInsightsCard
entityType="league"
entityId={leagueId}
entityName={viewModel.name}
tier={viewModel.sponsorInsights.tier}
metrics={leagueMetrics}
slots={SlotTemplates.league(
viewModel.sponsorInsights.mainSponsorAvailable,
viewModel.sponsorInsights.secondarySlotsAvailable,
viewModel.sponsorInsights.mainSponsorPrice,
viewModel.sponsorInsights.secondaryPrice
)}
trustScore={viewModel.sponsorInsights.trustScore}
discordMembers={viewModel.sponsorInsights.discordMembers}
monthlyActivity={viewModel.sponsorInsights.monthlyActivity}
entityName={viewData.name}
tier={viewData.sponsorInsights.tier}
metrics={viewData.sponsorInsights.metrics}
slots={viewData.sponsorInsights.slots}
trustScore={viewData.sponsorInsights.trustScore}
discordMembers={viewData.sponsorInsights.discordMembers}
monthlyActivity={viewData.sponsorInsights.monthlyActivity}
additionalStats={{
label: 'League Stats',
items: [
{ label: 'Total Races', value: viewModel.completedRacesCount },
{ label: 'Active Members', value: viewModel.memberships.length },
{ label: 'Total Impressions', value: viewModel.sponsorInsights.totalImpressions },
{ label: 'Total Races', value: viewData.info.racesCount },
{ label: 'Active Members', value: viewData.info.membersCount },
{ label: 'Total Impressions', value: viewData.sponsorInsights.totalImpressions },
],
}}
/>
)}
{/* Live Race Card - Prominently show running races */}
{viewModel && viewModel.runningRaces.length > 0 && (
{viewData.runningRaces.length > 0 && (
<LiveRaceCard
races={viewModel.runningRaces}
races={viewData.runningRaces}
membership={membership}
onLiveRaceClick={onLiveRaceClick}
onEndRaceModalOpen={onEndRaceModalOpen}
@@ -505,19 +475,18 @@ export function LeagueDetailTemplate({
{/* Right Sidebar - League Info */}
<div className="space-y-6">
{/* League Info - Combined */}
<LeagueInfoCard viewModel={viewModel} />
<LeagueInfoCard info={viewData.info} />
{/* Sponsors Section - Show sponsor logos */}
{viewModel.sponsors.length > 0 && (
<SponsorsSection sponsors={viewModel.sponsors} />
{viewData.sponsors.length > 0 && (
<SponsorsSection sponsors={viewData.sponsors} />
)}
{/* Management */}
<ManagementSection
ownerSummary={viewModel.ownerSummary}
adminSummaries={viewModel.adminSummaries}
stewardSummaries={viewModel.stewardSummaries}
leagueId={leagueId}
ownerSummary={viewData.ownerSummary}
adminSummaries={viewData.adminSummaries}
stewardSummaries={viewData.stewardSummaries}
/>
</div>
</div>

View File

@@ -1,23 +1,16 @@
'use client';
import StandingsTable from '@/components/leagues/StandingsTable';
import LeagueChampionshipStats from '@/components/leagues/LeagueChampionshipStats';
import { LeagueChampionshipStats } from '@/components/leagues/LeagueChampionshipStats';
import { StandingsTable } from '@/components/leagues/StandingsTable';
import Card from '@/components/ui/Card';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
// ============================================================================
// TYPES
// ============================================================================
interface LeagueStandingsTemplateProps {
standings: StandingEntryViewModel[];
drivers: DriverViewModel[];
memberships: LeagueMembership[];
leagueId: string;
currentDriverId: string | null;
isAdmin: boolean;
viewData: LeagueStandingsViewData;
onRemoveMember: (driverId: string) => void;
onUpdateRole: (driverId: string, newRole: string) => void;
loading?: boolean;
@@ -28,12 +21,7 @@ interface LeagueStandingsTemplateProps {
// ============================================================================
export function LeagueStandingsTemplate({
standings,
drivers,
memberships,
leagueId,
currentDriverId,
isAdmin,
viewData,
onRemoveMember,
onUpdateRole,
loading = false,
@@ -49,38 +37,16 @@ export function LeagueStandingsTemplate({
return (
<div className="space-y-6">
{/* Championship Stats */}
<LeagueChampionshipStats standings={standings} drivers={drivers} />
<LeagueChampionshipStats standings={viewData.standings} drivers={viewData.drivers} />
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
<StandingsTable
standings={standings.map((s) => ({
leagueId,
driverId: s.driverId,
position: s.position,
totalPoints: s.points,
racesFinished: s.races,
racesStarted: s.races,
avgFinish: null,
penaltyPoints: 0,
bonusPoints: 0,
}) satisfies {
leagueId: string;
driverId: string;
position: number;
totalPoints: number;
racesFinished: number;
racesStarted: number;
avgFinish: number | null;
penaltyPoints: number;
bonusPoints: number;
teamName?: string;
})}
drivers={drivers}
leagueId={leagueId}
memberships={memberships}
currentDriverId={currentDriverId ?? undefined}
isAdmin={isAdmin}
standings={viewData.standings}
drivers={viewData.drivers}
memberships={viewData.memberships}
currentDriverId={viewData.currentDriverId ?? undefined}
isAdmin={viewData.isAdmin}
onRemoveMember={onRemoveMember}
onUpdateRole={onUpdateRole}
/>

View File

@@ -1,7 +1,7 @@
'use client';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import SponsorInsightsCard, { MetricBuilders, SlotTemplates, useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
import SponsorInsightsCard, { SlotTemplates, useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Image from 'next/image';
@@ -13,7 +13,7 @@ import TeamStandings from '@/components/teams/TeamStandings';
import StatItem from '@/components/teams/StatItem';
import { getMediaUrl } from '@/lib/utilities/media';
import PlaceholderImage from '@/components/ui/PlaceholderImage';
import type { TeamDetailViewData, TeamDetailData, TeamMemberData } from './TeamDetailViewData';
import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
@@ -22,12 +22,9 @@ type Tab = 'overview' | 'roster' | 'standings' | 'admin';
// ============================================================================
export interface TeamDetailTemplateProps {
// Data props
team: TeamDetailData | null;
memberships: TeamMemberData[];
viewData: TeamDetailViewData;
activeTab: Tab;
loading: boolean;
isAdmin: boolean;
// Event handlers
onTabChange: (tab: Tab) => void;
@@ -41,12 +38,10 @@ export interface TeamDetailTemplateProps {
// MAIN TEMPLATE COMPONENT
// ============================================================================
export default function TeamDetailTemplate({
team,
memberships,
export function TeamDetailTemplate({
viewData,
activeTab,
loading,
isAdmin,
onTabChange,
onUpdate,
onRemoveMember,
@@ -65,7 +60,7 @@ export default function TeamDetailTemplate({
}
// Show not found state
if (!team) {
if (!viewData.team) {
return (
<div className="max-w-6xl mx-auto">
<Card>
@@ -87,20 +82,11 @@ export default function TeamDetailTemplate({
{ id: 'overview', label: 'Overview', visible: true },
{ id: 'roster', label: 'Roster', visible: true },
{ id: 'standings', label: 'Standings', visible: true },
{ id: 'admin', label: 'Admin', visible: isAdmin },
{ id: 'admin', label: 'Admin', visible: viewData.isAdmin },
];
const visibleTabs = tabs.filter(tab => tab.visible);
// Build sponsor insights for team using real membership and league data
const leagueCount = team.leagues?.length ?? 0;
const teamMetrics = [
MetricBuilders.members(memberships.length),
MetricBuilders.reach(memberships.length * 15),
MetricBuilders.races(leagueCount),
MetricBuilders.engagement(82),
];
return (
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
@@ -108,18 +94,18 @@ export default function TeamDetailTemplate({
items={[
{ label: 'Home', href: '/' },
{ label: 'Teams', href: '/teams' },
{ label: team.name }
{ label: viewData.team.name }
]}
/>
{/* Sponsor Insights Card - Consistent placement at top */}
{isSponsorMode && team && (
{isSponsorMode && viewData.team && (
<SponsorInsightsCard
entityType="team"
entityId={team.id}
entityName={team.name}
entityId={viewData.team.id}
entityName={viewData.team.name}
tier="standard"
metrics={teamMetrics}
metrics={viewData.teamMetrics}
slots={SlotTemplates.team(true, true, 500, 250)}
trustScore={90}
monthlyActivity={85}
@@ -131,8 +117,8 @@ export default function TeamDetailTemplate({
<div className="flex items-start gap-6">
<div className="w-24 h-24 bg-charcoal-outline rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
<Image
src={getMediaUrl('team-logo', team.id)}
alt={team.name}
src={getMediaUrl('team-logo', viewData.team.id)}
alt={viewData.team.name}
width={96}
height={96}
className="w-full h-full object-cover"
@@ -141,39 +127,39 @@ export default function TeamDetailTemplate({
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{team.name}</h1>
{team.tag && (
<h1 className="text-3xl font-bold text-white">{viewData.team.name}</h1>
{viewData.team.tag && (
<span className="px-2 py-0.5 rounded-full text-xs bg-charcoal-outline text-gray-300">
[{team.tag}]
[{viewData.team.tag}]
</span>
)}
</div>
<p className="text-gray-300 mb-4 max-w-2xl">{team.description}</p>
<p className="text-gray-300 mb-4 max-w-2xl">{viewData.team.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span>
{team.category && (
<span>{viewData.memberships.length} {viewData.memberships.length === 1 ? 'member' : 'members'}</span>
{viewData.team.category && (
<span className="flex items-center gap-1 text-purple-400">
<span className="w-2 h-2 rounded-full bg-purple-400"></span>
{team.category}
{viewData.team.category}
</span>
)}
{team.createdAt && (
{viewData.team.createdAt && (
<span>
Founded {new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
Founded {new Date(viewData.team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</span>
)}
{leagueCount > 0 && (
{viewData.team.leagues && viewData.team.leagues.length > 0 && (
<span>
Active in {leagueCount} {leagueCount === 1 ? 'league' : 'leagues'}
Active in {viewData.team.leagues.length} {viewData.team.leagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
</div>
</div>
<JoinTeamButton teamId={team.id} onUpdate={onUpdate} />
<JoinTeamButton teamId={viewData.team.id} onUpdate={onUpdate} />
</div>
</Card>
@@ -185,8 +171,8 @@ export default function TeamDetailTemplate({
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 font-medium transition-all relative
${activeTab === tab.id
? 'text-primary-blue'
${activeTab === tab.id
? 'text-primary-blue'
: 'text-gray-400 hover:text-white'
}
`}
@@ -206,23 +192,23 @@ export default function TeamDetailTemplate({
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<h3 className="text-xl font-semibold text-white mb-4">About</h3>
<p className="text-gray-300 leading-relaxed">{team.description}</p>
<p className="text-gray-300 leading-relaxed">{viewData.team.description}</p>
</Card>
<Card>
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
<div className="space-y-3">
<StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" />
{team.category && (
<StatItem label="Category" value={team.category} color="text-purple-400" />
<StatItem label="Members" value={viewData.memberships.length.toString()} color="text-primary-blue" />
{viewData.team.category && (
<StatItem label="Category" value={viewData.team.category} color="text-purple-400" />
)}
{leagueCount > 0 && (
<StatItem label="Leagues" value={leagueCount.toString()} color="text-green-400" />
{viewData.team.leagues && viewData.team.leagues.length > 0 && (
<StatItem label="Leagues" value={viewData.team.leagues.length.toString()} color="text-green-400" />
)}
{team.createdAt && (
{viewData.team.createdAt && (
<StatItem
label="Founded"
value={new Date(team.createdAt).toLocaleDateString('en-US', {
value={new Date(viewData.team.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})}
@@ -244,20 +230,20 @@ export default function TeamDetailTemplate({
{activeTab === 'roster' && (
<TeamRoster
teamId={team.id}
memberships={memberships}
isAdmin={isAdmin}
teamId={viewData.team.id}
memberships={viewData.memberships}
isAdmin={viewData.isAdmin}
onRemoveMember={onRemoveMember}
onChangeRole={onChangeRole}
/>
)}
{activeTab === 'standings' && (
<TeamStandings teamId={team.id} leagues={team.leagues} />
<TeamStandings teamId={viewData.team.id} leagues={viewData.team.leagues} />
)}
{activeTab === 'admin' && isAdmin && (
<TeamAdmin team={team} onUpdate={onUpdate} />
{activeTab === 'admin' && viewData.isAdmin && (
<TeamAdmin team={viewData.team} onUpdate={onUpdate} />
)}
</div>
</div>

View File

@@ -1,12 +1,12 @@
'use client';
import { Users, Trophy, Target } from 'lucide-react';
import { Trophy, Users } from 'lucide-react';
import Link from 'next/link';
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import type { TeamsViewData, TeamSummaryData } from './view-data/TeamsViewData';
import type { TeamSummaryData, TeamsViewData } from '../lib/view-data/TeamsViewData';
interface TeamsTemplateProps extends TeamsViewData {
searchQuery?: string;
@@ -20,7 +20,7 @@ interface TeamsTemplateProps extends TeamsViewData {
onSkillLevelClick?: (level: string) => void;
}
export function TeamsTemplate({ teams, searchQuery, onSearchChange, onShowCreateForm, onTeamClick }: TeamsTemplateProps) {
export function TeamsTemplate({ teams }: TeamsTemplateProps) {
return (
<main className="min-h-screen bg-deep-graphite py-8">
<div className="max-w-7xl mx-auto px-6">

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
/**
* Guardrail violation representation
*/
export class GuardrailViolation {
constructor(
public readonly ruleName: string,
public readonly filePath: string,
public readonly lineNumber: number,
public readonly description: string,
) {}
toString(): string {
return `${this.filePath}:${this.lineNumber} - ${this.ruleName}: ${this.description}`;
}
toJSON(): object {
return {
rule: this.ruleName,
file: this.filePath,
line: this.lineNumber,
description: this.description,
};
}
}

View File

@@ -1,88 +0,0 @@
/**
* Allowlist for architecture guardrail violations
*
* This file contains violations that currently exist in the codebase.
* In future slices, these should be shrunk to zero.
*
* Format: Each rule has an array of file paths that are allowed to violate it.
*/
export interface GuardrailAllowlist {
[ruleName: string]: string[];
}
export const ALLOWED_VIOLATIONS: GuardrailAllowlist = {
// Rule 1: ContainerManager usage in server page queries
'no-container-manager-in-server': [],
// Rule 2: PageDataFetcher.fetch() usage in server page queries
'no-page-data-fetcher-fetch-in-server': [],
// Rule 3: ViewModels imported in forbidden paths
'no-view-models-in-server': [],
// Rule 4: Templates importing view-models or display-objects
'no-view-models-in-templates': [],
// Rule 5: Intl.* or toLocale* in presentation paths
'no-intl-in-presentation': [],
// Rule 6: Client-side fetch with write methods
'no-client-write-fetch': [
'apps/website/app/sponsor/signup/page.tsx',
],
// Rule 7: *Template.tsx files under app/
'no-templates-in-app': [],
// Rule 8: 'as any' usage - ZERO TOLERANCE
// Hard fail - no allowlist entries allowed
'no-as-any': [],
// New Rule 1: RSC boundary - additional checks
'no-presenters-in-server': [],
'no-sorting-filtering-in-server': [],
'no-display-objects-in-server': [],
'no-unsafe-services-in-server': [],
'no-di-in-server': [],
'no-local-helpers-in-server': [],
'no-object-construction-in-server': [],
'no-container-manager-calls-in-server': [],
// New Rule 2: Template purity - additional checks
'no-state-hooks-in-templates': [],
'no-computations-in-templates': [],
'no-restricted-imports-in-templates': [],
'no-invalid-template-signature': [],
'no-template-helper-exports': [],
'invalid-template-filename': [],
// New Rule 3: Display Object guardrails
'no-io-in-display-objects': [],
'no-non-class-display-exports': [],
// New Rule 4: Page Query guardrails
'no-null-returns-in-page-queries': [],
'invalid-page-query-filename': [],
// New Rule 5: Services guardrails
'no-service-state': [],
'no-blockers-in-services': [],
'no-dto-variable-name': [],
// New Rule 6: Client-only guardrails
'no-use-client-directive': [],
'no-viewmodel-imports-from-server': [],
'no-http-in-presenters': [],
// New Rule 7: Write boundary guardrails
'no-server-action-imports-from-client': [],
'no-server-action-viewmodel-returns': [],
// New Rule 10: Generated DTO isolation
'no-generated-dto-in-ui': [],
'no-types-in-templates': [],
// New Rule 11: Filename rules
'invalid-app-filename': [],
};

View File

@@ -1,180 +0,0 @@
import { describe, it, expect } from 'vitest';
import { ArchitectureGuardrails } from './ArchitectureGuardrails';
/**
* Architecture Guardrail Tests
*
* These tests enforce the architectural contract for the website.
* They use an allowlist to permit existing violations while preventing new ones.
*
* The goal is to shrink the allowlist slice-by-slice until zero violations remain.
*/
describe('Architecture Guardrails', () => {
const guardrails = new ArchitectureGuardrails();
it('should detect all violations in the codebase', () => {
const allViolations = guardrails.scan();
// This test documents the current state
// It will always pass but shows what violations exist
console.log(`\n📊 Total violations found: ${allViolations.length}`);
if (allViolations.length > 0) {
console.log('\n📋 Violations by rule:');
const byRule = allViolations.reduce((acc, v) => {
acc[v.ruleName] = (acc[v.ruleName] || 0) + 1;
return acc;
}, {} as Record<string, number>);
Object.entries(byRule).forEach(([rule, count]) => {
console.log(` - ${rule}: ${count}`);
});
}
// We expect violations to exist initially
expect(allViolations.length).toBeGreaterThanOrEqual(0);
});
it('should have no violations after filtering by allowlist', () => {
const filteredViolations = guardrails.getFilteredViolations();
console.log(`\n🔍 Filtered violations (after allowlist): ${filteredViolations.length}`);
if (filteredViolations.length > 0) {
console.log('\n❌ New violations not in allowlist:');
filteredViolations.forEach(v => {
console.log(` - ${v.toString()}`);
});
}
// This is the main assertion - no new violations allowed
expect(filteredViolations.length).toBe(0);
});
it('should not have stale allowlist entries', () => {
const staleEntries = guardrails.findStaleAllowlistEntries();
console.log(`\n🧹 Stale allowlist entries: ${staleEntries.length}`);
if (staleEntries.length > 0) {
console.log('\n⚠ These allowlist entries no longer match any violations:');
staleEntries.forEach(entry => {
console.log(` - ${entry}`);
});
console.log('\n💡 Consider removing them from allowed-violations.ts');
}
// Stale entries should be removed to keep allowlist clean
expect(staleEntries.length).toBe(0);
});
it('should enforce: no ContainerManager in server page queries', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-container-manager-in-server'
);
expect(violations.length).toBe(0);
});
it('should enforce: no PageDataFetcher.fetch() in server page queries', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-page-data-fetcher-fetch-in-server'
);
expect(violations.length).toBe(0);
});
it('should enforce: no view-models imports in server code', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-view-models-in-server'
);
expect(violations.length).toBe(0);
});
it('should enforce: no view-models/display-objects in templates', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-view-models-in-templates'
);
expect(violations.length).toBe(0);
});
it('should enforce: no Intl.* or toLocale* in presentation paths', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-intl-in-presentation'
);
expect(violations.length).toBe(0);
});
it('should enforce: no client-side write fetch', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-client-write-fetch'
);
expect(violations.length).toBe(0);
});
it('should enforce: no *Template.tsx under app/', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-templates-in-app'
);
expect(violations.length).toBe(0);
});
it('should enforce: no hooks directory in apps/website/', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-hooks-directory'
);
expect(violations.length).toBe(0);
});
it('should enforce: no as any usage', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-as-any'
);
expect(violations.length).toBe(0);
});
// NEW: Contract enforcement tests
it('should enforce: PageQuery classes must implement PageQuery contract', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'pagequery-must-implement-contract' ||
v.ruleName === 'pagequery-must-have-execute' ||
v.ruleName === 'pagequery-execute-return-type'
);
expect(violations.length).toBe(0);
});
it('should enforce: Presenter classes must implement Presenter contract', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'presenter-must-implement-contract' ||
v.ruleName === 'presenter-must-have-present' ||
v.ruleName === 'presenter-must-be-client'
);
expect(violations.length).toBe(0);
});
it('should enforce: ViewModel classes must extend ViewModel contract', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'viewmodel-must-extend-contract' ||
v.ruleName === 'viewmodel-must-be-client'
);
expect(violations.length).toBe(0);
});
it('should enforce: DisplayObject files must export only classes', () => {
const violations = guardrails.getFilteredViolations().filter(
v => v.ruleName === 'no-non-class-display-exports'
);
expect(violations.length).toBe(0);
});
});