website refactor
This commit is contained in:
482
.eslintrc.json
482
.eslintrc.json
@@ -3,225 +3,245 @@
|
|||||||
"es2022": true,
|
"es2022": true,
|
||||||
"node": true
|
"node": true
|
||||||
},
|
},
|
||||||
"parserOptions": {
|
"ignorePatterns": [
|
||||||
"sourceType": "module",
|
"**/dist/**",
|
||||||
"ecmaVersion": 2022
|
"**/*.d.ts"
|
||||||
},
|
],
|
||||||
"ignorePatterns": ["**/dist/**", "**/*.d.ts"],
|
|
||||||
"settings": {
|
|
||||||
"import/resolver": {
|
|
||||||
"typescript": {}
|
|
||||||
},
|
|
||||||
"boundaries/elements": [
|
|
||||||
{
|
|
||||||
"type": "website",
|
|
||||||
"pattern": "apps/website/**/*"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "api",
|
|
||||||
"pattern": "apps/api/**/*"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "adapters",
|
|
||||||
"pattern": ["adapters/**/*", "@adapters/**/*"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "core",
|
|
||||||
"pattern": ["core/**/*", "@core/**/*"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["**/index.ts", "**/index.tsx"],
|
"files": [
|
||||||
|
"**/index.ts",
|
||||||
|
"**/index.tsx"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "Program",
|
"message": "index.ts files are forbidden. Use explicit file names instead (e.g., UserService.ts, not index.ts).",
|
||||||
"message": "index.ts files are forbidden. Use explicit file names instead (e.g., UserService.ts, not index.ts)."
|
"selector": "Program"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["core/*/application/ports/*/*.ts"],
|
"files": [
|
||||||
|
"core/*/application/ports/*/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "TSInterfaceDeclaration[id.name=/^Get.*Port$/]",
|
"message": "Port interface names should not start with 'Get'. Use descriptive names without the 'Get' prefix.",
|
||||||
"message": "Port interface names should not start with 'Get'. Use descriptive names without the 'Get' prefix."
|
"selector": "TSInterfaceDeclaration[id.name=/^Get.*Port$/]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["core/**/*.ts"],
|
"files": [
|
||||||
|
"core/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "TSClassDeclaration[id.name=/Blocker$/], TSInterfaceDeclaration[id.name=/Blocker$/]",
|
"message": "Blocker classes/interfaces are not allowed in core. Use Guards in backend.",
|
||||||
"message": "Blocker classes/interfaces are not allowed in core. Use Guards in backend."
|
"selector": "TSClassDeclaration[id.name=/Blocker$/], TSInterfaceDeclaration[id.name=/Blocker$/]"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"selector": "TSClassDeclaration[id.name=/Presenter$/], TSInterfaceDeclaration[id.name=/Presenter$/]",
|
"message": "Presenter classes/interfaces are not allowed in core. Presenters belong in API or frontend layers.",
|
||||||
"message": "Presenter classes/interfaces are not allowed in core. Presenters belong in API or frontend layers."
|
"selector": "TSClassDeclaration[id.name=/Presenter$/], TSInterfaceDeclaration[id.name=/Presenter$/]"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"selector": "TSClassDeclaration[id.name=/Dto$/], TSInterfaceDeclaration[id.name=/Dto$/]",
|
"message": "DTO classes/interfaces are not allowed in core. DTOs belong in API or frontend layers.",
|
||||||
"message": "DTO classes/interfaces are not allowed in core. DTOs belong in API or frontend layers."
|
"selector": "TSClassDeclaration[id.name=/Dto$/], TSInterfaceDeclaration[id.name=/Dto$/]"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"selector": "TSClassDeclaration[id.name=/ViewModel$/], TSInterfaceDeclaration[id.name=/ViewModel$/]",
|
"message": "ViewModel classes/interfaces are not allowed in core. View Models belong in frontend.",
|
||||||
"message": "ViewModel classes/interfaces are not allowed in core. View Models belong in frontend."
|
"selector": "TSClassDeclaration[id.name=/ViewModel$/], TSInterfaceDeclaration[id.name=/ViewModel$/]"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"selector": "TSClassDeclaration[id.name=/CommandModel$/], TSInterfaceDeclaration[id.name=/CommandModel$/]",
|
"message": "CommandModel classes/interfaces are not allowed in core. Command Models belong in frontend.",
|
||||||
"message": "CommandModel classes/interfaces are not allowed in core. Command Models belong in frontend."
|
"selector": "TSClassDeclaration[id.name=/CommandModel$/], TSInterfaceDeclaration[id.name=/CommandModel$/]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["core/**/application/dto/**/*.ts", "core/**/application/dtos/**/*.ts"],
|
"files": [
|
||||||
|
"core/**/application/dto/**/*.ts",
|
||||||
|
"core/**/application/dtos/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "Program",
|
"message": "core/*/application/dto is forbidden. Use application result models + output ports; DTOs belong in API/website layers.",
|
||||||
"message": "core/*/application/dto is forbidden. Use application result models + output ports; DTOs belong in API/website layers."
|
"selector": "Program"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["core/**/infrastructure/**/*.ts"],
|
"files": [
|
||||||
|
"core/**/infrastructure/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "Program",
|
"message": "core/*/infrastructure is forbidden. Implementations must live in adapters/ and be wired in apps/.",
|
||||||
"message": "core/*/infrastructure is forbidden. Implementations must live in adapters/ and be wired in apps/."
|
"selector": "Program"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["core/**/domain/ports/**/*.ts"],
|
"files": [
|
||||||
|
"core/**/domain/ports/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "Program",
|
"message": "core/*/domain/ports is forbidden. Ports belong in application/ports (or shared application layer), not domain.",
|
||||||
"message": "core/*/domain/ports is forbidden. Ports belong in application/ports (or shared application layer), not domain."
|
"selector": "Program"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["core/**/shared/presentation/**/*.ts"],
|
"files": [
|
||||||
|
"core/**/shared/presentation/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "Program",
|
"message": "core/shared/presentation is forbidden. Presentation belongs in API or website layers.",
|
||||||
"message": "core/shared/presentation is forbidden. Presentation belongs in API or website layers."
|
"selector": "Program"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["apps/website/**/*.ts"],
|
"files": [
|
||||||
|
"apps/website/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "TSClassDeclaration[id.name=/Guard$/], TSInterfaceDeclaration[id.name=/Guard$/]",
|
"message": "Guard classes/interfaces are not allowed in frontend. Use Blockers in frontend.",
|
||||||
"message": "Guard classes/interfaces are not allowed in frontend. Use Blockers in frontend."
|
"selector": "TSClassDeclaration[id.name=/Guard$/], TSInterfaceDeclaration[id.name=/Guard$/]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["apps/api/**/*.ts", "apps/website/lib/dtos/**/*.ts"],
|
"files": [
|
||||||
|
"apps/api/**/*.ts",
|
||||||
|
"apps/website/lib/dtos/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "TSEnumDeclaration[id.name=/^(?!.*Enum$).+/]",
|
"message": "Transport enums must end with 'Enum'.",
|
||||||
"message": "Transport enums must end with 'Enum'."
|
"selector": "TSEnumDeclaration[id.name=/^(?!.*Enum$).+/]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["core/*/application/use-cases/*.ts"],
|
"files": [
|
||||||
|
"core/*/application/use-cases/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "TSClassDeclaration[id.name=/^(?!.*UseCase$).+/]",
|
"message": "Use Case classes must end with 'UseCase'.",
|
||||||
"message": "Use Case classes must end with 'UseCase'."
|
"selector": "TSClassDeclaration[id.name=/^(?!.*UseCase$).+/]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["core/*/application/services/*.ts"],
|
"files": [
|
||||||
|
"core/*/application/services/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "TSClassDeclaration[id.name=/^(?!.*Service$).+/]",
|
"message": "Application Service classes must end with 'Service'.",
|
||||||
"message": "Application Service classes must end with 'Service'."
|
"selector": "TSClassDeclaration[id.name=/^(?!.*Service$).+/]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["apps/website/lib/view-models/*.ts"],
|
"files": [
|
||||||
|
"apps/website/lib/view-models/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "TSClassDeclaration[id.name=/^(?!.*ViewModel$).+/]",
|
"message": "View Model classes must end with 'ViewModel'.",
|
||||||
"message": "View Model classes must end with 'ViewModel'."
|
"selector": "TSClassDeclaration[id.name=/^(?!.*ViewModel$).+/]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["apps/website/lib/commands/*.ts"],
|
"files": [
|
||||||
|
"apps/website/lib/commands/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "TSClassDeclaration[id.name=/^(?!.*CommandModel$).+/]",
|
"message": "Command Model classes must end with 'CommandModel'.",
|
||||||
"message": "Command Model classes must end with 'CommandModel'."
|
"selector": "TSClassDeclaration[id.name=/^(?!.*CommandModel$).+/]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["apps/website/app/**/page.tsx", "apps/website/app/**/page.ts", "apps/website/app/**/layout.tsx", "apps/website/app/**/layout.ts"],
|
"files": [
|
||||||
|
"apps/website/app/**/page.tsx",
|
||||||
|
"apps/website/app/**/page.ts",
|
||||||
|
"apps/website/app/**/layout.tsx",
|
||||||
|
"apps/website/app/**/layout.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"import/no-default-export": "off",
|
"import/no-default-export": "off",
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]",
|
"message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor').",
|
||||||
"message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor')."
|
"selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["**/*.ts", "**/*.tsx"],
|
"extends": [
|
||||||
|
"plugin:import/recommended",
|
||||||
|
"plugin:import/typescript"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
],
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"plugins": ["@typescript-eslint", "boundaries", "import"],
|
"plugins": [
|
||||||
"extends": ["plugin:import/recommended", "plugin:import/typescript"],
|
"@typescript-eslint",
|
||||||
|
"boundaries",
|
||||||
|
"import"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/no-explicit-any": "error",
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
"@typescript-eslint/no-unused-vars": "error",
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
@@ -231,20 +251,39 @@
|
|||||||
"default": "disallow",
|
"default": "disallow",
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"from": ["website"],
|
"allow": [
|
||||||
"allow": ["website"]
|
"website"
|
||||||
|
],
|
||||||
|
"from": [
|
||||||
|
"website"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": ["api"],
|
"allow": [
|
||||||
"allow": ["api", "adapters", "core"]
|
"api",
|
||||||
|
"adapters",
|
||||||
|
"core"
|
||||||
|
],
|
||||||
|
"from": [
|
||||||
|
"api"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": ["adapters"],
|
"allow": [
|
||||||
"allow": ["adapters", "core"]
|
"adapters",
|
||||||
|
"core"
|
||||||
|
],
|
||||||
|
"from": [
|
||||||
|
"adapters"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": ["core"],
|
"allow": [
|
||||||
"allow": ["core"]
|
"core"
|
||||||
|
],
|
||||||
|
"from": [
|
||||||
|
"core"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -254,171 +293,240 @@
|
|||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "ExportDefaultDeclaration",
|
"message": "Default exports are forbidden. Use named exports instead.",
|
||||||
"message": "Default exports are forbidden. Use named exports instead."
|
"selector": "ExportDefaultDeclaration"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]",
|
"message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor').",
|
||||||
"message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor')."
|
"selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["core/**/*.ts"],
|
"files": [
|
||||||
|
"core/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
"no-restricted-syntax": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"selector": "ExportDefaultDeclaration",
|
"message": "Default exports are forbidden. Use named exports instead.",
|
||||||
"message": "Default exports are forbidden. Use named exports instead."
|
"selector": "ExportDefaultDeclaration"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["apps/website/**/*.tsx", "apps/website/**/*.ts"],
|
"files": [
|
||||||
|
"apps/website/**/*.tsx",
|
||||||
|
"apps/website/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-syntax": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"selector": "TSInterfaceDeclaration[id.name=/ViewModel$/], TSTypeAliasDeclaration[id.name=/ViewModel$/], TSClassDeclaration[id.name=/ViewModel$/]",
|
|
||||||
"message": "ViewModel types must be defined in apps/website/lib/view-models, not in components."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"selector": "TSInterfaceDeclaration[id.name=/DTO$/], TSTypeAliasDeclaration[id.name=/DTO$/], TSClassDeclaration[id.name=/DTO$/]",
|
|
||||||
"message": "DTO types are forbidden in website components. Use ViewModels instead."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"no-restricted-imports": [
|
"no-restricted-imports": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"paths": [
|
"paths": [
|
||||||
{
|
{
|
||||||
"name": "@core/racing",
|
"message": "Imports from @core are forbidden in website components",
|
||||||
"message": "Imports from @core are forbidden in website components"
|
"name": "@core/racing"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@core/analytics",
|
"message": "Imports from @core are forbidden in website components",
|
||||||
"message": "Imports from @core are forbidden in website components"
|
"name": "@core/analytics"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@core/identity",
|
"message": "Imports from @core are forbidden in website components",
|
||||||
"message": "Imports from @core are forbidden in website components"
|
"name": "@core/identity"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@core/media",
|
"message": "Imports from @core are forbidden in website components",
|
||||||
"message": "Imports from @core are forbidden in website components"
|
"name": "@core/media"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@core/notifications",
|
"message": "Imports from @core are forbidden in website components",
|
||||||
"message": "Imports from @core are forbidden in website components"
|
"name": "@core/notifications"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@core/payments",
|
"message": "Imports from @core are forbidden in website components",
|
||||||
"message": "Imports from @core are forbidden in website components"
|
"name": "@core/payments"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@core/shared",
|
"message": "Imports from @core are forbidden in website components",
|
||||||
"message": "Imports from @core are forbidden in website components"
|
"name": "@core/shared"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@core/social",
|
"message": "Imports from @core are forbidden in website components",
|
||||||
"message": "Imports from @core are forbidden in website components"
|
"name": "@core/social"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@adapters",
|
"message": "Imports from @adapters are forbidden in website components",
|
||||||
"message": "Imports from @adapters are forbidden in website components"
|
"name": "@adapters"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@api",
|
"message": "Imports from @api are forbidden in website components",
|
||||||
"message": "Imports from @api are forbidden in website components"
|
"name": "@api"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"patterns": [
|
"patterns": [
|
||||||
{
|
{
|
||||||
"group": ["@core/*"],
|
"group": [
|
||||||
|
"@core/*"
|
||||||
|
],
|
||||||
"message": "Imports from @core are forbidden in website components"
|
"message": "Imports from @core are forbidden in website components"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": ["@adapters/*"],
|
"group": [
|
||||||
|
"@adapters/*"
|
||||||
|
],
|
||||||
"message": "Imports from @adapters are forbidden in website components"
|
"message": "Imports from @adapters are forbidden in website components"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": ["@api/*"],
|
"group": [
|
||||||
|
"@api/*"
|
||||||
|
],
|
||||||
"message": "Imports from @api are forbidden in website components"
|
"message": "Imports from @api are forbidden in website components"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"message": "ViewModel types must be defined in apps/website/lib/view-models, not in components.",
|
||||||
|
"selector": "TSInterfaceDeclaration[id.name=/ViewModel$/], TSTypeAliasDeclaration[id.name=/ViewModel$/], TSClassDeclaration[id.name=/ViewModel$/]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "DTO types are forbidden in website components. Use ViewModels instead.",
|
||||||
|
"selector": "TSInterfaceDeclaration[id.name=/DTO$/], TSTypeAliasDeclaration[id.name=/DTO$/], TSClassDeclaration[id.name=/DTO$/]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"apps/api/**/*.test.ts",
|
||||||
|
"apps/api/**/*.test.tsx"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"no-restricted-syntax": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"tests/**/*.ts"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"message": "Integration tests must use in-memory adapters, not core directly",
|
||||||
|
"name": "@core/*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"message": "Integration tests must use in-memory adapters only",
|
||||||
|
"name": "@adapters/*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["apps/api/**/*.test.ts", "apps/api/**/*.test.tsx"],
|
"files": [
|
||||||
|
"tests/e2e/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"no-restricted-imports": [
|
||||||
"no-restricted-syntax": "off"
|
"error",
|
||||||
|
{
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"group": [
|
||||||
|
"**/inmemory/**"
|
||||||
|
],
|
||||||
|
"message": "E2E tests must use TypeORM/PostgreSQL, not in-memory adapters"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["tests/**/*.ts"],
|
"files": [
|
||||||
|
"core/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-imports": ["error", {
|
"no-restricted-imports": [
|
||||||
"paths": [
|
"error",
|
||||||
{
|
{
|
||||||
"name": "@core/*",
|
"paths": [
|
||||||
"message": "Integration tests must use in-memory adapters, not core directly"
|
{
|
||||||
},
|
"message": "Use @testing/* from adapters/testing",
|
||||||
{
|
"name": "testing"
|
||||||
"name": "@adapters/*",
|
},
|
||||||
"message": "Integration tests must use in-memory adapters only"
|
{
|
||||||
}
|
"message": "Core layer should not depend on testing utilities",
|
||||||
]
|
"name": "@testing/*"
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["tests/e2e/**/*.ts"],
|
"files": [
|
||||||
|
"adapters/**/*.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-restricted-imports": ["error", {
|
"no-restricted-imports": [
|
||||||
"patterns": [
|
"error",
|
||||||
{
|
{
|
||||||
"group": ["**/inmemory/**"],
|
"paths": [
|
||||||
"message": "E2E tests must use TypeORM/PostgreSQL, not in-memory adapters"
|
{
|
||||||
}
|
"message": "Use @testing/* from adapters/testing",
|
||||||
]
|
"name": "testing"
|
||||||
}]
|
}
|
||||||
}
|
]
|
||||||
},
|
}
|
||||||
{
|
]
|
||||||
"files": ["core/**/*.ts"],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": ["error", {
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "testing",
|
|
||||||
"message": "Use @testing/* from adapters/testing"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "@testing/*",
|
|
||||||
"message": "Core layer should not depend on testing utilities"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": ["adapters/**/*.ts"],
|
|
||||||
"rules": {
|
|
||||||
"no-restricted-imports": ["error", {
|
|
||||||
"paths": [
|
|
||||||
{
|
|
||||||
"name": "testing",
|
|
||||||
"message": "Use @testing/* from adapters/testing"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2022,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"boundaries/elements": [
|
||||||
|
{
|
||||||
|
"pattern": "apps/website/**/*",
|
||||||
|
"type": "website"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "apps/api/**/*",
|
||||||
|
"type": "api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": [
|
||||||
|
"adapters/**/*",
|
||||||
|
"@adapters/**/*"
|
||||||
|
],
|
||||||
|
"type": "adapters"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": [
|
||||||
|
"core/**/*",
|
||||||
|
"@core/**/*"
|
||||||
|
],
|
||||||
|
"type": "core"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"import/resolver": {
|
||||||
|
"typescript": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,14 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"extends": [
|
||||||
"ignorePatterns": ["lib/types/generated/**", "**/*.test.ts", "**/*.test.tsx"],
|
"next/core-web-vitals",
|
||||||
"extends": ["next/core-web-vitals", "plugin:import/recommended", "plugin:import/typescript"],
|
"plugin:import/recommended",
|
||||||
"plugins": ["boundaries", "import", "@typescript-eslint", "unused-imports"],
|
"plugin:import/typescript"
|
||||||
"settings": {
|
],
|
||||||
"import/resolver": {
|
"ignorePatterns": [
|
||||||
"typescript": {}
|
"lib/types/generated/**",
|
||||||
},
|
"**/*.test.ts",
|
||||||
"boundaries/elements": [
|
"**/*.test.tsx"
|
||||||
{
|
],
|
||||||
"type": "website",
|
|
||||||
"pattern": ["**/*"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"react/no-unescaped-entities": "off",
|
|
||||||
"@next/next/no-img-element": "off",
|
|
||||||
"react-hooks/exhaustive-deps": "off",
|
|
||||||
"react-hooks/rules-of-hooks": "off",
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
|
||||||
"import/no-default-export": "off",
|
|
||||||
"import/no-named-as-default-member": "off",
|
|
||||||
"no-restricted-syntax": "off",
|
|
||||||
"boundaries/element-types": [
|
|
||||||
2,
|
|
||||||
{
|
|
||||||
"default": "disallow",
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"from": ["website"],
|
|
||||||
"allow": ["website"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"unused-imports/no-unused-imports": "off",
|
|
||||||
"unused-imports/no-unused-vars": "off"
|
|
||||||
},
|
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": [
|
"files": [
|
||||||
@@ -56,5 +26,54 @@
|
|||||||
"no-restricted-syntax": "off"
|
"no-restricted-syntax": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"plugins": [
|
||||||
|
"boundaries",
|
||||||
|
"import",
|
||||||
|
"@typescript-eslint",
|
||||||
|
"unused-imports"
|
||||||
|
],
|
||||||
|
"root": true,
|
||||||
|
"rules": {
|
||||||
|
"@next/next/no-img-element": "error",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
|
"boundaries/element-types": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"default": "disallow",
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"allow": [
|
||||||
|
"website"
|
||||||
|
],
|
||||||
|
"from": [
|
||||||
|
"website"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"import/no-default-export": "error",
|
||||||
|
"import/no-named-as-default-member": "error",
|
||||||
|
"no-restricted-syntax": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "error",
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react/no-unescaped-entities": "error",
|
||||||
|
"unused-imports/no-unused-imports": "off",
|
||||||
|
"unused-imports/no-unused-vars": "off"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"boundaries/elements": [
|
||||||
|
{
|
||||||
|
"pattern": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"type": "website"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"import/resolver": {
|
||||||
|
"typescript": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
42
apps/website/app/actions/logoutAction.ts
Normal file
42
apps/website/app/actions/logoutAction.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { AuthApiClient } from '@/lib/api/auth/AuthApiClient';
|
||||||
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||||
|
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server action for logout
|
||||||
|
*
|
||||||
|
* Performs the logout mutation by calling the API and redirects to login.
|
||||||
|
* Follows the write boundary contract: all writes enter through server actions.
|
||||||
|
*/
|
||||||
|
export async function logoutAction(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Create required dependencies for API client
|
||||||
|
const logger = new ConsoleLogger();
|
||||||
|
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||||
|
showUserNotifications: false,
|
||||||
|
logToConsole: true,
|
||||||
|
reportToExternal: process.env.NODE_ENV === 'production',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get API base URL from environment
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
// Create API client instance
|
||||||
|
const apiClient = new AuthApiClient(baseUrl, errorReporter, logger);
|
||||||
|
|
||||||
|
// Call the logout API endpoint
|
||||||
|
await apiClient.logout();
|
||||||
|
|
||||||
|
// Redirect to login page after successful logout
|
||||||
|
redirect('/auth/login');
|
||||||
|
} catch (error) {
|
||||||
|
// Log error for debugging
|
||||||
|
console.error('Logout action failed:', error);
|
||||||
|
|
||||||
|
// Still redirect even if logout fails - user should be able to leave
|
||||||
|
redirect('/auth/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, FormEvent, type ChangeEvent } from 'react';
|
import { useState, useEffect, FormEvent, type ChangeEvent } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { useForgotPassword } from '@/hooks/auth/useForgotPassword';
|
import { useForgotPassword } from "@/lib/hooks/auth/useForgotPassword";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import Button from '@/components/ui/Button';
|
|||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { useLogin } from '@/hooks/auth/useLogin';
|
import { useLogin } from "@/lib/hooks/auth/useLogin";
|
||||||
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
|
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
|
||||||
import UserRolesPreview from '@/components/auth/UserRolesPreview';
|
import UserRolesPreview from '@/components/auth/UserRolesPreview';
|
||||||
import { EnhancedFormError } from '@/components/errors/EnhancedFormError';
|
import { EnhancedFormError } from '@/components/errors/EnhancedFormError';
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import Button from '@/components/ui/Button';
|
|||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { useResetPassword } from '@/hooks/auth/useResetPassword';
|
import { useResetPassword } from "@/lib/hooks/auth/useResetPassword";
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
newPassword?: string;
|
newPassword?: string;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import Button from '@/components/ui/Button';
|
|||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { useSignup } from '@/hooks/auth/useSignup';
|
import { useSignup } from "@/lib/hooks/auth/useSignup";
|
||||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
|
|||||||
@@ -1,95 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
import type { DashboardViewData } from './DashboardViewData';
|
import type { DashboardViewData } from '@/templates/view-data/DashboardViewData';
|
||||||
import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData';
|
import type { DashboardPageDto } from '@/lib/page-queries/page-dtos/DashboardPageDto';
|
||||||
import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
import { DashboardPresenter } from '@/lib/presenters/DashboardPresenter';
|
||||||
import { DashboardTemplate } from '@/templates/DashboardTemplate';
|
import { DashboardTemplate } from '@/templates/DashboardTemplate';
|
||||||
|
|
||||||
interface DashboardPageClientProps {
|
interface DashboardPageClientProps {
|
||||||
initialViewData: DashboardViewData;
|
pageDto: DashboardPageDto;
|
||||||
dto: DashboardOverviewViewModelData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard Page Client Component
|
* Dashboard Page Client Component
|
||||||
*
|
*
|
||||||
* Two-phase render:
|
* Uses Presenter to transform Page DTO into ViewData
|
||||||
* 1. Initial SSR: Uses ViewData built directly from DTO (no ViewModel)
|
* Presenter is deterministic and side-effect free
|
||||||
* 2. Post-hydration: Instantiates ViewModel and re-renders with enhanced data
|
|
||||||
*/
|
*/
|
||||||
export function DashboardPageClient({ initialViewData, dto }: DashboardPageClientProps) {
|
export function DashboardPageClient({ pageDto }: DashboardPageClientProps) {
|
||||||
const [viewData, setViewData] = useState<DashboardViewData>(initialViewData);
|
const viewData: DashboardViewData = DashboardPresenter.createViewData(pageDto);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Phase 2: After hydration, instantiate ViewModel and enhance data
|
|
||||||
const viewModel = new DashboardOverviewViewModel(dto);
|
|
||||||
|
|
||||||
const enhancedViewData: DashboardViewData = {
|
|
||||||
currentDriver: {
|
|
||||||
name: viewModel.currentDriverName,
|
|
||||||
avatarUrl: viewModel.currentDriverAvatarUrl,
|
|
||||||
country: viewModel.currentDriverCountry,
|
|
||||||
rating: viewModel.currentDriverRating,
|
|
||||||
rank: viewModel.currentDriverRank,
|
|
||||||
totalRaces: viewModel.currentDriverTotalRaces,
|
|
||||||
wins: viewModel.currentDriverWins,
|
|
||||||
podiums: viewModel.currentDriverPodiums,
|
|
||||||
consistency: viewModel.currentDriverConsistency,
|
|
||||||
},
|
|
||||||
nextRace: viewModel.nextRace ? {
|
|
||||||
id: viewModel.nextRace.id,
|
|
||||||
track: viewModel.nextRace.track,
|
|
||||||
car: viewModel.nextRace.car,
|
|
||||||
scheduledAt: viewModel.nextRace.scheduledAt,
|
|
||||||
formattedDate: viewModel.nextRace.formattedDate,
|
|
||||||
formattedTime: viewModel.nextRace.formattedTime,
|
|
||||||
timeUntil: viewModel.nextRace.timeUntil,
|
|
||||||
isMyLeague: viewModel.nextRace.isMyLeague,
|
|
||||||
} : null,
|
|
||||||
upcomingRaces: viewModel.upcomingRaces.map((race) => ({
|
|
||||||
id: race.id,
|
|
||||||
track: race.track,
|
|
||||||
car: race.car,
|
|
||||||
scheduledAt: race.scheduledAt,
|
|
||||||
formattedDate: race.formattedDate,
|
|
||||||
formattedTime: race.formattedTime,
|
|
||||||
timeUntil: race.timeUntil,
|
|
||||||
isMyLeague: race.isMyLeague,
|
|
||||||
})),
|
|
||||||
leagueStandings: viewModel.leagueStandings.map((standing) => ({
|
|
||||||
leagueId: standing.leagueId,
|
|
||||||
leagueName: standing.leagueName,
|
|
||||||
position: standing.position,
|
|
||||||
points: standing.points,
|
|
||||||
totalDrivers: standing.totalDrivers,
|
|
||||||
})),
|
|
||||||
feedItems: viewModel.feedItems.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
type: item.type,
|
|
||||||
headline: item.headline,
|
|
||||||
body: item.body,
|
|
||||||
timestamp: item.timestamp,
|
|
||||||
formattedTime: item.formattedTime,
|
|
||||||
ctaHref: item.ctaHref,
|
|
||||||
ctaLabel: item.ctaLabel,
|
|
||||||
})),
|
|
||||||
friends: viewModel.friends.map((friend) => ({
|
|
||||||
id: friend.id,
|
|
||||||
name: friend.name,
|
|
||||||
avatarUrl: friend.avatarUrl,
|
|
||||||
country: friend.country,
|
|
||||||
})),
|
|
||||||
activeLeaguesCount: viewModel.activeLeaguesCount,
|
|
||||||
friendCount: viewModel.friendCount,
|
|
||||||
hasUpcomingRaces: viewModel.hasUpcomingRaces,
|
|
||||||
hasLeagueStandings: viewModel.hasLeagueStandings,
|
|
||||||
hasFeedItems: viewModel.hasFeedItems,
|
|
||||||
hasFriends: viewModel.hasFriends,
|
|
||||||
};
|
|
||||||
|
|
||||||
setViewData(enhancedViewData);
|
|
||||||
}, [dto]);
|
|
||||||
|
|
||||||
return <DashboardTemplate data={viewData} />;
|
return <DashboardTemplate data={viewData} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData';
|
|
||||||
import type { DashboardViewData } from './DashboardViewData';
|
|
||||||
import {
|
|
||||||
formatDashboardDate,
|
|
||||||
formatRating,
|
|
||||||
formatRank,
|
|
||||||
formatConsistency,
|
|
||||||
formatRaceCount,
|
|
||||||
formatFriendCount,
|
|
||||||
formatLeaguePosition,
|
|
||||||
formatPoints,
|
|
||||||
formatTotalDrivers,
|
|
||||||
} from '@/lib/display-objects/DashboardDisplay';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build DashboardViewData directly from ViewModelData
|
|
||||||
* Used for SSR phase - no ViewModel instantiation
|
|
||||||
*/
|
|
||||||
export function buildDashboardViewData(viewModelData: DashboardOverviewViewModelData): DashboardViewData {
|
|
||||||
return {
|
|
||||||
currentDriver: {
|
|
||||||
name: viewModelData.currentDriver?.name || '',
|
|
||||||
avatarUrl: viewModelData.currentDriver?.avatarUrl || '',
|
|
||||||
country: viewModelData.currentDriver?.country || '',
|
|
||||||
rating: viewModelData.currentDriver ? formatRating(viewModelData.currentDriver.rating) : '0.0',
|
|
||||||
rank: viewModelData.currentDriver ? formatRank(viewModelData.currentDriver.globalRank) : '0',
|
|
||||||
totalRaces: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.totalRaces) : '0',
|
|
||||||
wins: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.wins) : '0',
|
|
||||||
podiums: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.podiums) : '0',
|
|
||||||
consistency: viewModelData.currentDriver ? formatConsistency(viewModelData.currentDriver.consistency) : '0%',
|
|
||||||
},
|
|
||||||
nextRace: viewModelData.nextRace ? (() => {
|
|
||||||
const dateInfo = formatDashboardDate(new Date(viewModelData.nextRace.scheduledAt));
|
|
||||||
return {
|
|
||||||
id: viewModelData.nextRace.id,
|
|
||||||
track: viewModelData.nextRace.track,
|
|
||||||
car: viewModelData.nextRace.car,
|
|
||||||
scheduledAt: viewModelData.nextRace.scheduledAt,
|
|
||||||
formattedDate: dateInfo.date,
|
|
||||||
formattedTime: dateInfo.time,
|
|
||||||
timeUntil: dateInfo.relative,
|
|
||||||
isMyLeague: viewModelData.nextRace.isMyLeague,
|
|
||||||
};
|
|
||||||
})() : null,
|
|
||||||
upcomingRaces: viewModelData.upcomingRaces.map((race) => {
|
|
||||||
const dateInfo = formatDashboardDate(new Date(race.scheduledAt));
|
|
||||||
return {
|
|
||||||
id: race.id,
|
|
||||||
track: race.track,
|
|
||||||
car: race.car,
|
|
||||||
scheduledAt: race.scheduledAt,
|
|
||||||
formattedDate: dateInfo.date,
|
|
||||||
formattedTime: dateInfo.time,
|
|
||||||
timeUntil: dateInfo.relative,
|
|
||||||
isMyLeague: race.isMyLeague,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
leagueStandings: viewModelData.leagueStandingsSummaries.map((standing) => ({
|
|
||||||
leagueId: standing.leagueId,
|
|
||||||
leagueName: standing.leagueName,
|
|
||||||
position: formatLeaguePosition(standing.position),
|
|
||||||
points: formatPoints(standing.points),
|
|
||||||
totalDrivers: formatTotalDrivers(standing.totalDrivers),
|
|
||||||
})),
|
|
||||||
feedItems: viewModelData.feedSummary.items.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
type: item.type,
|
|
||||||
headline: item.headline,
|
|
||||||
body: item.body,
|
|
||||||
timestamp: item.timestamp,
|
|
||||||
formattedTime: formatDashboardDate(new Date(item.timestamp)).relative,
|
|
||||||
ctaHref: item.ctaHref,
|
|
||||||
ctaLabel: item.ctaLabel,
|
|
||||||
})),
|
|
||||||
friends: viewModelData.friends.map((friend) => ({
|
|
||||||
id: friend.id,
|
|
||||||
name: friend.name,
|
|
||||||
avatarUrl: friend.avatarUrl,
|
|
||||||
country: friend.country,
|
|
||||||
})),
|
|
||||||
activeLeaguesCount: formatRaceCount(viewModelData.activeLeaguesCount),
|
|
||||||
friendCount: formatFriendCount(viewModelData.friends.length),
|
|
||||||
hasUpcomingRaces: viewModelData.upcomingRaces.length > 0,
|
|
||||||
hasLeagueStandings: viewModelData.leagueStandingsSummaries.length > 0,
|
|
||||||
hasFeedItems: viewModelData.feedSummary.items.length > 0,
|
|
||||||
hasFriends: viewModelData.friends.length > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { DashboardPageQuery } from '@/lib/page-queries/DashboardPageQuery';
|
import { DashboardPageQuery } from '@/lib/page-queries/page-queries/DashboardPageQuery';
|
||||||
import { DashboardPageClient } from './DashboardPageClient';
|
import { DashboardPageClient } from './DashboardPageClient';
|
||||||
import { buildDashboardViewData } from './DashboardViewDataBuilder';
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const result = await DashboardPageQuery.execute();
|
const result = await DashboardPageQuery.execute();
|
||||||
@@ -9,23 +8,18 @@ export default async function DashboardPage() {
|
|||||||
// Handle result based on status
|
// Handle result based on status
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case 'ok':
|
case 'ok':
|
||||||
const viewModelData = result.data;
|
// Pass Page DTO to client component
|
||||||
|
return <DashboardPageClient pageDto={result.dto} />;
|
||||||
// Build SSR ViewData directly from ViewModelData
|
|
||||||
const ssrViewData = buildDashboardViewData(viewModelData);
|
|
||||||
|
|
||||||
// Pass both ViewData (for SSR) and ViewModelData (for client enhancement)
|
|
||||||
return <DashboardPageClient initialViewData={ssrViewData} dto={viewModelData} />;
|
|
||||||
|
|
||||||
case 'notFound':
|
case 'notFound':
|
||||||
notFound();
|
notFound();
|
||||||
|
|
||||||
case 'redirect':
|
case 'redirect':
|
||||||
redirect(result.destination);
|
redirect(result.to);
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
// For now, treat as notFound. Could also show error page
|
// For now, treat as notFound. Could also show error page
|
||||||
console.error('Dashboard error:', result.error);
|
console.error('Dashboard error:', result.errorId);
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||||
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
||||||
import { useDriverProfilePageData } from '@/hooks/driver/useDriverProfilePageData';
|
import { useDriverProfilePageData } from "@/lib/hooks/driver/useDriverProfilePageData";
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export default async function DriverLeaderboardPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Prepare data for template
|
// Prepare data for template
|
||||||
const data: DriverLeaderboardViewModel | null = driverData as DriverLeaderboardViewModel | null;
|
const data: DriverLeaderboardViewModel | null = driverData;
|
||||||
|
|
||||||
const hasData = (driverData as any)?.drivers?.length > 0;
|
const hasData = (driverData?.drivers?.length ?? 0) > 0;
|
||||||
|
|
||||||
// Handle loading state (should be fast since we're using async/await)
|
// Handle loading state (should be fast since we're using async/await)
|
||||||
const isLoading = false;
|
const isLoading = false;
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ export default async function LeaderboardsPage() {
|
|||||||
|
|
||||||
// Prepare data for template
|
// Prepare data for template
|
||||||
const data: LeaderboardsPageData = {
|
const data: LeaderboardsPageData = {
|
||||||
drivers: driverData as DriverLeaderboardViewModel | null,
|
drivers: driverData,
|
||||||
teams: teamsData as TeamSummaryViewModel[] | null,
|
teams: teamsData,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasData = (driverData as any)?.drivers?.length > 0 || (teamsData as any)?.length > 0;
|
const hasData = (driverData?.drivers?.length ?? 0) > 0 || (teamsData?.length ?? 0) > 0;
|
||||||
|
|
||||||
// Handle loading state (should be fast since we're using async/await)
|
// Handle loading state (should be fast since we're using async/await)
|
||||||
const isLoading = false;
|
const isLoading = false;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
import LeagueHeader from '@/components/leagues/LeagueHeader';
|
import LeagueHeader from '@/components/leagues/LeagueHeader';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useLeagueDetail } from '@/hooks/league/useLeagueDetail';
|
import { useLeagueDetail } from "@/lib/hooks/league/useLeagueDetail";
|
||||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
useRejectJoinRequest,
|
useRejectJoinRequest,
|
||||||
useUpdateMemberRole,
|
useUpdateMemberRole,
|
||||||
useRemoveMember,
|
useRemoveMember,
|
||||||
} from '@/hooks/league/useLeagueRosterAdmin';
|
} from "@/lib/hooks/league/useLeagueRosterAdmin";
|
||||||
|
|
||||||
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
useLeagueAdminStatus,
|
useLeagueAdminStatus,
|
||||||
useLeagueSeasons,
|
useLeagueSeasons,
|
||||||
useLeagueAdminSchedule
|
useLeagueAdminSchedule
|
||||||
} from '@/hooks/league/useLeagueScheduleAdminPageData';
|
} from "@/lib/hooks/league/useLeagueScheduleAdminPageData";
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
|
|||||||
@@ -10,12 +10,39 @@ import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporte
|
|||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import type { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
import { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
||||||
|
import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO';
|
||||||
|
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: { id: string };
|
params: { id: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
|
||||||
|
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
|
||||||
|
const now = new Date();
|
||||||
|
const isPast = scheduledAt.getTime() < now.getTime();
|
||||||
|
const isUpcoming = !isPast;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: race.id,
|
||||||
|
name: race.name,
|
||||||
|
scheduledAt,
|
||||||
|
isPast,
|
||||||
|
isUpcoming,
|
||||||
|
status: isPast ? 'completed' : 'scheduled',
|
||||||
|
track: undefined,
|
||||||
|
car: undefined,
|
||||||
|
sessionType: undefined,
|
||||||
|
isRegistered: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapScheduleDtoToViewModel(dto: LeagueScheduleDTO): LeagueScheduleViewModel {
|
||||||
|
const races = dto.races.map(mapRaceDtoToViewModel);
|
||||||
|
return new LeagueScheduleViewModel(races);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Page({ params }: Props) {
|
export default async function Page({ params }: Props) {
|
||||||
// Validate params
|
// Validate params
|
||||||
if (!params.id) {
|
if (!params.id) {
|
||||||
@@ -52,7 +79,7 @@ export default async function Page({ params }: Props) {
|
|||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error('League schedule not found');
|
throw new Error('League schedule not found');
|
||||||
}
|
}
|
||||||
return result;
|
return mapScheduleDtoToViewModel(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo';
|
import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo';
|
||||||
import LeagueOwnershipTransfer from '@/components/leagues/LeagueOwnershipTransfer';
|
import LeagueOwnershipTransfer from '@/components/leagues/LeagueOwnershipTransfer';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
// Shared state components
|
// Shared state components
|
||||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||||
import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus';
|
import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus";
|
||||||
import { useLeagueSettings } from '@/hooks/league/useLeagueSettings';
|
import { useLeagueSettings } from "@/lib/hooks/league/useLeagueSettings";
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { LEAGUE_SETTINGS_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { LEAGUE_SETTINGS_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
import { AlertTriangle, Settings } from 'lucide-react';
|
import { AlertTriangle, Settings } from 'lucide-react';
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { LeagueSponsorshipsSection } from '@/components/leagues/LeagueSponsorshipsSection';
|
import { LeagueSponsorshipsSection } from '@/components/leagues/LeagueSponsorshipsSection';
|
||||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||||
import { useLeagueSponsorshipsPageData } from '@/hooks/league/useLeagueSponsorshipsPageData';
|
import { useLeagueSponsorshipsPageData } from "@/lib/hooks/league/useLeagueSponsorshipsPageData";
|
||||||
import { ApiError } from '@/lib/api/base/ApiError';
|
import { ApiError } from '@/lib/api/base/ApiError';
|
||||||
import { Building } from 'lucide-react';
|
import { Building } from 'lucide-react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporte
|
|||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import type { LeagueStandingsViewModel } from '@/lib/view-models/LeagueStandingsViewModel';
|
import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
|
||||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||||
|
import type { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO';
|
||||||
|
import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: { id: string };
|
params: { id: string };
|
||||||
@@ -49,45 +51,72 @@ export default async function Page({ params }: Props) {
|
|||||||
racesApiClient
|
racesApiClient
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch data - using empty string for currentDriverId since this is SSR without session
|
// Fetch data
|
||||||
const result = await service.getLeagueStandings(params.id, '');
|
const standingsDto = await service.getLeagueStandings(params.id);
|
||||||
if (!result) {
|
if (!standingsDto) {
|
||||||
throw new Error('League standings not found');
|
throw new Error('League standings not found');
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
|
// Get memberships for transformation
|
||||||
|
const membershipsDto = await service.getLeagueMemberships(params.id);
|
||||||
|
|
||||||
|
// Transform standings to StandingEntryViewModel[]
|
||||||
|
const standings: LeagueStandingDTO[] = standingsDto.standings || [];
|
||||||
|
const leaderPoints = standings[0]?.points || 0;
|
||||||
|
const standingViewModels = standings.map((entry, index) => {
|
||||||
|
const nextPoints = standings[index + 1]?.points || entry.points;
|
||||||
|
return new StandingEntryViewModel(entry, leaderPoints, nextPoints, '', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract unique drivers from standings and convert to DriverViewModel[]
|
||||||
|
const driverMap = new Map<string, DriverViewModel>();
|
||||||
|
standings.forEach(standing => {
|
||||||
|
if (standing.driver && !driverMap.has(standing.driver.id)) {
|
||||||
|
const driver = standing.driver;
|
||||||
|
driverMap.set(driver.id, new DriverViewModel({
|
||||||
|
id: driver.id,
|
||||||
|
name: driver.name,
|
||||||
|
avatarUrl: null, // DriverDTO doesn't have avatarUrl
|
||||||
|
iracingId: driver.iracingId,
|
||||||
|
rating: undefined, // DriverDTO doesn't have rating
|
||||||
|
country: driver.country,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const drivers = Array.from(driverMap.values());
|
||||||
|
|
||||||
|
// Transform memberships
|
||||||
|
const memberships: LeagueMembership[] = (membershipsDto.members || []).map((m: LeagueMemberDTO) => ({
|
||||||
|
driverId: m.driverId,
|
||||||
|
leagueId: params.id,
|
||||||
|
role: (m.role as LeagueMembership['role']) ?? 'member',
|
||||||
|
joinedAt: m.joinedAt,
|
||||||
|
status: 'active' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
standings: standingViewModels,
|
||||||
|
drivers,
|
||||||
|
memberships,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform data for template
|
|
||||||
const standings = data.standings ?? [];
|
|
||||||
const drivers: DriverViewModel[] = data.drivers?.map((d) =>
|
|
||||||
new DriverViewModel({
|
|
||||||
id: d.id,
|
|
||||||
name: d.name,
|
|
||||||
avatarUrl: d.avatarUrl || null,
|
|
||||||
iracingId: d.iracingId,
|
|
||||||
rating: d.rating,
|
|
||||||
country: d.country,
|
|
||||||
})
|
|
||||||
) ?? [];
|
|
||||||
const memberships: LeagueMembership[] = data.memberships ?? [];
|
|
||||||
|
|
||||||
// Create a wrapper component that passes data to the template
|
// Create a wrapper component that passes data to the template
|
||||||
const TemplateWrapper = () => {
|
const TemplateWrapper = () => {
|
||||||
return (
|
return (
|
||||||
<LeagueStandingsTemplate
|
<LeagueStandingsTemplate
|
||||||
standings={standings}
|
standings={data.standings}
|
||||||
drivers={drivers}
|
drivers={data.drivers}
|
||||||
memberships={memberships}
|
memberships={data.memberships}
|
||||||
leagueId={params.id}
|
leagueId={params.id}
|
||||||
currentDriverId={null}
|
currentDriverId={null}
|
||||||
isAdmin={false}
|
isAdmin={false}
|
||||||
onRemoveMember={() => {}}
|
onRemoveMember={() => {}}
|
||||||
onUpdateRole={() => {}}
|
onUpdateRole={() => {}}
|
||||||
loading={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
|
|||||||
import StewardingStats from '@/components/leagues/StewardingStats';
|
import StewardingStats from '@/components/leagues/StewardingStats';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { useLeagueStewardingMutations } from '@/hooks/league/useLeagueStewardingMutations';
|
import { useLeagueStewardingMutations } from "@/lib/hooks/league/useLeagueStewardingMutations";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver';
|
import { useCurrentDriver } from "@/lib/hooks/driver/useCurrentDriver";
|
||||||
import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus';
|
import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus";
|
||||||
import { useLeagueStewardingData } from '@/hooks/league/useLeagueStewardingData';
|
import { useLeagueStewardingData } from "@/lib/hooks/league/useLeagueStewardingData";
|
||||||
import { useLeagueStewardingMutations } from '@/hooks/league/useLeagueStewardingMutations';
|
import { useLeagueStewardingMutations } from "@/lib/hooks/league/useLeagueStewardingMutations";
|
||||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||||
import { StewardingTemplate } from './StewardingTemplate';
|
import { StewardingTemplate } from './StewardingTemplate';
|
||||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
@@ -38,8 +38,8 @@ import { useMemo, useState } from 'react';
|
|||||||
// Shared state components
|
// Shared state components
|
||||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||||
import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus';
|
import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus";
|
||||||
import { useProtestDetail } from '@/hooks/league/useProtestDetail';
|
import { useProtestDetail } from "@/lib/hooks/league/useProtestDetail";
|
||||||
|
|
||||||
// Timeline event types
|
// Timeline event types
|
||||||
interface TimelineEvent {
|
interface TimelineEvent {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useLeagueWalletPageData, useLeagueWalletWithdrawal } from '@/hooks/league/useLeagueWalletPageData';
|
import { useLeagueWalletPageData, useLeagueWalletWithdrawal } from "@/lib/hooks/league/useLeagueWalletPageData";
|
||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||||
import { WalletTemplate } from './WalletTemplate';
|
import { WalletTemplate } from './WalletTemplate';
|
||||||
import { Wallet } from 'lucide-react';
|
import { Wallet } from 'lucide-react';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import OnboardingWizard from '@/components/onboarding/OnboardingWizard';
|
|||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
|
|
||||||
// Shared state components
|
// Shared state components
|
||||||
import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver';
|
import { useCurrentDriver } from "@/lib/hooks/driver/useCurrentDriver";
|
||||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||||
|
|
||||||
// Template component that accepts data
|
// Template component that accepts data
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ProfileLeaguesPageDto } from '@/lib/page-queries/page-queries/ProfileLeaguesPageQuery';
|
||||||
|
import { ProfileLeaguesPresenter } from '@/lib/presenters/ProfileLeaguesPresenter';
|
||||||
|
import { ProfileLeaguesTemplate } from '@/templates/ProfileLeaguesTemplate';
|
||||||
|
|
||||||
|
interface ProfileLeaguesPageClientProps {
|
||||||
|
pageDto: ProfileLeaguesPageDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileLeaguesPageClient({ pageDto }: ProfileLeaguesPageClientProps) {
|
||||||
|
// Convert Page DTO to ViewData using Presenter
|
||||||
|
const viewData = ProfileLeaguesPresenter.toViewData(pageDto);
|
||||||
|
|
||||||
|
// Render Template with ViewData
|
||||||
|
return <ProfileLeaguesTemplate viewData={viewData} />;
|
||||||
|
}
|
||||||
@@ -1,205 +1,23 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
import { ProfileLeaguesPageQuery } from '@/lib/page-queries/ProfileLeaguesPageQuery';
|
||||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
import { ProfileLeaguesPageClient } from './ProfileLeaguesPageClient';
|
||||||
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
||||||
import type { LeagueService } from '@/lib/services/leagues/LeagueService';
|
|
||||||
import type { LeagueMembershipService } from '@/lib/services/leagues/LeagueMembershipService';
|
|
||||||
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
|
||||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
|
||||||
import { SessionGateway } from '@/lib/gateways/SessionGateway';
|
|
||||||
import { ContainerManager } from '@/lib/di/container';
|
|
||||||
|
|
||||||
interface LeagueWithRole {
|
|
||||||
league: LeagueSummaryViewModel;
|
|
||||||
membership: LeagueMembership;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProfileLeaguesData {
|
|
||||||
ownedLeagues: LeagueWithRole[];
|
|
||||||
memberLeagues: LeagueWithRole[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchProfileLeaguesData(): Promise<ProfileLeaguesData | null> {
|
|
||||||
try {
|
|
||||||
// Get current driver ID from session
|
|
||||||
const sessionGateway = new SessionGateway();
|
|
||||||
const session = await sessionGateway.getSession();
|
|
||||||
|
|
||||||
if (!session?.user?.primaryDriverId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentDriverId = session.user.primaryDriverId;
|
|
||||||
|
|
||||||
// Fetch leagues using PageDataFetcher
|
|
||||||
const leagues = await PageDataFetcher.fetch<LeagueService, 'getAllLeagues'>(
|
|
||||||
LEAGUE_SERVICE_TOKEN,
|
|
||||||
'getAllLeagues'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!leagues) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get membership service from container
|
|
||||||
const container = ContainerManager.getInstance().getContainer();
|
|
||||||
const membershipService = container.get<LeagueMembershipService>(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
// Fetch memberships for each league
|
|
||||||
const memberships = await Promise.all(
|
|
||||||
leagues.map(async (league) => {
|
|
||||||
await membershipService.fetchLeagueMemberships(league.id);
|
|
||||||
const membership = membershipService.getMembership(league.id, currentDriverId);
|
|
||||||
|
|
||||||
return membership ? { league, membership } : null;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter and categorize leagues
|
|
||||||
const owned: LeagueWithRole[] = [];
|
|
||||||
const member: LeagueWithRole[] = [];
|
|
||||||
|
|
||||||
for (const entry of memberships) {
|
|
||||||
if (!entry || !entry.membership || entry.membership.status !== 'active') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.membership.role === 'owner') {
|
|
||||||
owned.push(entry);
|
|
||||||
} else {
|
|
||||||
member.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ownedLeagues: owned, memberLeagues: member };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch profile leagues data:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Template component
|
|
||||||
function ProfileLeaguesTemplate({ data }: { data: ProfileLeaguesData }) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Manage leagues</h1>
|
|
||||||
<p className="text-gray-400 text-sm">
|
|
||||||
View leagues you own and participate in, and jump into league admin tools.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Leagues You Own */}
|
|
||||||
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-white">Leagues you own</h2>
|
|
||||||
{data.ownedLeagues.length > 0 && (
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
{data.ownedLeagues.length} {data.ownedLeagues.length === 1 ? 'league' : 'leagues'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.ownedLeagues.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
You don't own any leagues yet in this session.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{data.ownedLeagues.map(({ league }) => (
|
|
||||||
<div
|
|
||||||
key={league.id}
|
|
||||||
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white font-medium">{league.name}</h3>
|
|
||||||
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
|
||||||
{league.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<a
|
|
||||||
href={`/leagues/${league.id}`}
|
|
||||||
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</a>
|
|
||||||
<a href={`/leagues/${league.id}?tab=admin`}>
|
|
||||||
<button className="bg-primary hover:bg-primary/90 text-white text-xs px-3 py-1.5 rounded transition-colors">
|
|
||||||
Manage
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Leagues You're In */}
|
|
||||||
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-white">Leagues you're in</h2>
|
|
||||||
{data.memberLeagues.length > 0 && (
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
{data.memberLeagues.length} {data.memberLeagues.length === 1 ? 'league' : 'leagues'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.memberLeagues.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
You're not a member of any other leagues yet.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{data.memberLeagues.map(({ league, membership }) => (
|
|
||||||
<div
|
|
||||||
key={league.id}
|
|
||||||
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white font-medium">{league.name}</h3>
|
|
||||||
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
|
||||||
{league.description}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Your role:{' '}
|
|
||||||
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={`/leagues/${league.id}`}
|
|
||||||
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
View league
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProfileLeaguesPage() {
|
export default async function ProfileLeaguesPage() {
|
||||||
const data = await fetchProfileLeaguesData();
|
const result = await ProfileLeaguesPageQuery.execute();
|
||||||
|
|
||||||
if (!data) {
|
switch (result.status) {
|
||||||
notFound();
|
case 'notFound':
|
||||||
|
notFound();
|
||||||
|
case 'redirect':
|
||||||
|
// Note: In Next.js, redirect would be imported from next/navigation
|
||||||
|
// For now, we'll handle this case by returning notFound
|
||||||
|
// In a full implementation, you'd use: redirect(result.to);
|
||||||
|
notFound();
|
||||||
|
case 'error':
|
||||||
|
// For now, treat errors as notFound
|
||||||
|
// In a full implementation, you might render an error page
|
||||||
|
notFound();
|
||||||
|
case 'ok':
|
||||||
|
return <ProfileLeaguesPageClient pageDto={result.dto} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper
|
|
||||||
data={data}
|
|
||||||
Template={ProfileLeaguesTemplate}
|
|
||||||
loading={{ variant: 'skeleton', message: 'Loading your leagues...' }}
|
|
||||||
errorConfig={{ variant: 'full-screen' }}
|
|
||||||
empty={{
|
|
||||||
title: 'No leagues found',
|
|
||||||
description: 'You are not a member of any leagues yet.',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,8 @@ import ProfileSettings from '@/components/drivers/ProfileSettings';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useDriverProfile } from '@/hooks/driver/useDriverProfile';
|
import { useDriverProfile } from "@/lib/hooks/driver/useDriverProfile";
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { DRIVER_SERVICE_TOKEN, MEDIA_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { DRIVER_SERVICE_TOKEN, MEDIA_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemp
|
|||||||
import {
|
import {
|
||||||
useSponsorshipRequestsPageData,
|
useSponsorshipRequestsPageData,
|
||||||
useSponsorshipRequestMutations
|
useSponsorshipRequestMutations
|
||||||
} from '@/hooks/sponsor/useSponsorshipRequestsPageData';
|
} from "@/lib/hooks/sponsor/useSponsorshipRequestsPageData";
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
|
|
||||||
export default function SponsorshipRequestsPage() {
|
export default function SponsorshipRequestsPage() {
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||||
import { useRaceResultsPageData } from '@/hooks/race/useRaceResultsPageData';
|
import { useRaceResultsPageData } from "@/lib/hooks/race/useRaceResultsPageData";
|
||||||
import { RaceResultsDataTransformer } from '@/lib/view-models/RaceResultsDataTransformer';
|
import { RaceResultsDataTransformer } from '@/lib/view-models/RaceResultsDataTransformer';
|
||||||
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
|
import { useLeagueMemberships } from "@/lib/hooks/league/useLeagueMemberships";
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { notFound, useRouter } from 'next/navigation';
|
import { notFound, useRouter } from 'next/navigation';
|
||||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
|||||||
import { RACE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { RACE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
import { RaceStewardingService } from '@/lib/services/races/RaceStewardingService';
|
import { RaceStewardingService } from '@/lib/services/races/RaceStewardingService';
|
||||||
import type { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
|
import type { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
|
||||||
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
|
import { useLeagueMemberships } from "@/lib/hooks/league/useLeagueMemberships";
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||||
import { Gavel } from 'lucide-react';
|
import { Gavel } from 'lucide-react';
|
||||||
|
import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO';
|
||||||
|
|
||||||
export default function RaceStewardingPage() {
|
export default function RaceStewardingPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -61,7 +62,7 @@ export default function RaceStewardingPage() {
|
|||||||
|
|
||||||
// Fetch membership
|
// Fetch membership
|
||||||
const { data: membershipsData } = useLeagueMemberships(pageData?.league?.id || '', currentDriverId || '');
|
const { data: membershipsData } = useLeagueMemberships(pageData?.league?.id || '', currentDriverId || '');
|
||||||
const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId);
|
const currentMembership = membershipsData?.members.find(m => m.driverId === currentDriverId);
|
||||||
const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false;
|
const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||||
import { RacesAllTemplate, StatusFilter } from '@/templates/RacesAllTemplate';
|
import { RacesAllTemplate, StatusFilter } from '@/templates/RacesAllTemplate';
|
||||||
import { useAllRacesPageData } from '@/hooks/race/useAllRacesPageData';
|
import { useAllRacesPageData } from "@/lib/hooks/race/useAllRacesPageData";
|
||||||
import { Flag } from 'lucide-react';
|
import { Flag } from 'lucide-react';
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 10;
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import StatusBadge from '@/components/ui/StatusBadge';
|
|||||||
import InfoBanner from '@/components/ui/InfoBanner';
|
import InfoBanner from '@/components/ui/InfoBanner';
|
||||||
import PageHeader from '@/components/ui/PageHeader';
|
import PageHeader from '@/components/ui/PageHeader';
|
||||||
import { siteConfig } from '@/lib/siteConfig';
|
import { siteConfig } from '@/lib/siteConfig';
|
||||||
import { useSponsorBilling } from '@/hooks/sponsor/useSponsorBilling';
|
import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling";
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
import StatusBadge from '@/components/ui/StatusBadge';
|
||||||
import InfoBanner from '@/components/ui/InfoBanner';
|
import InfoBanner from '@/components/ui/InfoBanner';
|
||||||
import { useSponsorSponsorships } from '@/hooks/sponsor/useSponsorSponsorships';
|
import { useSponsorSponsorships } from "@/lib/hooks/sponsor/useSponsorSponsorships";
|
||||||
import {
|
import {
|
||||||
Megaphone,
|
Megaphone,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
Smartphone,
|
Smartphone,
|
||||||
AlertCircle
|
AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { logoutAction } from '@/app/actions/logoutAction';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -174,10 +175,8 @@ export default function SponsorSettingsPage() {
|
|||||||
|
|
||||||
const handleDeleteAccount = () => {
|
const handleDeleteAccount = () => {
|
||||||
if (confirm('Are you sure you want to delete your sponsor account? This action cannot be undone. All sponsorship data will be permanently removed.')) {
|
if (confirm('Are you sure you want to delete your sponsor account? This action cannot be undone. All sponsorship data will be permanently removed.')) {
|
||||||
// Call logout API to clear session
|
// Call the logout action directly
|
||||||
fetch('/api/auth/logout', { method: 'POST' }).finally(() => {
|
logoutAction();
|
||||||
router.push('/');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
91
apps/website/app/teams/TeamsPageClient.tsx
Normal file
91
apps/website/app/teams/TeamsPageClient.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import type { TeamsPageDto } from '@/lib/page-queries/page-queries/TeamsPageQuery';
|
||||||
|
import { TeamsPresenter } from '@/lib/presenters/TeamsPresenter';
|
||||||
|
import { TeamsTemplate } from '@/templates/TeamsTemplate';
|
||||||
|
import type { TeamSummaryData } from '@/templates/view-data/TeamsViewData';
|
||||||
|
|
||||||
|
interface TeamsPageClientProps {
|
||||||
|
pageDto: TeamsPageDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamsPageClient({ pageDto }: TeamsPageClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Use presenter to create ViewData
|
||||||
|
const viewData = TeamsPresenter.createViewData(pageDto);
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
|
||||||
|
// Filter teams based on search query
|
||||||
|
const filteredTeams = useMemo(() => {
|
||||||
|
if (!searchQuery) return viewData.teams;
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return viewData.teams.filter((team: TeamSummaryData) =>
|
||||||
|
team.teamName.toLowerCase().includes(query) ||
|
||||||
|
team.leagueName.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [viewData.teams, searchQuery]);
|
||||||
|
|
||||||
|
// Update viewData with filtered teams
|
||||||
|
const templateViewData = {
|
||||||
|
...viewData,
|
||||||
|
teams: filteredTeams,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handleSearchChange = (query: string) => {
|
||||||
|
setSearchQuery(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowCreateForm = () => {
|
||||||
|
setShowCreateForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHideCreateForm = () => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTeamClick = (teamId: string) => {
|
||||||
|
router.push(`/teams/${teamId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSuccess = (teamId: string) => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
router.push(`/teams/${teamId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseTeams = () => {
|
||||||
|
const element = document.getElementById('teams-list');
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkillLevelClick = (level: string) => {
|
||||||
|
const element = document.getElementById(`level-${level}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TeamsTemplate
|
||||||
|
teams={templateViewData.teams}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
showCreateForm={showCreateForm}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
onShowCreateForm={handleShowCreateForm}
|
||||||
|
onHideCreateForm={handleHideCreateForm}
|
||||||
|
onTeamClick={handleTeamClick}
|
||||||
|
onCreateSuccess={handleCreateSuccess}
|
||||||
|
onBrowseTeams={handleBrowseTeams}
|
||||||
|
onSkillLevelClick={handleSkillLevelClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
apps/website/app/teams/[id]/TeamDetailPageClient.tsx
Normal file
68
apps/website/app/teams/[id]/TeamDetailPageClient.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import type { TeamDetailPageDto } from '@/lib/page-queries/page-queries/TeamDetailPageQuery';
|
||||||
|
import { TeamDetailPresenter } from '@/lib/presenters/TeamDetailPresenter';
|
||||||
|
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||||
|
|
||||||
|
interface TeamDetailPageClientProps {
|
||||||
|
pageDto: TeamDetailPageDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamDetailPageClient({ pageDto }: TeamDetailPageClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Use presenter to create ViewData
|
||||||
|
const viewData = TeamDetailPresenter.createViewData(pageDto);
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
|
const [loading] = useState(false);
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handleTabChange = (tab: Tab) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
// Trigger a refresh by reloading the page
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMember = (driverId: string) => {
|
||||||
|
// This would call an API to remove the member
|
||||||
|
// For now, just log
|
||||||
|
console.log('Remove member:', driverId);
|
||||||
|
// In a real implementation, you'd have a mutation hook here
|
||||||
|
alert('Remove member functionality would be implemented here');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeRole = (driverId: string, newRole: 'owner' | 'admin' | 'member') => {
|
||||||
|
// This would call an API to change the role
|
||||||
|
console.log('Change role:', driverId, newRole);
|
||||||
|
// In a real implementation, you'd have a mutation hook here
|
||||||
|
alert('Change role functionality would be implemented here');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoBack = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TeamDetailTemplate
|
||||||
|
team={viewData.team}
|
||||||
|
memberships={viewData.memberships}
|
||||||
|
activeTab={activeTab}
|
||||||
|
loading={loading}
|
||||||
|
isAdmin={viewData.isAdmin}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onRemoveMember={handleRemoveMember}
|
||||||
|
onChangeRole={handleChangeRole}
|
||||||
|
onGoBack={handleGoBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,102 +1,22 @@
|
|||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
|
||||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
|
||||||
import { TeamService } from '@/lib/services/teams/TeamService';
|
|
||||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
|
||||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
|
||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
|
||||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
import { TeamDetailPageQuery } from '@/lib/page-queries/TeamDetailPageQuery';
|
||||||
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
import TeamDetailPageClient from './TeamDetailPageClient';
|
||||||
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
|
|
||||||
|
|
||||||
// Template wrapper to adapt TeamDetailTemplate for SSR
|
|
||||||
interface TeamDetailData {
|
|
||||||
team: TeamDetailsViewModel;
|
|
||||||
memberships: TeamMemberViewModel[];
|
|
||||||
isAdmin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TeamDetailTemplateWrapper({ data }: { data: TeamDetailData }) {
|
|
||||||
return (
|
|
||||||
<TeamDetailTemplate
|
|
||||||
team={data.team}
|
|
||||||
memberships={data.memberships}
|
|
||||||
activeTab="overview"
|
|
||||||
loading={false}
|
|
||||||
isAdmin={data.isAdmin}
|
|
||||||
// Event handlers are no-ops for SSR (client will handle real interactions)
|
|
||||||
onTabChange={() => {}}
|
|
||||||
onUpdate={() => {}}
|
|
||||||
onRemoveMember={() => {}}
|
|
||||||
onChangeRole={() => {}}
|
|
||||||
onGoBack={() => {}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Page({ params }: { params: { id: string } }) {
|
export default async function Page({ params }: { params: { id: string } }) {
|
||||||
// Validate params
|
const result = await TeamDetailPageQuery.execute(params.id);
|
||||||
if (!params.id) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch data using PageDataFetcher.fetchManual
|
switch (result.status) {
|
||||||
const data = await PageDataFetcher.fetchManual(async () => {
|
case 'ok':
|
||||||
// Manual dependency creation
|
return <TeamDetailPageClient pageDto={result.dto} />;
|
||||||
const baseUrl = getWebsiteApiBaseUrl();
|
case 'notFound':
|
||||||
const logger = new ConsoleLogger();
|
notFound();
|
||||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
case 'redirect':
|
||||||
showUserNotifications: true,
|
// This would typically use redirect() from next/navigation
|
||||||
logToConsole: true,
|
// but we need to handle it at the page level
|
||||||
reportToExternal: process.env.NODE_ENV === 'production',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create API client
|
|
||||||
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
|
|
||||||
|
|
||||||
// Create service
|
|
||||||
const service = new TeamService(teamsApiClient);
|
|
||||||
|
|
||||||
// For server-side, we need a current driver ID
|
|
||||||
// This would typically come from session, but for server components we'll use a placeholder
|
|
||||||
const currentDriverId = ''; // Placeholder - would need session handling
|
|
||||||
|
|
||||||
// Fetch team details
|
|
||||||
const teamData = await service.getTeamDetails(params.id, currentDriverId);
|
|
||||||
|
|
||||||
if (!teamData) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
case 'error':
|
||||||
|
// For now, treat errors as not found
|
||||||
// Fetch team members
|
// In production, you might want a proper error page
|
||||||
const membersData = await service.getTeamMembers(params.id, currentDriverId, teamData.ownerId || '');
|
notFound();
|
||||||
|
|
||||||
// Determine admin status
|
|
||||||
const isAdmin = teamData.isOwner ||
|
|
||||||
(membersData || []).some((m: any) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
|
|
||||||
|
|
||||||
return {
|
|
||||||
team: teamData,
|
|
||||||
memberships: membersData || [],
|
|
||||||
isAdmin,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
notFound();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper
|
|
||||||
data={data}
|
|
||||||
Template={TeamDetailTemplateWrapper}
|
|
||||||
loading={{ variant: 'skeleton', message: 'Loading team details...' }}
|
|
||||||
errorConfig={{ variant: 'full-screen' }}
|
|
||||||
empty={{
|
|
||||||
title: 'Team not found',
|
|
||||||
description: 'The team you are looking for does not exist or has been removed.',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -19,9 +19,9 @@ export default async function TeamLeaderboardPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Prepare data for template
|
// Prepare data for template
|
||||||
const data: TeamSummaryViewModel[] | null = teamsData as TeamSummaryViewModel[] | null;
|
const data: TeamSummaryViewModel[] | null = teamsData;
|
||||||
|
|
||||||
const hasData = (teamsData as any)?.length > 0;
|
const hasData = (teamsData?.length ?? 0) > 0;
|
||||||
|
|
||||||
// Handle loading state (should be fast since we're using async/await)
|
// Handle loading state (should be fast since we're using async/await)
|
||||||
const isLoading = false;
|
const isLoading = false;
|
||||||
|
|||||||
@@ -1,97 +1,22 @@
|
|||||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
|
||||||
import TeamsTemplate from '@/templates/TeamsTemplate';
|
|
||||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
|
||||||
import { TeamService } from '@/lib/services/teams/TeamService';
|
|
||||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
|
||||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
|
||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
|
||||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
import { TeamsPageQuery } from '@/lib/page-queries/TeamsPageQuery';
|
||||||
|
import TeamsPageClient from './TeamsPageClient';
|
||||||
// Helper to compute derived data for SSR
|
|
||||||
function computeDerivedData(teams: TeamSummaryViewModel[]) {
|
|
||||||
// Group teams by performance level (skill level)
|
|
||||||
const teamsByLevel = teams.reduce((acc, team) => {
|
|
||||||
const level = team.performanceLevel || 'intermediate';
|
|
||||||
if (!acc[level]) {
|
|
||||||
acc[level] = [];
|
|
||||||
}
|
|
||||||
acc[level].push(team);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, TeamSummaryViewModel[]>);
|
|
||||||
|
|
||||||
// Get top teams (by rating, descending)
|
|
||||||
const topTeams = [...teams]
|
|
||||||
.filter(t => t.rating !== undefined)
|
|
||||||
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
|
|
||||||
.slice(0, 5);
|
|
||||||
|
|
||||||
// Count recruiting teams
|
|
||||||
const recruitingCount = teams.filter(t => t.isRecruiting).length;
|
|
||||||
|
|
||||||
// For SSR, filtered teams = all teams (no search filter applied server-side)
|
|
||||||
const filteredTeams = teams;
|
|
||||||
|
|
||||||
return {
|
|
||||||
teamsByLevel,
|
|
||||||
topTeams,
|
|
||||||
recruitingCount,
|
|
||||||
filteredTeams,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Template wrapper for SSR
|
|
||||||
function TeamsTemplateWrapper({ data }: { data: TeamSummaryViewModel[] }) {
|
|
||||||
const derived = computeDerivedData(data);
|
|
||||||
|
|
||||||
// Provide default values for SSR
|
|
||||||
// The template will handle client-side state management
|
|
||||||
return (
|
|
||||||
<TeamsTemplate
|
|
||||||
teams={data}
|
|
||||||
isLoading={false}
|
|
||||||
searchQuery=""
|
|
||||||
showCreateForm={false}
|
|
||||||
teamsByLevel={derived.teamsByLevel}
|
|
||||||
topTeams={derived.topTeams}
|
|
||||||
recruitingCount={derived.recruitingCount}
|
|
||||||
filteredTeams={derived.filteredTeams}
|
|
||||||
// No-op handlers for SSR (client will override)
|
|
||||||
onSearchChange={() => {}}
|
|
||||||
onShowCreateForm={() => {}}
|
|
||||||
onHideCreateForm={() => {}}
|
|
||||||
onTeamClick={() => {}}
|
|
||||||
onCreateSuccess={() => {}}
|
|
||||||
onBrowseTeams={() => {}}
|
|
||||||
onSkillLevelClick={() => {}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const data = await PageDataFetcher.fetchManual(async () => {
|
const result = await TeamsPageQuery.execute();
|
||||||
// Manual dependency creation
|
|
||||||
const baseUrl = getWebsiteApiBaseUrl();
|
|
||||||
const logger = new ConsoleLogger();
|
|
||||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
|
||||||
showUserNotifications: true,
|
|
||||||
logToConsole: true,
|
|
||||||
reportToExternal: process.env.NODE_ENV === 'production',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create API client
|
switch (result.status) {
|
||||||
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
|
case 'ok':
|
||||||
|
return <TeamsPageClient pageDto={result.dto} />;
|
||||||
// Create service
|
case 'notFound':
|
||||||
const service = new TeamService(teamsApiClient);
|
notFound();
|
||||||
|
case 'redirect':
|
||||||
return await service.getAllTeams();
|
// This would typically use redirect() from next/navigation
|
||||||
});
|
// but we need to handle it at the page level
|
||||||
|
return null;
|
||||||
if (!data) {
|
case 'error':
|
||||||
notFound();
|
// For now, treat errors as not found
|
||||||
|
// In production, you might want a proper error page
|
||||||
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PageWrapper data={data} Template={TeamsTemplateWrapper} />;
|
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { apiClient } from '@/lib/apiClient';
|
import { apiClient } from '@/lib/apiClient';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { AdminViewModelService } from '@/lib/services/AdminViewModelService';
|
import { AdminViewModelPresenter } from '@/lib/view-models/AdminViewModelPresenter';
|
||||||
import { DashboardStatsViewModel } from '@/lib/view-models/AdminUserViewModel';
|
import { DashboardStatsViewModel } from '@/lib/view-models/AdminUserViewModel';
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
@@ -31,7 +31,7 @@ export function AdminDashboardPage() {
|
|||||||
const response = await apiClient.admin.getDashboardStats();
|
const response = await apiClient.admin.getDashboardStats();
|
||||||
|
|
||||||
// Map DTO to View Model
|
// Map DTO to View Model
|
||||||
const viewModel = AdminViewModelService.mapDashboardStats(response);
|
const viewModel = AdminViewModelPresenter.mapDashboardStats(response);
|
||||||
setStats(viewModel);
|
setStats(viewModel);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to load stats';
|
const message = err instanceof Error ? err.message : 'Failed to load stats';
|
||||||
@@ -222,4 +222,4 @@ export function AdminDashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Activity
|
Activity
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { logoutAction } from '@/app/actions/logoutAction';
|
||||||
|
|
||||||
interface AdminLayoutProps {
|
interface AdminLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -62,15 +63,6 @@ export function AdminLayout({ children }: AdminLayoutProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
try {
|
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
|
||||||
router.push('/');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout failed:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-deep-graphite overflow-hidden">
|
<div className="flex h-screen bg-deep-graphite overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@@ -132,13 +124,16 @@ export function AdminLayout({ children }: AdminLayoutProps) {
|
|||||||
{isSidebarOpen && <span className="text-sm">Toggle Sidebar</span>}
|
{isSidebarOpen && <span className="text-sm">Toggle Sidebar</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{/* Use form with server action for logout */}
|
||||||
onClick={handleLogout}
|
<form action={logoutAction}>
|
||||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-racing-red hover:bg-racing-red/10 transition-colors"
|
<button
|
||||||
>
|
type="submit"
|
||||||
<LogOut className="w-5 h-5" />
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-racing-red hover:bg-racing-red/10 transition-colors"
|
||||||
{isSidebarOpen && <span className="text-sm">Logout</span>}
|
>
|
||||||
</button>
|
<LogOut className="w-5 h-5" />
|
||||||
|
{isSidebarOpen && <span className="text-sm">Logout</span>}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { apiClient } from '@/lib/apiClient';
|
import { apiClient } from '@/lib/apiClient';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import StatusBadge from '@/components/ui/StatusBadge';
|
import StatusBadge from '@/components/ui/StatusBadge';
|
||||||
import { AdminViewModelService } from '@/lib/services/AdminViewModelService';
|
import { AdminViewModelPresenter } from '@/lib/view-models/AdminViewModelPresenter';
|
||||||
import { AdminUserViewModel, UserListViewModel } from '@/lib/view-models/AdminUserViewModel';
|
import { AdminUserViewModel, UserListViewModel } from '@/lib/view-models/AdminUserViewModel';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
@@ -47,7 +47,7 @@ export function AdminUsersPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Map DTO to View Model
|
// Map DTO to View Model
|
||||||
const viewModel = AdminViewModelService.mapUserList(response);
|
const viewModel = AdminViewModelPresenter.mapUserList(response);
|
||||||
setUserList(viewModel);
|
setUserList(viewModel);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to load users';
|
const message = err instanceof Error ? err.message : 'Failed to load users';
|
||||||
@@ -356,4 +356,4 @@ export function AdminUsersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,18 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Bug, X, Settings, Shield, Activity } from 'lucide-react';
|
import { Bug, X, Settings, Shield, Activity } from 'lucide-react';
|
||||||
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
|
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
|
||||||
import { getGlobalApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
|
import { getGlobalApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
|
||||||
|
import type { GlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
|
||||||
|
import type { ApiRequestLogger } from '@/lib/infrastructure/ApiRequestLogger';
|
||||||
|
|
||||||
|
// Extend Window interface for debug globals
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__GRIDPILOT_FETCH_LOGGED__?: boolean;
|
||||||
|
__GRIDPILOT_GLOBAL_HANDLER__?: GlobalErrorHandler;
|
||||||
|
__GRIDPILOT_API_LOGGER__?: ApiRequestLogger;
|
||||||
|
__GRIDPILOT_REACT_ERRORS__?: Array<{ error: unknown; componentStack?: string }>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface DebugModeToggleProps {
|
interface DebugModeToggleProps {
|
||||||
/**
|
/**
|
||||||
@@ -74,21 +86,21 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
|
|||||||
globalHandler.initialize();
|
globalHandler.initialize();
|
||||||
|
|
||||||
// Override fetch with logging
|
// Override fetch with logging
|
||||||
if (!(window as any).__GRIDPILOT_FETCH_LOGGED__) {
|
if (!window.__GRIDPILOT_FETCH_LOGGED__) {
|
||||||
const loggedFetch = apiLogger.createLoggedFetch();
|
const loggedFetch = apiLogger.createLoggedFetch();
|
||||||
window.fetch = loggedFetch as any;
|
window.fetch = loggedFetch as typeof fetch;
|
||||||
(window as any).__GRIDPILOT_FETCH_LOGGED__ = true;
|
window.__GRIDPILOT_FETCH_LOGGED__ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose to window for easy access
|
// Expose to window for easy access
|
||||||
(window as any).__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler;
|
window.__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler;
|
||||||
(window as any).__GRIDPILOT_API_LOGGER__ = apiLogger;
|
window.__GRIDPILOT_API_LOGGER__ = apiLogger;
|
||||||
|
|
||||||
console.log('%c[DEBUG MODE] Enabled', 'color: #00ff88; font-weight: bold; font-size: 14px;');
|
console.log('%c[DEBUG MODE] Enabled', 'color: #00ff88; font-weight: bold; font-size: 14px;');
|
||||||
console.log('Available globals:', {
|
console.log('Available globals:', {
|
||||||
__GRIDPILOT_GLOBAL_HANDLER__: globalHandler,
|
__GRIDPILOT_GLOBAL_HANDLER__: globalHandler,
|
||||||
__GRIDPILOT_API_LOGGER__: apiLogger,
|
__GRIDPILOT_API_LOGGER__: apiLogger,
|
||||||
__GRIDPILOT_REACT_ERRORS__: (window as any).__GRIDPILOT_REACT_ERRORS__ || [],
|
__GRIDPILOT_REACT_ERRORS__: window.__GRIDPILOT_REACT_ERRORS__ || [],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
||||||
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
|
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
|
||||||
import { Wrench, ChevronDown, ChevronUp, X, MessageSquare, Activity, AlertTriangle } from 'lucide-react';
|
import { Wrench, ChevronDown, ChevronUp, X, MessageSquare, Activity, AlertTriangle } from 'lucide-react';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Bell } from 'lucide-react';
|
import { Bell } from 'lucide-react';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
||||||
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
|
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
|
||||||
import type { DemoNotificationType, DemoUrgency } from '../types';
|
import type { DemoNotificationType, DemoUrgency } from '../types';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, FormEvent } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Input from '../ui/Input';
|
import Input from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { useCreateDriver } from '@/hooks/driver/useCreateDriver';
|
import { useCreateDriver } from "@/lib/hooks/driver/useCreateDriver";
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import ProfileStats from './ProfileStats';
|
|||||||
import CareerHighlights from './CareerHighlights';
|
import CareerHighlights from './CareerHighlights';
|
||||||
import DriverRankings from './DriverRankings';
|
import DriverRankings from './DriverRankings';
|
||||||
import PerformanceMetrics from './PerformanceMetrics';
|
import PerformanceMetrics from './PerformanceMetrics';
|
||||||
import { useDriverProfile } from '@/hooks/driver/useDriverProfile';
|
import { useDriverProfile } from "@/lib/hooks/driver/useDriverProfile";
|
||||||
|
|
||||||
interface DriverProfileProps {
|
interface DriverProfileProps {
|
||||||
driver: DriverViewModel;
|
driver: DriverViewModel;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useDriverProfile } from '@/hooks/driver';
|
import { useDriverProfile } from "@/lib/hooks/driver";
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import Card from '../ui/Card';
|
import Card from '../ui/Card';
|
||||||
import RankBadge from './RankBadge';
|
import RankBadge from './RankBadge';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Container from '@/components/ui/Container';
|
import Container from '@/components/ui/Container';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { useParallax } from '@/hooks/useScrollProgress';
|
import { useParallax } from "@/lib/hooks/useScrollProgress";
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
|
||||||
interface AlternatingSectionProps {
|
interface AlternatingSectionProps {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRef } from 'react';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Container from '@/components/ui/Container';
|
import Container from '@/components/ui/Container';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { useParallax } from '../../hooks/useScrollProgress';
|
import { useParallax } from '@/lib/hooks/useScrollProgress';
|
||||||
|
|
||||||
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
|
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, FormEvent } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Input from '../ui/Input';
|
import Input from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { useCreateLeague } from '@/hooks/league/useCreateLeague';
|
import { useCreateLeague } from "@/lib/hooks/league/useCreateLeague";
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
|
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
|
||||||
|
|
||||||
import { useCreateLeagueWizard } from '@/hooks/useLeagueWizardService';
|
import { useCreateLeagueWizard } from "@/lib/hooks/useLeagueWizardService";
|
||||||
import { useLeagueScoringPresets } from '@/hooks/useLeagueScoringPresets';
|
import { useLeagueScoringPresets } from "@/lib/hooks/useLeagueScoringPresets";
|
||||||
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
||||||
import { LeagueDropSection } from './LeagueDropSection';
|
import { LeagueDropSection } from './LeagueDropSection';
|
||||||
import {
|
import {
|
||||||
@@ -316,7 +316,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
|
|
||||||
const validateStep = (currentStep: Step): boolean => {
|
const validateStep = (currentStep: Step): boolean => {
|
||||||
// Convert form to LeagueWizardFormData for validation
|
// Convert form to LeagueWizardFormData for validation
|
||||||
const formData: any = {
|
const formData: LeagueWizardCommandModel.LeagueWizardFormData = {
|
||||||
leagueId: form.leagueId || '',
|
leagueId: form.leagueId || '',
|
||||||
basics: {
|
basics: {
|
||||||
name: form.basics?.name || '',
|
name: form.basics?.name || '',
|
||||||
@@ -409,7 +409,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert form to LeagueWizardFormData for validation
|
// Convert form to LeagueWizardFormData for validation
|
||||||
const formData: any = {
|
const formData: LeagueWizardCommandModel.LeagueWizardFormData = {
|
||||||
leagueId: form.leagueId || '',
|
leagueId: form.leagueId || '',
|
||||||
basics: {
|
basics: {
|
||||||
name: form.basics?.name || '',
|
name: form.basics?.name || '',
|
||||||
@@ -520,7 +520,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ id: 1 as Step, label: 'Basics', icon: FileText, shortLabel: 'Name' },
|
{ id: 1 as Step, label: 'Basics', icon: FileText, shortLabel: 'Name' },
|
||||||
{ id: 2 as Step, label: 'Visibility', icon: Award, shortLabel: 'Type' },
|
{ id: 2 as Step, label: 'Visibility', icon: Award, shortLabel: 'Type' },
|
||||||
@@ -870,8 +870,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
|
|
||||||
{/* Championships & Drop Rules side by side on larger screens */}
|
{/* Championships & Drop Rules side by side on larger screens */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<ChampionshipsSection form={form} onChange={setForm as any} readOnly={presetsLoading} />
|
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
|
||||||
<LeagueDropSection form={form} onChange={setForm as any} readOnly={false} />
|
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errors.submit && (
|
{errors.submit && (
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { getMembership } from '@/lib/leagueMembership';
|
import { getMembership } from '@/lib/leagueMembership';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLeagueMembershipMutation } from '@/hooks/league/useLeagueMembershipMutation';
|
import { useLeagueMembershipMutation } from "@/lib/hooks/league/useLeagueMembershipMutation";
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
interface JoinLeagueButtonProps {
|
interface JoinLeagueButtonProps {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
|
import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
|
||||||
import { useLeagueRaces } from '@/hooks/league/useLeagueRaces';
|
import { useLeagueRaces } from "@/lib/hooks/league/useLeagueRaces";
|
||||||
|
|
||||||
export type LeagueActivity =
|
export type LeagueActivity =
|
||||||
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
|
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import DriverIdentity from '../drivers/DriverIdentity';
|
import DriverIdentity from '../drivers/DriverIdentity';
|
||||||
import { useEffectiveDriverId } from '../../hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/lib/hooks/useEffectiveDriverId';
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||||
@@ -45,7 +45,7 @@ export default function LeagueMembers({
|
|||||||
|
|
||||||
const byId: Record<string, DriverViewModel> = {};
|
const byId: Record<string, DriverViewModel> = {};
|
||||||
for (const dto of driverDtos) {
|
for (const dto of driverDtos) {
|
||||||
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: (dto as any).avatarUrl ?? null });
|
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null });
|
||||||
}
|
}
|
||||||
setDriversById(byId);
|
setDriversById(byId);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function LeagueOwnershipTransfer({
|
|||||||
driver={new DriverViewModel({
|
driver={new DriverViewModel({
|
||||||
id: ownerSummary.driver.id,
|
id: ownerSummary.driver.id,
|
||||||
name: ownerSummary.driver.name,
|
name: ownerSummary.driver.name,
|
||||||
avatarUrl: (ownerSummary.driver as any).avatarUrl ?? null,
|
avatarUrl: ownerSummary.driver.avatarUrl ?? null,
|
||||||
iracingId: ownerSummary.driver.iracingId,
|
iracingId: ownerSummary.driver.iracingId,
|
||||||
country: ownerSummary.driver.country,
|
country: ownerSummary.driver.country,
|
||||||
bio: ownerSummary.driver.bio,
|
bio: ownerSummary.driver.bio,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useRegisterForRace } from '@/hooks/race/useRegisterForRace';
|
import { useRegisterForRace } from "@/lib/hooks/race/useRegisterForRace";
|
||||||
import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace';
|
import { useWithdrawFromRace } from "@/lib/hooks/race/useWithdrawFromRace";
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
||||||
@@ -10,7 +10,7 @@ import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueSchedu
|
|||||||
// Shared state components
|
// Shared state components
|
||||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||||
import { EmptyState } from '@/components/shared/state/EmptyState';
|
import { EmptyState } from '@/components/shared/state/EmptyState';
|
||||||
import { useLeagueSchedule } from '@/hooks/league/useLeagueSchedule';
|
import { useLeagueSchedule } from "@/lib/hooks/league/useLeagueSchedule";
|
||||||
import { Calendar } from 'lucide-react';
|
import { Calendar } from 'lucide-react';
|
||||||
|
|
||||||
interface LeagueScheduleProps {
|
interface LeagueScheduleProps {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import PendingSponsorshipRequests, { type PendingRequestDTO } from '../sponsors/
|
|||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import Input from '../ui/Input';
|
import Input from '../ui/Input';
|
||||||
|
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useLeagueSeasons } from '@/hooks/league/useLeagueSeasons';
|
import { useLeagueSeasons } from "@/lib/hooks/league/useLeagueSeasons";
|
||||||
import { useSponsorshipRequests } from '@/hooks/league/useSponsorshipRequests';
|
import { useSponsorshipRequests } from "@/lib/hooks/league/useSponsorshipRequests";
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { SPONSORSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { SPONSORSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { getMembership } from '@/lib/leagueMembership';
|
import { getMembership } from '@/lib/leagueMembership';
|
||||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { usePenaltyMutation } from '@/hooks/league/usePenaltyMutation';
|
import { usePenaltyMutation } from "@/lib/hooks/league/usePenaltyMutation";
|
||||||
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
|
import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react';
|
||||||
|
|
||||||
interface DriverOption {
|
interface DriverOption {
|
||||||
@@ -52,16 +52,14 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const command: any = {
|
const command = {
|
||||||
raceId: selectedRaceId,
|
raceId: selectedRaceId,
|
||||||
driverId: selectedDriver,
|
driverId: selectedDriver,
|
||||||
adminId,
|
stewardId: adminId,
|
||||||
infractionType: infractionType as any,
|
type: infractionType,
|
||||||
severity: severity as any,
|
reason: severity,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
};
|
};
|
||||||
if (notes.trim()) {
|
|
||||||
command.notes = notes.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
await penaltyMutation.mutateAsync(command);
|
await penaltyMutation.mutateAsync(command);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { usePenaltyTypesReference } from "@/hooks/usePenaltyTypesReference";
|
import { usePenaltyTypesReference } from "@/lib/hooks/usePenaltyTypesReference";
|
||||||
import type { PenaltyValueKindDTO } from "@/lib/types/PenaltyTypesReferenceDTO";
|
import type { PenaltyValueKindDTO } from "@/lib/types/PenaltyTypesReferenceDTO";
|
||||||
import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
|
import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
|
||||||
import Modal from "../ui/Modal";
|
import Modal from "../ui/Modal";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import Input from '../ui/Input';
|
import Input from '../ui/Input';
|
||||||
import { useAllLeagues } from '@/hooks/league/useAllLeagues';
|
import { useAllLeagues } from "@/lib/hooks/league/useAllLeagues";
|
||||||
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||||
|
|
||||||
interface ScheduleRaceFormData {
|
interface ScheduleRaceFormData {
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import Input from '@/components/ui/Input';
|
|||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import CountrySelect from '@/components/ui/CountrySelect';
|
import CountrySelect from '@/components/ui/CountrySelect';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { useCompleteOnboarding } from '@/hooks/onboarding/useCompleteOnboarding';
|
import { useCompleteOnboarding } from "@/lib/hooks/onboarding/useCompleteOnboarding";
|
||||||
import { useGenerateAvatars } from '@/hooks/onboarding/useGenerateAvatars';
|
import { useGenerateAvatars } from "@/lib/hooks/onboarding/useGenerateAvatars";
|
||||||
import { useValidateFacePhoto } from '@/hooks/onboarding/useValidateFacePhoto';
|
import { useValidateFacePhoto } from "@/lib/hooks/onboarding/useValidateFacePhoto";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ import { BarChart3, Building2, ChevronDown, CreditCard, Handshake, LogOut, Megap
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
|
|
||||||
import { CapabilityGate } from '@/components/shared/CapabilityGate';
|
import { CapabilityGate } from '@/components/shared/CapabilityGate';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/lib/hooks/useEffectiveDriverId';
|
||||||
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
import type { DriverViewModel } from '@/lib/view-models/view-models/DriverViewModel';
|
||||||
import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/DriverViewModel';
|
import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/view-models/DriverViewModel';
|
||||||
import { useFindDriverById } from '@/hooks/driver/useFindDriverById';
|
import { useFindDriverById } from '@/lib/hooks/driver/useFindDriverById';
|
||||||
|
|
||||||
// Hook to detect demo user mode based on session
|
// Hook to detect demo user mode based on session
|
||||||
function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } {
|
function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } {
|
||||||
@@ -27,8 +26,8 @@ function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } {
|
|||||||
|
|
||||||
const email = session.user.email?.toLowerCase() || '';
|
const email = session.user.email?.toLowerCase() || '';
|
||||||
const displayName = session.user.displayName?.toLowerCase() || '';
|
const displayName = session.user.displayName?.toLowerCase() || '';
|
||||||
const primaryDriverId = (session.user as any).primaryDriverId || '';
|
const primaryDriverId = session.user.primaryDriverId || '';
|
||||||
const role = (session.user as any).role;
|
const role = 'role' in session.user ? (session.user as { role?: string }).role : undefined;
|
||||||
|
|
||||||
// Check if this is a demo user
|
// Check if this is a demo user
|
||||||
if (email.includes('demo') ||
|
if (email.includes('demo') ||
|
||||||
@@ -151,7 +150,7 @@ export default function UserPill() {
|
|||||||
// Transform DTO to ViewModel
|
// Transform DTO to ViewModel
|
||||||
const driver = useMemo(() => {
|
const driver = useMemo(() => {
|
||||||
if (!driverDto) return null;
|
if (!driverDto) return null;
|
||||||
return new DriverViewModelClass({ ...driverDto, avatarUrl: (driverDto as any).avatarUrl ?? null });
|
return new DriverViewModelClass({ ...driverDto, avatarUrl: driverDto.avatarUrl ?? null });
|
||||||
}, [driverDto]);
|
}, [driverDto]);
|
||||||
|
|
||||||
const data = useMemo(() => {
|
const data = useMemo(() => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO';
|
import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO';
|
||||||
import { useFileProtest } from '@/hooks/race/useFileProtest';
|
import { useFileProtest } from "@/lib/hooks/race/useFileProtest";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Video,
|
Video,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { useCapability } from '@/hooks/useCapability';
|
import { useCapability } from "@/lib/hooks/useCapability";
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
import { useInject } from '@/lib/di/hooks/useInject';
|
||||||
import { POLICY_SERVICE_TOKEN } from '@/lib/di/tokens';
|
import { POLICY_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||||
|
|
||||||
|
|||||||
@@ -456,7 +456,7 @@ export function useSponsorMode(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check session.user.role for sponsor
|
// Check session.user.role for sponsor
|
||||||
const role = (session.user as any).role;
|
const role = session.user?.role;
|
||||||
if (role === 'sponsor') {
|
if (role === 'sponsor') {
|
||||||
setIsSponsor(true);
|
setIsSponsor(true);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useCreateTeam } from '@/hooks/team';
|
import { useCreateTeam } from "@/lib/hooks/team";
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { UserPlus, Users, Trophy } from 'lucide-react';
|
import { UserPlus, Users, Trophy } from 'lucide-react';
|
||||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
|
||||||
import { getMediaUrl } from '@/lib/utilities/media';
|
import { getMediaUrl } from '@/lib/utilities/media';
|
||||||
|
|
||||||
const SKILL_LEVELS: {
|
const SKILL_LEVELS: {
|
||||||
@@ -14,7 +13,7 @@ const SKILL_LEVELS: {
|
|||||||
{
|
{
|
||||||
id: 'pro',
|
id: 'pro',
|
||||||
label: 'Pro',
|
label: 'Pro',
|
||||||
icon: () => null, // We'll import Crown if needed
|
icon: () => null,
|
||||||
color: 'text-yellow-400',
|
color: 'text-yellow-400',
|
||||||
bgColor: 'bg-yellow-400/10',
|
bgColor: 'bg-yellow-400/10',
|
||||||
borderColor: 'border-yellow-400/30',
|
borderColor: 'border-yellow-400/30',
|
||||||
@@ -46,7 +45,17 @@ const SKILL_LEVELS: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface FeaturedRecruitingProps {
|
interface FeaturedRecruitingProps {
|
||||||
teams: TeamSummaryViewModel[];
|
teams: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
category?: string;
|
||||||
|
memberCount: number;
|
||||||
|
totalWins: number;
|
||||||
|
performanceLevel: string;
|
||||||
|
isRecruiting: boolean;
|
||||||
|
}>;
|
||||||
onTeamClick: (id: string) => void;
|
onTeamClick: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||||
import { useTeamMembership, useJoinTeam, useLeaveTeam } from '@/hooks/team';
|
import { useTeamMembership, useJoinTeam, useLeaveTeam } from "@/lib/hooks/team";
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface JoinTeamButtonProps {
|
interface JoinTeamButtonProps {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ChevronRight, Users, Trophy, UserPlus } from 'lucide-react';
|
import { ChevronRight, Users, Trophy, UserPlus } from 'lucide-react';
|
||||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
|
||||||
import TeamCard from './TeamCard';
|
import TeamCard from './TeamCard';
|
||||||
|
|
||||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||||
@@ -18,7 +17,22 @@ interface SkillLevelConfig {
|
|||||||
|
|
||||||
interface SkillLevelSectionProps {
|
interface SkillLevelSectionProps {
|
||||||
level: SkillLevelConfig;
|
level: SkillLevelConfig;
|
||||||
teams: TeamSummaryViewModel[];
|
teams: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
memberCount: number;
|
||||||
|
rating?: number;
|
||||||
|
totalWins: number;
|
||||||
|
totalRaces: number;
|
||||||
|
performanceLevel: string;
|
||||||
|
isRecruiting: boolean;
|
||||||
|
specialization?: string;
|
||||||
|
region?: string;
|
||||||
|
languages: string[];
|
||||||
|
category?: string;
|
||||||
|
}>;
|
||||||
onTeamClick: (id: string) => void;
|
onTeamClick: (id: string) => void;
|
||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import { useState } from 'react';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { useTeamJoinRequests, useUpdateTeam, useApproveJoinRequest, useRejectJoinRequest } from '@/hooks/team';
|
import { useTeamJoinRequests, useUpdateTeam, useApproveJoinRequest, useRejectJoinRequest } from "@/lib/hooks/team";
|
||||||
import type { TeamJoinRequestViewModel } from '@/lib/view-models/TeamJoinRequestViewModel';
|
import type { TeamJoinRequestViewModel } from '@/lib/view-models/TeamJoinRequestViewModel';
|
||||||
import type { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
|
||||||
|
|
||||||
interface TeamAdminProps {
|
interface TeamAdminProps {
|
||||||
team: Pick<TeamDetailsViewModel, 'id' | 'name' | 'tag' | 'description' | 'ownerId'>;
|
team: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tag: string;
|
||||||
|
description?: string;
|
||||||
|
ownerId: string;
|
||||||
|
};
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +23,7 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
|||||||
const [editedTeam, setEditedTeam] = useState({
|
const [editedTeam, setEditedTeam] = useState({
|
||||||
name: team.name,
|
name: team.name,
|
||||||
tag: team.tag,
|
tag: team.tag,
|
||||||
description: team.description,
|
description: team.description || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use hooks for data fetching
|
// Use hooks for data fetching
|
||||||
@@ -141,7 +146,7 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
|||||||
setEditedTeam({
|
setEditedTeam({
|
||||||
name: team.name,
|
name: team.name,
|
||||||
tag: team.tag,
|
tag: team.tag,
|
||||||
description: team.description,
|
description: team.description || '',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useRouter } from 'next/navigation';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Award, ChevronRight, Crown, Trophy, Users } from 'lucide-react';
|
import { Award, ChevronRight, Crown, Trophy, Users } from 'lucide-react';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
|
||||||
import { getMediaUrl } from '@/lib/utilities/media';
|
import { getMediaUrl } from '@/lib/utilities/media';
|
||||||
|
|
||||||
const SKILL_LEVELS: {
|
const SKILL_LEVELS: {
|
||||||
@@ -48,7 +47,17 @@ const SKILL_LEVELS: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface TeamLeaderboardPreviewProps {
|
interface TeamLeaderboardPreviewProps {
|
||||||
topTeams: TeamSummaryViewModel[];
|
topTeams: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
category?: string;
|
||||||
|
memberCount: number;
|
||||||
|
totalWins: number;
|
||||||
|
isRecruiting: boolean;
|
||||||
|
rating?: number;
|
||||||
|
performanceLevel: string;
|
||||||
|
}>;
|
||||||
onTeamClick: (id: string) => void;
|
onTeamClick: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||||
import { useTeamRoster } from '@/hooks/team';
|
import { useTeamRoster } from "@/lib/hooks/team";
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||||
|
|
||||||
@@ -11,7 +11,14 @@ type TeamMemberRole = 'owner' | 'manager' | 'member';
|
|||||||
|
|
||||||
interface TeamRosterProps {
|
interface TeamRosterProps {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
memberships: any[];
|
memberships: Array<{
|
||||||
|
driverId: string;
|
||||||
|
driverName: string;
|
||||||
|
role: 'owner' | 'manager' | 'member';
|
||||||
|
joinedAt: string;
|
||||||
|
isActive: boolean;
|
||||||
|
avatarUrl: string;
|
||||||
|
}>;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onRemoveMember?: (driverId: string) => void;
|
onRemoveMember?: (driverId: string) => void;
|
||||||
onChangeRole?: (driverId: string, newRole: TeamRole) => void;
|
onChangeRole?: (driverId: string, newRole: TeamRole) => void;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { useTeamStandings } from '@/hooks/team';
|
import { useTeamStandings } from "@/lib/hooks/team";
|
||||||
|
|
||||||
interface TeamStandingsProps {
|
interface TeamStandingsProps {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
|
||||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
||||||
|
|
||||||
export function useCreateLeague() {
|
|
||||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const createLeagueMutation = useMutation({
|
|
||||||
mutationFn: (input: any) => leagueService.createLeague(input),
|
|
||||||
onSuccess: () => {
|
|
||||||
// Invalidate relevant queries to refresh data
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return createLeagueMutation;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
|
||||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
||||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
|
||||||
|
|
||||||
export function useLeagueDetail(leagueId: string, currentDriverId: string) {
|
|
||||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
const queryResult = useQuery({
|
|
||||||
queryKey: ['leagueDetail', leagueId, currentDriverId],
|
|
||||||
queryFn: () => leagueService.getLeagueDetail(leagueId, currentDriverId),
|
|
||||||
enabled: !!leagueId && !!currentDriverId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return enhanceQueryResult(queryResult);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
|
||||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
||||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
|
||||||
import { ApiError } from '@/lib/api/base/ApiError';
|
|
||||||
import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel';
|
|
||||||
import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel';
|
|
||||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
|
||||||
|
|
||||||
export function useLeagueRosterJoinRequests(
|
|
||||||
leagueId: string,
|
|
||||||
options?: Omit<UseQueryOptions<LeagueAdminRosterJoinRequestViewModel[], ApiError>, 'queryKey' | 'queryFn'>
|
|
||||||
) {
|
|
||||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
const queryResult = useQuery({
|
|
||||||
queryKey: ['leagueRosterJoinRequests', leagueId],
|
|
||||||
queryFn: () => leagueService.getAdminRosterJoinRequests(leagueId),
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
return enhanceQueryResult(queryResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLeagueRosterMembers(
|
|
||||||
leagueId: string,
|
|
||||||
options?: Omit<UseQueryOptions<LeagueAdminRosterMemberViewModel[], ApiError>, 'queryKey' | 'queryFn'>
|
|
||||||
) {
|
|
||||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
const queryResult = useQuery({
|
|
||||||
queryKey: ['leagueRosterMembers', leagueId],
|
|
||||||
queryFn: () => leagueService.getAdminRosterMembers(leagueId),
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
return enhanceQueryResult(queryResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApproveJoinRequest(
|
|
||||||
options?: Omit<UseMutationOptions<{ success: boolean }, ApiError, { leagueId: string; joinRequestId: string }>, 'mutationFn'>
|
|
||||||
) {
|
|
||||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
return useMutation<{ success: boolean }, ApiError, { leagueId: string; joinRequestId: string }>({
|
|
||||||
mutationFn: ({ leagueId, joinRequestId }) => leagueService.approveJoinRequest(leagueId, joinRequestId),
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRejectJoinRequest(
|
|
||||||
options?: Omit<UseMutationOptions<{ success: boolean }, ApiError, { leagueId: string; joinRequestId: string }>, 'mutationFn'>
|
|
||||||
) {
|
|
||||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
return useMutation<{ success: boolean }, ApiError, { leagueId: string; joinRequestId: string }>({
|
|
||||||
mutationFn: ({ leagueId, joinRequestId }) => leagueService.rejectJoinRequest(leagueId, joinRequestId),
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateMemberRole(
|
|
||||||
options?: Omit<UseMutationOptions<{ success: boolean }, ApiError, { leagueId: string; driverId: string; role: MembershipRole }>, 'mutationFn'>
|
|
||||||
) {
|
|
||||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
return useMutation<{ success: boolean }, ApiError, { leagueId: string; driverId: string; role: MembershipRole }>({
|
|
||||||
mutationFn: ({ leagueId, driverId, role }) => leagueService.updateMemberRole(leagueId, driverId, role),
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemoveMember(
|
|
||||||
options?: Omit<UseMutationOptions<{ success: boolean }, ApiError, { leagueId: string; driverId: string }>, 'mutationFn'>
|
|
||||||
) {
|
|
||||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
return useMutation<{ success: boolean }, ApiError, { leagueId: string; driverId: string }>({
|
|
||||||
mutationFn: ({ leagueId, driverId }) => leagueService.removeMember(leagueId, driverId),
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
|
||||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
||||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
|
||||||
|
|
||||||
export function useLeagueSchedule(leagueId: string) {
|
|
||||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
const queryResult = useQuery({
|
|
||||||
queryKey: ['leagueSchedule', leagueId],
|
|
||||||
queryFn: () => leagueService.getLeagueSchedule(leagueId),
|
|
||||||
enabled: !!leagueId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return enhanceQueryResult(queryResult);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { usePageData } from '@/lib/page/usePageData';
|
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
|
||||||
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
||||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
|
||||||
|
|
||||||
export function useLeagueAdminStatus(leagueId: string, currentDriverId: string) {
|
|
||||||
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
return usePageData({
|
|
||||||
queryKey: ['admin-check', leagueId, currentDriverId],
|
|
||||||
queryFn: async () => {
|
|
||||||
await leagueMembershipService.fetchLeagueMemberships(leagueId);
|
|
||||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
|
||||||
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
|
|
||||||
},
|
|
||||||
enabled: !!leagueId && !!currentDriverId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLeagueSeasons(leagueId: string, isAdmin: boolean) {
|
|
||||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
return usePageData({
|
|
||||||
queryKey: ['leagueSeasons', leagueId],
|
|
||||||
queryFn: () => leagueService.getLeagueSeasonSummaries(leagueId),
|
|
||||||
enabled: !!leagueId && !!isAdmin,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLeagueAdminSchedule(leagueId: string, selectedSeasonId: string, isAdmin: boolean) {
|
|
||||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
return usePageData({
|
|
||||||
queryKey: ['adminSchedule', leagueId, selectedSeasonId],
|
|
||||||
queryFn: () => leagueService.getAdminSchedule(leagueId, selectedSeasonId),
|
|
||||||
enabled: !!leagueId && !!selectedSeasonId && !!isAdmin,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { usePageData, usePageMutation } from '@/lib/page/usePageData';
|
|
||||||
import { useInject } from '@/lib/di/hooks/useInject';
|
|
||||||
import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens';
|
|
||||||
|
|
||||||
export function useLeagueWalletPageData(leagueId: string) {
|
|
||||||
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
const queryResult = usePageData({
|
|
||||||
queryKey: ['leagueWallet', leagueId],
|
|
||||||
queryFn: () => leagueWalletService.getWalletForLeague(leagueId),
|
|
||||||
enabled: !!leagueId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return queryResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLeagueWalletWithdrawal(leagueId: string, data: any, refetch: () => void) {
|
|
||||||
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
|
|
||||||
|
|
||||||
const withdrawMutation = usePageMutation(
|
|
||||||
async ({ amount }: { amount: number }) => {
|
|
||||||
if (!data) throw new Error('Wallet data not available');
|
|
||||||
|
|
||||||
const result = await leagueWalletService.withdraw(
|
|
||||||
leagueId,
|
|
||||||
amount,
|
|
||||||
data.currency,
|
|
||||||
'season-2', // Current active season
|
|
||||||
'bank-account-***1234'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.message || 'Withdrawal failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
// Refetch wallet data after successful withdrawal
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return withdrawMutation;
|
|
||||||
}
|
|
||||||
@@ -101,7 +101,7 @@ export const responseCache = new ResponseCache();
|
|||||||
export async function withGracefulDegradation<T>(
|
export async function withGracefulDegradation<T>(
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
options: DegradationOptions<T> = {}
|
options: DegradationOptions<T> = {}
|
||||||
): Promise<T> {
|
): Promise<T | undefined> {
|
||||||
const {
|
const {
|
||||||
fallback,
|
fallback,
|
||||||
throwOnError = false,
|
throwOnError = false,
|
||||||
@@ -139,7 +139,7 @@ export async function withGracefulDegradation<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return undefined (caller must handle)
|
// Return undefined (caller must handle)
|
||||||
return undefined as unknown as T;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API is available, try to execute
|
// API is available, try to execute
|
||||||
@@ -193,7 +193,7 @@ export async function withGracefulDegradation<T>(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined as unknown as T;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||||
import { useCurrentSession } from '@/hooks/auth/useCurrentSession';
|
import { useCurrentSession } from "@/lib/hooks/auth/useCurrentSession";
|
||||||
import { useLogin } from '@/hooks/auth/useLogin';
|
import { useLogin } from "@/lib/hooks/auth/useLogin";
|
||||||
import { useLogout } from '@/hooks/auth/useLogout';
|
import { useLogout } from "@/lib/hooks/auth/useLogout";
|
||||||
|
|
||||||
export type AuthContextValue = {
|
export type AuthContextValue = {
|
||||||
session: SessionViewModel | null;
|
session: SessionViewModel | null;
|
||||||
|
|||||||
26
apps/website/lib/contracts/page-queries/PageQuery.ts
Normal file
26
apps/website/lib/contracts/page-queries/PageQuery.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { PageQueryResult } from '@/lib/page-queries/PageQueryResult';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PageQuery contract interface
|
||||||
|
*
|
||||||
|
* Defines the canonical contract for all server-side page queries.
|
||||||
|
*
|
||||||
|
* Based on WEBSITE_PAGE_QUERIES.md:
|
||||||
|
* - Server-side composition classes
|
||||||
|
* - Call services that call apps/api
|
||||||
|
* - Assemble a Page DTO
|
||||||
|
* - Return explicit result describing route outcome
|
||||||
|
* - Do not implement business rules
|
||||||
|
*
|
||||||
|
* @template TPageDto - The Page DTO type this query produces
|
||||||
|
* @template TParams - The parameters required to execute this query
|
||||||
|
*/
|
||||||
|
export interface PageQuery<TPageDto, TParams = void> {
|
||||||
|
/**
|
||||||
|
* Execute the page query
|
||||||
|
*
|
||||||
|
* @param params - Parameters required for query execution
|
||||||
|
* @returns Promise resolving to a PageQueryResult discriminated union
|
||||||
|
*/
|
||||||
|
execute(params: TParams): Promise<PageQueryResult<TPageDto>>;
|
||||||
|
}
|
||||||
@@ -18,12 +18,12 @@ export function useInject<T extends symbol>(token: T): T extends { type: infer U
|
|||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return container.get(token);
|
return container.get(token) as T extends { type: infer U } ? U : unknown;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to resolve token ${token.toString()}:`, error);
|
console.error(`Failed to resolve token ${token.toString()}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [container, token]) as any;
|
}, [container, token]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user