Adds the Offerings module and retargets the CI/CD pipeline to Api.SlpSoftware
Continuous Integration / config (pull_request) Successful in 12s
Continuous Integration / changes (pull_request) Successful in 22s
Continuous Integration / backend-build (pull_request) Successful in 5m53s
Continuous Integration / vulnerability-scan (pull_request) Successful in 5m46s
Continuous Integration / frontend-prepare (pull_request) Successful in 1m54s
Continuous Integration / backend-test (pull_request) Successful in 7m37s
Continuous Integration / frontend-build (pull_request) Successful in 2m14s
Continuous Integration / frontend-test (pull_request) Successful in 4m59s
Continuous Integration / frontend-lint (pull_request) Successful in 2m2s
Continuous Integration / publish-production (pull_request) Skipped
Continuous Integration / deploy-production (pull_request) Skipped
Continuous Integration / publish-test (pull_request) Successful in 7m34s
Continuous Integration / deploy-test (pull_request) Skipped

Implements Unit 2 "Offerings" (backend module, admin CRUD UI with
drag-and-drop reordering, public GET /api/v1/offerings endpoint) and
executes the feature's D-15 CI/CD cutover, switching the deploy
pipeline's build/publish target from SlpModularCms.Api to
SlpModularCms.Api.SlpSoftware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWyStNL2ZsjrS7FLd7xvvN
This commit is contained in:
2026-08-02 16:23:09 +02:00
co-authored by Claude Sonnet 5
parent b6e9c07c06
commit cfb06b28b6
79 changed files with 4069 additions and 94 deletions
+3
View File
@@ -18,6 +18,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
+120 -62
View File
@@ -8,6 +8,15 @@ importers:
.:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.7)
'@hookform/resolvers':
specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.79.0(react@19.2.7))
@@ -74,7 +83,7 @@ importers:
devDependencies:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.5.0(jiti@2.7.0))
version: 10.0.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))
'@tailwindcss/vite':
specifier: ^4.3.1
version: 4.3.1(vite@8.0.16(@types/node@24.13.2)(jiti@2.7.0))
@@ -107,13 +116,13 @@ importers:
version: 9.2.3
eslint:
specifier: ^10.3.0
version: 10.5.0(jiti@2.7.0)
version: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
eslint-plugin-react-hooks:
specifier: ^7.1.1
version: 7.1.1(eslint@10.5.0(jiti@2.7.0))
version: 7.1.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)
eslint-plugin-react-refresh:
specifier: ^0.5.2
version: 0.5.3(eslint@10.5.0(jiti@2.7.0))
version: 0.5.3(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))
globals:
specifier: ^17.6.0
version: 17.6.0
@@ -134,7 +143,7 @@ importers:
version: 6.0.3
typescript-eslint:
specifier: ^8.59.2
version: 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
version: 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
vite:
specifier: ^8.0.12
version: 8.0.16(@types/node@24.13.2)(jiti@2.7.0)
@@ -277,6 +286,28 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -2377,20 +2408,20 @@ snapshots:
'@babel/compat-data@7.29.7': {}
'@babel/core@7.29.7':
'@babel/core@7.29.7(supports-color@8.1.1)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-compilation-targets': 7.29.7
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@8.1.1)
'@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -2415,19 +2446,19 @@ snapshots:
'@babel/helper-globals@7.29.7': {}
'@babel/helper-module-imports@7.29.7':
'@babel/helper-module-imports@7.29.7(supports-color@8.1.1)':
dependencies:
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@8.1.1)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-module-imports': 7.29.7
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/helper-module-imports': 7.29.7(supports-color@8.1.1)
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@@ -2454,7 +2485,7 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/traverse@7.29.7':
'@babel/traverse@7.29.7(supports-color@8.1.1)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
@@ -2462,7 +2493,7 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/types': 7.29.7
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@@ -2501,6 +2532,31 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.7)
'@dnd-kit/utilities': 3.2.2(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@dnd-kit/utilities': 3.2.2(react@19.2.7)
react: 19.2.7
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.7)':
dependencies:
react: 19.2.7
tslib: 2.8.1
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -2517,17 +2573,17 @@ snapshots:
tslib: 2.8.1
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0))':
'@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))':
dependencies:
eslint: 10.5.0(jiti@2.7.0)
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.23.5':
'@eslint/config-array@0.23.5(supports-color@8.1.1)':
dependencies:
'@eslint/object-schema': 3.0.5
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
minimatch: 10.2.5
transitivePeerDependencies:
- supports-color
@@ -2540,9 +2596,9 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/js@10.0.1(eslint@10.5.0(jiti@2.7.0))':
'@eslint/js@10.0.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))':
optionalDependencies:
eslint: 10.5.0(jiti@2.7.0)
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
'@eslint/object-schema@3.0.5': {}
@@ -3253,15 +3309,15 @@ snapshots:
'@types/statuses@2.0.6': {}
'@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
'@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.61.1
'@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.61.1
eslint: 10.5.0(jiti@2.7.0)
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@6.0.3)
@@ -3269,23 +3325,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
'@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.61.1
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.61.1(supports-color@8.1.1)(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.61.1
debug: 4.4.3
eslint: 10.5.0(jiti@2.7.0)
debug: 4.4.3(supports-color@8.1.1)
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.61.1(typescript@6.0.3)':
'@typescript-eslint/project-service@8.61.1(supports-color@8.1.1)(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3)
'@typescript-eslint/types': 8.61.1
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -3299,13 +3355,13 @@ snapshots:
dependencies:
typescript: 6.0.3
'@typescript-eslint/type-utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
'@typescript-eslint/type-utils@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)':
dependencies:
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
debug: 4.4.3
eslint: 10.5.0(jiti@2.7.0)
'@typescript-eslint/typescript-estree': 8.61.1(supports-color@8.1.1)(typescript@6.0.3)
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
debug: 4.4.3(supports-color@8.1.1)
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
@@ -3313,13 +3369,13 @@ snapshots:
'@typescript-eslint/types@8.61.1': {}
'@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3)':
'@typescript-eslint/typescript-estree@8.61.1(supports-color@8.1.1)(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.61.1(typescript@6.0.3)
'@typescript-eslint/project-service': 8.61.1(supports-color@8.1.1)(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3)
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/visitor-keys': 8.61.1
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
minimatch: 10.2.5
semver: 7.8.4
tinyglobby: 0.2.17
@@ -3328,13 +3384,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)':
'@typescript-eslint/utils@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0))
'@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))
'@typescript-eslint/scope-manager': 8.61.1
'@typescript-eslint/types': 8.61.1
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
eslint: 10.5.0(jiti@2.7.0)
'@typescript-eslint/typescript-estree': 8.61.1(supports-color@8.1.1)(typescript@6.0.3)
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
@@ -3530,9 +3586,11 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
debug@4.4.3:
debug@4.4.3(supports-color@8.1.1):
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 8.1.1
decimal.js@10.6.0: {}
@@ -3565,20 +3623,20 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-plugin-react-hooks@7.1.1(eslint@10.5.0(jiti@2.7.0)):
eslint-plugin-react-hooks@7.1.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1):
dependencies:
'@babel/core': 7.29.7
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/parser': 7.29.7
eslint: 10.5.0(jiti@2.7.0)
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
hermes-parser: 0.25.1
zod: 4.4.3
zod-validation-error: 4.0.2(zod@4.4.3)
transitivePeerDependencies:
- supports-color
eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)):
eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1)):
dependencies:
eslint: 10.5.0(jiti@2.7.0)
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
eslint-scope@9.1.2:
dependencies:
@@ -3591,11 +3649,11 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
eslint@10.5.0(jiti@2.7.0):
eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0))
'@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.5
'@eslint/config-array': 0.23.5(supports-color@8.1.1)
'@eslint/config-helpers': 0.6.0
'@eslint/core': 1.2.1
'@eslint/plugin-kit': 0.7.2
@@ -3605,7 +3663,7 @@ snapshots:
'@types/estree': 1.0.9
ajv: 6.15.0
cross-spawn: 7.0.6
debug: 4.4.3
debug: 4.4.3(supports-color@8.1.1)
escape-string-regexp: 4.0.0
eslint-scope: 9.1.2
eslint-visitor-keys: 5.0.1
@@ -4210,13 +4268,13 @@ snapshots:
dependencies:
tagged-tag: 1.0.0
typescript-eslint@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3):
typescript-eslint@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3)
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)
eslint: 10.5.0(jiti@2.7.0)
'@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
'@typescript-eslint/parser': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.61.1(supports-color@8.1.1)(typescript@6.0.3)
'@typescript-eslint/utils': 8.61.1(eslint@10.5.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)
eslint: 10.5.0(jiti@2.7.0)(supports-color@8.1.1)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
+9 -1
View File
@@ -1,6 +1,6 @@
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Users, FileText, Settings, X } from 'lucide-react';
import { LayoutDashboard, Users, FileText, Package, Settings, X } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/features/auth/context/auth-context';
@@ -27,6 +27,14 @@ const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'], requiredModule: 'Master' },
{
to: '/offerings',
labelKey: 'nav.offerings',
icon: Package,
testId: 'nav-offerings',
roles: ['Owner', 'Administrator'],
requiredModule: 'Offerings',
},
];
const SETTINGS_ITEM: NavItem = {
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import type { OfferingAdminDto } from '../services/types';
interface DeleteOfferingDialogProps {
offering: OfferingAdminDto | null;
isDeleting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function DeleteOfferingDialog({ offering, isDeleting, onConfirm, onCancel }: DeleteOfferingDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={offering !== null} onOpenChange={(open) => { if (!open) onCancel(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('offerings.deleteDialog.title')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground" data-testid="delete-offering-message">
{t('offerings.deleteDialog.message', { title: offering?.title ?? '' })}
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel} data-testid="delete-offering-cancel">
{t('offerings.deleteDialog.cancelButton')}
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isDeleting}
data-testid="delete-offering-confirm"
>
{isDeleting ? '…' : t('offerings.deleteDialog.confirmButton')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,155 @@
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { FieldError } from '@/components/ui/FieldError';
import { offeringFormSchema, type OfferingFormData } from '../schemas/offering';
import type { OfferingAdminDto } from '../services/types';
interface OfferingFormProps {
initialValues?: OfferingAdminDto;
onSubmit: (values: OfferingFormData) => void;
isSubmitting: boolean;
}
export function OfferingForm({ initialValues, onSubmit, isSubmitting }: OfferingFormProps) {
const { t } = useTranslation();
const {
register,
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm<OfferingFormData>({
resolver: zodResolver(offeringFormSchema),
mode: 'onTouched',
defaultValues: initialValues ?? {
title: '',
description: '',
price: '',
priceNote: '',
features: [''],
ctaLabel: '',
featured: false,
},
});
// useFieldArray requires object-shaped array items — features is a plain string[],
// so add/remove are handled directly via setValue instead.
const features = useWatch({ control, name: 'features' });
function addFeature() {
setValue('features', [...features, ''], { shouldValidate: true });
}
function removeFeature(index: number) {
setValue('features', features.filter((_, i) => i !== index), { shouldValidate: true });
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div className="space-y-2">
<Label htmlFor="offering-title">{t('offerings.form.titleLabel')}</Label>
<Input id="offering-title" data-testid="offering-title" aria-invalid={errors.title !== undefined} {...register('title')} />
{errors.title && <FieldError message={errors.title.message} testId="offering-title-error" />}
</div>
<div className="space-y-2">
<Label htmlFor="offering-description">{t('offerings.form.descriptionLabel')}</Label>
<textarea
id="offering-description"
data-testid="offering-description"
aria-invalid={errors.description !== undefined}
className="flex min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
{...register('description')}
/>
{errors.description && <FieldError message={errors.description.message} testId="offering-description-error" />}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="offering-price">{t('offerings.form.priceLabel')}</Label>
<Input id="offering-price" data-testid="offering-price" aria-invalid={errors.price !== undefined} {...register('price')} />
{errors.price && <FieldError message={errors.price.message} testId="offering-price-error" />}
</div>
<div className="space-y-2">
<Label htmlFor="offering-price-note">{t('offerings.form.priceNoteLabel')}</Label>
<Input
id="offering-price-note"
data-testid="offering-price-note"
aria-invalid={errors.priceNote !== undefined}
{...register('priceNote')}
/>
{errors.priceNote && <FieldError message={errors.priceNote.message} testId="offering-price-note-error" />}
</div>
</div>
<div className="space-y-2">
<Label>{t('offerings.form.featuresLabel')}</Label>
{features.map((_, index) => (
<div key={index} className="flex items-center gap-2">
<Input
data-testid={`offering-feature-${index}`}
aria-invalid={errors.features?.[index] !== undefined}
{...register(`features.${index}` as const)}
/>
<Button
type="button"
variant="ghost"
size="icon"
data-testid={`offering-feature-${index}-remove`}
aria-label={t('offerings.form.removeFeatureButton')}
disabled={features.length <= 1}
onClick={() => removeFeature(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
{errors.features?.message && <FieldError message={errors.features.message} testId="offering-features-error" />}
<Button
type="button"
variant="outline"
size="sm"
data-testid="offering-add-feature"
disabled={features.length >= 10}
onClick={addFeature}
>
<Plus className="size-4" />
{t('offerings.form.addFeatureButton')}
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="offering-cta-label">{t('offerings.form.ctaLabelLabel')}</Label>
<Input
id="offering-cta-label"
data-testid="offering-cta-label"
aria-invalid={errors.ctaLabel !== undefined}
{...register('ctaLabel')}
/>
{errors.ctaLabel && <FieldError message={errors.ctaLabel.message} testId="offering-cta-label-error" />}
</div>
<div className="flex items-center gap-2">
<input
id="offering-featured"
type="checkbox"
data-testid="offering-featured"
className="size-4 rounded border-input"
{...register('featured')}
/>
<Label htmlFor="offering-featured">{t('offerings.form.featuredLabel')}</Label>
</div>
<Button type="submit" disabled={isSubmitting} data-testid="offering-form-submit">
{isSubmitting ? '…' : t('offerings.form.submitButton')}
</Button>
</form>
);
}
@@ -0,0 +1,112 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useTranslation } from 'react-i18next';
import { GripVertical, Star, ChevronUp, ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { Link } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { useUpdateOffering } from '../services/useUpdateOffering';
import { useMoveOffering } from '../services/useMoveOffering';
import type { OfferingAdminDto } from '../services/types';
interface OfferingRowProps {
offering: OfferingAdminDto;
isFirst: boolean;
isLast: boolean;
onDeleteRequested: () => void;
}
export function OfferingRow({ offering, isFirst, isLast, onDeleteRequested }: OfferingRowProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: offering.id });
const updateOffering = useUpdateOffering();
const moveUp = useMoveOffering('up');
const moveDown = useMoveOffering('down');
// The moving visual is rendered by <DragOverlay> instead (see OfferingsList) — a portal
// outside the document flow, so it can't grow the page's scrollable area the way
// transforming this row in place would. This row just hides itself while dragging.
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : 1,
};
return (
<TableRow ref={setNodeRef} style={style} data-testid={`offering-row-${offering.id}`}>
<TableCell>
<button
type="button"
className="cursor-grab text-muted-foreground touch-none"
aria-label={t('offerings.actions.drag')}
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
</TableCell>
<TableCell>{offering.title}</TableCell>
<TableCell>{offering.price}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
data-testid={`offering-row-${offering.id}-featured-toggle`}
aria-label={offering.featured ? t('offerings.actions.unmarkFeatured') : t('offerings.actions.markFeatured')}
onClick={() =>
updateOffering.mutate({
id: offering.id,
title: offering.title,
description: offering.description,
price: offering.price,
priceNote: offering.priceNote,
features: offering.features,
ctaLabel: offering.ctaLabel,
featured: !offering.featured,
})
}
>
<Star className={`size-4 ${offering.featured ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</Button>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
data-testid={`offering-row-${offering.id}-move-up-button`}
aria-label={t('offerings.actions.moveUp')}
disabled={isFirst}
onClick={() => moveUp.mutate(offering.id)}
>
<ChevronUp className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
data-testid={`offering-row-${offering.id}-move-down-button`}
aria-label={t('offerings.actions.moveDown')}
disabled={isLast}
onClick={() => moveDown.mutate(offering.id)}
>
<ChevronDown className="size-4" />
</Button>
<Button variant="ghost" size="icon" asChild data-testid={`offering-row-${offering.id}-edit-link`}>
<Link to="/offerings/$id/edit" params={{ id: offering.id }} aria-label={t('offerings.actions.edit')}>
<Pencil className="size-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
data-testid={`offering-row-${offering.id}-delete-button`}
aria-label={t('offerings.actions.delete')}
onClick={onDeleteRequested}
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
}
@@ -0,0 +1,66 @@
import { DndContext, DragOverlay, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useTranslation } from 'react-i18next';
import { GripVertical } from 'lucide-react';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { OfferingRow } from './OfferingRow';
import { useOfferingsDnd } from '../hooks/useOfferingsDnd';
import type { OfferingAdminDto } from '../services/types';
interface OfferingsListProps {
offerings: OfferingAdminDto[];
onDeleteRequested: (offering: OfferingAdminDto) => void;
}
export function OfferingsList({ offerings, onDeleteRequested }: OfferingsListProps) {
const { t } = useTranslation();
const { items, sensors, activeItem, handleDragStart, handleDragEnd } = useOfferingsDnd(offerings);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<Table>
<TableHeader>
<TableRow>
<TableHead />
<TableHead>{t('offerings.table.title')}</TableHead>
<TableHead>{t('offerings.table.price')}</TableHead>
<TableHead>{t('offerings.table.featured')}</TableHead>
<TableHead>{t('offerings.table.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<SortableContext items={items.map((o) => o.id)} strategy={verticalListSortingStrategy}>
{items.map((offering, index) => (
<OfferingRow
key={offering.id}
offering={offering}
isFirst={index === 0}
isLast={index === items.length - 1}
onDeleteRequested={() => onDeleteRequested(offering)}
/>
))}
</SortableContext>
</TableBody>
</Table>
{/* Rendered in a portal outside document flow, so the dragged item's position
never grows the page's scrollable area the way transforming a table row in
place would (a real browser behavior with in-place transforms, not a bug in
dnd-kit itself). */}
<DragOverlay>
{activeItem && (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3 shadow-lg">
<GripVertical className="size-4 text-muted-foreground" />
<span className="font-medium">{activeItem.title}</span>
<span className="text-muted-foreground">{activeItem.price}</span>
</div>
)}
</DragOverlay>
</DndContext>
);
}
@@ -0,0 +1,53 @@
import { useState } from 'react';
import {
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
} from '@dnd-kit/core';
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { useReorderOfferings } from '../services/useReorderOfferings';
import type { OfferingAdminDto } from '../services/types';
export function useOfferingsDnd(offerings: OfferingAdminDto[]) {
const [items, setItems] = useState(offerings);
const [syncedOfferings, setSyncedOfferings] = useState(offerings);
const [activeId, setActiveId] = useState<string | null>(null);
const reorderMutation = useReorderOfferings();
// Adjusting state during render (not in an effect) avoids the extra render pass
// an effect-based sync would cause — see https://react.dev/learn/you-might-not-need-an-effect.
if (offerings !== syncedOfferings) {
setSyncedOfferings(offerings);
setItems(offerings);
}
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (over === null || active.id === over.id) return;
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(items, oldIndex, newIndex);
setItems(reordered);
reorderMutation.mutate(reordered.map((item) => item.id));
}
const activeItem = activeId === null ? null : (items.find((item) => item.id === activeId) ?? null);
return { items, sensors, activeItem, handleDragStart, handleDragEnd };
}
@@ -0,0 +1,106 @@
import { http, HttpResponse } from 'msw';
import type { OfferingAdminDto, CreateOfferingRequest, UpdateOfferingRequest } from '@/features/offerings/services/types';
import { API_BASE } from '@/features/auth/mocks/fixtures';
const seed: OfferingAdminDto[] = [
{
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
title: 'Starter',
description: 'A simple website',
price: '€ 300',
priceNote: 'one-time',
features: ['1 page', 'Contact form'],
ctaLabel: 'Get started',
featured: false,
displayOrder: 0,
},
{
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
title: 'Pro',
description: 'A full website',
price: '€ 800',
priceNote: 'one-time',
features: ['5 pages', 'Contact form', 'SEO'],
ctaLabel: 'Get started',
featured: true,
displayOrder: 1,
},
];
let mockOfferings: OfferingAdminDto[] = [...seed];
export const resetMockOfferings = () => {
mockOfferings = [...seed];
};
export const getMockOfferings = () => mockOfferings;
export const offeringsHandlers = [
http.get(`${API_BASE}/api/v1/offerings`, () =>
HttpResponse.json(mockOfferings.map(({ displayOrder: _displayOrder, ...dto }) => dto)),
),
http.get(`${API_BASE}/api/v1/offerings/admin`, () => HttpResponse.json(mockOfferings)),
http.post(`${API_BASE}/api/v1/offerings/admin`, async ({ request }) => {
const body = (await request.json()) as CreateOfferingRequest;
const newOffering: OfferingAdminDto = {
id: crypto.randomUUID(),
...body,
displayOrder: mockOfferings.length,
};
mockOfferings = [...mockOfferings, newOffering];
return HttpResponse.json(newOffering, { status: 201 });
}),
http.put(`${API_BASE}/api/v1/offerings/admin/:id`, async ({ params, request }) => {
const { id } = params as { id: string };
const body = (await request.json()) as UpdateOfferingRequest;
const existing = mockOfferings.find((o) => o.id === id);
if (!existing) return new HttpResponse(null, { status: 404 });
const updated: OfferingAdminDto = { ...existing, ...body };
mockOfferings = mockOfferings.map((o) => (o.id === id ? updated : o));
return HttpResponse.json(updated);
}),
http.delete(`${API_BASE}/api/v1/offerings/admin/:id`, ({ params }) => {
const { id } = params as { id: string };
mockOfferings = mockOfferings.filter((o) => o.id !== id);
return new HttpResponse(null, { status: 204 });
}),
http.put(`${API_BASE}/api/v1/offerings/admin/reorder`, async ({ request }) => {
const body = (await request.json()) as { orderedIds: string[] };
mockOfferings = body.orderedIds
.map((id, index) => {
const offering = mockOfferings.find((o) => o.id === id);
return offering ? { ...offering, displayOrder: index } : null;
})
.filter((o): o is OfferingAdminDto => o !== null);
return new HttpResponse(null, { status: 204 });
}),
http.post(`${API_BASE}/api/v1/offerings/admin/:id/move-up`, ({ params }) => {
const { id } = params as { id: string };
moveAdjacent(id, -1);
return new HttpResponse(null, { status: 204 });
}),
http.post(`${API_BASE}/api/v1/offerings/admin/:id/move-down`, ({ params }) => {
const { id } = params as { id: string };
moveAdjacent(id, 1);
return new HttpResponse(null, { status: 204 });
}),
];
function moveAdjacent(id: string, delta: number) {
const sorted = [...mockOfferings].sort((a, b) => a.displayOrder - b.displayOrder);
const index = sorted.findIndex((o) => o.id === id);
const swapIndex = index + delta;
if (index === -1 || swapIndex < 0 || swapIndex >= sorted.length) return;
const a = sorted[index];
const b = sorted[swapIndex];
[a.displayOrder, b.displayOrder] = [b.displayOrder, a.displayOrder];
}
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp, mockAuthenticated } from '@/test/utils';
import { resetMockOfferings } from '@/features/offerings/mocks/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
resetMockOfferings();
});
describe('OfferingFormPage', () => {
it('creates a new offering and returns to the list', async () => {
mockAuthenticated();
renderApp('/offerings/new');
await screen.findByTestId('offering-form-submit', {}, { timeout: 10000 });
await userEvent.type(screen.getByTestId('offering-title'), 'Enterprise');
await userEvent.type(screen.getByTestId('offering-description'), 'A large website');
await userEvent.type(screen.getByTestId('offering-price'), '€ 2000');
await userEvent.type(screen.getByTestId('offering-price-note'), 'one-time');
await userEvent.type(screen.getByTestId('offering-feature-0'), 'Unlimited pages');
await userEvent.type(screen.getByTestId('offering-cta-label'), 'Contact us');
await userEvent.click(screen.getByTestId('offering-form-submit'));
expect(await screen.findByTestId('offerings-title', {}, { timeout: 10000 })).toBeInTheDocument();
expect(screen.getByText('Enterprise')).toBeInTheDocument();
});
it('shows a validation error when required fields are missing', async () => {
mockAuthenticated();
renderApp('/offerings/new');
await screen.findByTestId('offering-form-submit', {}, { timeout: 10000 });
await userEvent.click(screen.getByTestId('offering-form-submit'));
expect(await screen.findByTestId('offering-title-error')).toBeInTheDocument();
});
it('pre-fills the form when editing an existing offering', async () => {
mockAuthenticated();
renderApp('/offerings/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/edit');
const titleInput = await screen.findByTestId('offering-title', {}, { timeout: 10000 });
expect(titleInput).toHaveValue('Starter');
});
});
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { useNavigate, useParams } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { FormErrorBanner } from '@/components/ui/FormErrorBanner';
import { OfferingForm } from '../components/OfferingForm';
import { useOffering } from '../services/useOffering';
import { useCreateOffering } from '../services/useCreateOffering';
import { useUpdateOffering } from '../services/useUpdateOffering';
import { NetworkError } from '@/lib/api-client';
import type { OfferingFormData } from '../schemas/offering';
export function OfferingFormPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams({ strict: false }) as { id?: string };
const isEdit = id !== undefined;
const [serverError, setServerError] = useState<string | null>(null);
const { data: existingOffering, isPending: isLoadingExisting } = useOffering(id);
const createOffering = useCreateOffering();
const updateOffering = useUpdateOffering();
const isSubmitting = createOffering.isPending || updateOffering.isPending;
const onSubmit = async (values: OfferingFormData) => {
setServerError(null);
try {
if (isEdit && id !== undefined) {
await updateOffering.mutateAsync({ id, ...values });
} else {
await createOffering.mutateAsync(values);
}
toast.success(t('offerings.form.successToast'));
navigate({ to: '/offerings' });
} catch (err) {
setServerError(err instanceof NetworkError ? t('errors.network') : t('errors.generic'));
}
};
if (isEdit && isLoadingExisting) {
return <p className="p-4 text-sm text-muted-foreground">{t('common.loading')}</p>;
}
return (
<div className="max-w-2xl space-y-4 p-4">
<h1 className="text-2xl font-semibold" data-testid="offering-form-title">
{isEdit ? t('offerings.form.editTitle') : t('offerings.form.createTitle')}
</h1>
<FormErrorBanner
error={serverError !== null ? { message: serverError } : null}
onDismiss={() => setServerError(null)}
/>
<OfferingForm initialValues={existingOffering} onSubmit={onSubmit} isSubmitting={isSubmitting} />
</div>
);
}
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
import { server } from '@/mocks/server';
import { API_BASE } from '@/features/auth/mocks/fixtures';
import { resetMockOfferings } from '@/features/offerings/mocks/handlers';
import { _resetSetupStatusCache } from '@/router';
beforeEach(() => {
_resetSetupStatusCache();
resetMockOfferings();
});
const OFFERINGS_URL = `${API_BASE}/api/v1/offerings/admin`;
describe('OfferingsListPage', () => {
it('renders the page title and Add button', async () => {
mockAuthenticated();
renderApp('/offerings');
expect(await screen.findByTestId('offerings-title', {}, { timeout: 10000 })).toBeInTheDocument();
expect(screen.getByTestId('offerings-add-button')).toBeInTheDocument();
});
it('renders the seeded offerings', async () => {
mockAuthenticated();
renderApp('/offerings');
expect(await screen.findByText('Starter', {}, { timeout: 10000 })).toBeInTheDocument();
expect(screen.getByText('Pro')).toBeInTheDocument();
});
it('renders empty state when no offerings exist', async () => {
mockAuthenticated();
server.use(http.get(OFFERINGS_URL, () => HttpResponse.json([])));
renderApp('/offerings');
expect(await screen.findByTestId('offerings-empty-state', {}, { timeout: 10000 })).toBeInTheDocument();
});
it('deletes an offering after confirmation', async () => {
mockAuthenticated();
renderApp('/offerings');
const deleteButton = await screen.findByTestId(
'offering-row-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-delete-button',
{},
{ timeout: 10000 },
);
await userEvent.click(deleteButton);
await userEvent.click(await screen.findByTestId('delete-offering-confirm'));
expect(screen.queryByText('Starter')).not.toBeInTheDocument();
expect(screen.getByText('Pro')).toBeInTheDocument();
});
it('cancels delete without removing the offering', async () => {
mockAuthenticated();
renderApp('/offerings');
const deleteButton = await screen.findByTestId(
'offering-row-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-delete-button',
{},
{ timeout: 10000 },
);
await userEvent.click(deleteButton);
await userEvent.click(await screen.findByTestId('delete-offering-cancel'));
expect(screen.getByText('Starter')).toBeInTheDocument();
});
it('disables the move-up button on the first row and move-down on the last', async () => {
mockAuthenticated();
renderApp('/offerings');
await screen.findByText('Starter', {}, { timeout: 10000 });
expect(
screen.getByTestId('offering-row-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-move-up-button'),
).toBeDisabled();
expect(
screen.getByTestId('offering-row-bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb-move-down-button'),
).toBeDisabled();
});
it('redirects unauthenticated users to login', async () => {
mockGuest();
renderApp('/offerings');
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
});
});
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from '@tanstack/react-router';
import { Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { OfferingsList } from '../components/OfferingsList';
import { DeleteOfferingDialog } from '../components/DeleteOfferingDialog';
import { useOfferings } from '../services/useOfferings';
import { useDeleteOffering } from '../services/useDeleteOffering';
import type { OfferingAdminDto } from '../services/types';
export function OfferingsListPage() {
const { t } = useTranslation();
const [offeringPendingDelete, setOfferingPendingDelete] = useState<OfferingAdminDto | null>(null);
const { data: offerings, isPending, isError } = useOfferings();
const deleteOffering = useDeleteOffering({
onSuccess: () => setOfferingPendingDelete(null),
});
return (
<div className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold" data-testid="offerings-title">
{t('offerings.title')}
</h1>
<Button asChild data-testid="offerings-add-button">
<Link to="/offerings/new">{t('offerings.addButton')}</Link>
</Button>
</div>
{isPending && <p className="text-sm text-muted-foreground">{t('common.loading')}</p>}
{isError && <p className="text-sm text-destructive">{t('errors.generic')}</p>}
{!isPending && !isError && offerings?.length === 0 && (
<div
className="flex flex-col items-center justify-center py-16 text-center space-y-4"
data-testid="offerings-empty-state"
>
<Package className="size-12 text-muted-foreground" />
<h2 className="text-xl font-semibold">{t('offerings.emptyState.heading')}</h2>
<p className="text-muted-foreground max-w-sm">{t('offerings.emptyState.description')}</p>
<Button asChild data-testid="offerings-empty-add-button">
<Link to="/offerings/new">{t('offerings.addButton')}</Link>
</Button>
</div>
)}
{!isPending && !isError && offerings && offerings.length > 0 && (
<OfferingsList offerings={offerings} onDeleteRequested={setOfferingPendingDelete} />
)}
<DeleteOfferingDialog
offering={offeringPendingDelete}
isDeleting={deleteOffering.isPending}
onConfirm={() => {
if (offeringPendingDelete) deleteOffering.mutate(offeringPendingDelete.id);
}}
onCancel={() => setOfferingPendingDelete(null)}
/>
</div>
);
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { offeringFormSchema } from './offering';
function validOffering() {
return {
title: 'Starter',
description: 'A simple website',
price: '€ 300',
priceNote: 'one-time',
features: ['1 page'],
ctaLabel: 'Get started',
featured: false,
};
}
describe('offeringFormSchema', () => {
it('accepts valid data', () => {
const result = offeringFormSchema.safeParse(validOffering());
expect(result.success).toBe(true);
});
it('rejects title over 100 characters', () => {
const result = offeringFormSchema.safeParse({ ...validOffering(), title: 'a'.repeat(101) });
expect(result.success).toBe(false);
});
it('rejects description over 500 characters', () => {
const result = offeringFormSchema.safeParse({ ...validOffering(), description: 'a'.repeat(501) });
expect(result.success).toBe(false);
});
it('rejects empty features list', () => {
const result = offeringFormSchema.safeParse({ ...validOffering(), features: [] });
expect(result.success).toBe(false);
});
it('rejects more than 10 features', () => {
const result = offeringFormSchema.safeParse({ ...validOffering(), features: Array(11).fill('Feature') });
expect(result.success).toBe(false);
});
it('rejects a feature over 200 characters', () => {
const result = offeringFormSchema.safeParse({ ...validOffering(), features: ['a'.repeat(201)] });
expect(result.success).toBe(false);
});
it('rejects missing required fields', () => {
const result = offeringFormSchema.safeParse({ ...validOffering(), title: '' });
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,22 @@
import { z } from 'zod';
export const offeringFormSchema = z.object({
title: z.string().min(1, 'Title is required').max(100, 'Title must be 100 characters or fewer'),
description: z
.string()
.min(1, 'Description is required')
.max(500, 'Description must be 500 characters or fewer'),
price: z.string().min(1, 'Price is required').max(50, 'Price must be 50 characters or fewer'),
priceNote: z
.string()
.min(1, 'Price note is required')
.max(100, 'Price note must be 100 characters or fewer'),
features: z
.array(z.string().min(1, 'Feature cannot be empty').max(200, 'Feature must be 200 characters or fewer'))
.min(1, 'At least 1 feature is required')
.max(10, 'At most 10 features are allowed'),
ctaLabel: z.string().min(1, 'CTA label is required').max(50, 'CTA label must be 50 characters or fewer'),
featured: z.boolean(),
});
export type OfferingFormData = z.infer<typeof offeringFormSchema>;
@@ -0,0 +1,25 @@
export interface OfferingAdminDto {
id: string;
title: string;
description: string;
price: string;
priceNote: string;
features: string[];
ctaLabel: string;
featured: boolean;
displayOrder: number;
}
export interface CreateOfferingRequest {
title: string;
description: string;
price: string;
priceNote: string;
features: string[];
ctaLabel: string;
featured: boolean;
}
export type UpdateOfferingRequest = CreateOfferingRequest;
export type MoveDirection = 'up' | 'down';
@@ -0,0 +1,17 @@
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { CreateOfferingRequest, OfferingAdminDto } from './types';
export function useCreateOffering(
options?: Pick<UseMutationOptions<OfferingAdminDto, Error, CreateOfferingRequest>, 'onSuccess' | 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<OfferingAdminDto, Error, CreateOfferingRequest>({
mutationFn: (data) => api.post<OfferingAdminDto>('/api/v1/offerings/admin', data),
onSuccess: (data, variables, ...rest) => {
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
options?.onSuccess?.(data, variables, ...rest);
},
onError: options?.onError,
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
export function useDeleteOffering(
options?: Pick<UseMutationOptions<void, Error, string>, 'onSuccess' | 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => api.delete<void>(`/api/v1/offerings/admin/${id}`),
onSuccess: (data, variables, ...rest) => {
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
options?.onSuccess?.(data, variables, ...rest);
},
onError: options?.onError,
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { MoveDirection } from './types';
export function useMoveOffering(
direction: MoveDirection,
options?: Pick<UseMutationOptions<void, Error, string>, 'onSuccess' | 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => api.post<void>(`/api/v1/offerings/admin/${id}/move-${direction}`),
onSuccess: (data, variables, ...rest) => {
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
options?.onSuccess?.(data, variables, ...rest);
},
onError: options?.onError,
});
}
@@ -0,0 +1,8 @@
import { useOfferings } from './useOfferings';
/** Derived from the `useOfferings()` admin-list cache — no dedicated fetch needed. */
export function useOffering(id: string | undefined) {
const query = useOfferings();
const offering = id === undefined ? undefined : query.data?.find((o) => o.id === id);
return { ...query, data: offering };
}
@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { OfferingAdminDto } from './types';
export function useOfferings() {
return useQuery<OfferingAdminDto[], Error>({
queryKey: ['offerings', 'admin'],
queryFn: () => api.get<OfferingAdminDto[]>('/api/v1/offerings/admin'),
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
export function useReorderOfferings(
options?: Pick<UseMutationOptions<void, Error, string[]>, 'onSuccess' | 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<void, Error, string[]>({
mutationFn: (orderedIds) => api.put<void>('/api/v1/offerings/admin/reorder', { orderedIds }),
onSuccess: (data, variables, ...rest) => {
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
options?.onSuccess?.(data, variables, ...rest);
},
onError: options?.onError,
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { OfferingAdminDto, UpdateOfferingRequest } from './types';
type UpdateVariables = { id: string } & UpdateOfferingRequest;
export function useUpdateOffering(
options?: Pick<UseMutationOptions<OfferingAdminDto, Error, UpdateVariables>, 'onSuccess' | 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<OfferingAdminDto, Error, UpdateVariables>({
mutationFn: ({ id, ...body }) => api.put<OfferingAdminDto>(`/api/v1/offerings/admin/${id}`, body),
onSuccess: (data, variables, ...rest) => {
queryClient.invalidateQueries({ queryKey: ['offerings', 'admin'] });
options?.onSuccess?.(data, variables, ...rest);
},
onError: options?.onError,
});
}
@@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw';
export const systemHandlers = [
http.get('*/System/capabilities', () =>
HttpResponse.json({
modules: ['Availability', 'Identity', 'Master'],
modules: ['Availability', 'Identity', 'Master', 'Offerings'],
}),
),
];
@@ -10,6 +10,7 @@
"dashboard": "Dashboard",
"users": "Users",
"cms": "CMS",
"offerings": "Offerings",
"profile": "Profile",
"settings": "Settings",
"openMenu": "Open navigation",
@@ -230,6 +231,50 @@
"successUnreachableToast": "Status saved — cliënt unreachable"
}
},
"offerings": {
"title": "Offerings",
"addButton": "Add Offering",
"emptyState": {
"heading": "No offerings yet",
"description": "Add your first offering to get started."
},
"table": {
"title": "Title",
"price": "Price",
"featured": "Featured",
"actions": "Actions"
},
"actions": {
"drag": "Drag to reorder",
"edit": "Edit",
"delete": "Delete",
"moveUp": "Move up",
"moveDown": "Move down",
"markFeatured": "Mark as featured",
"unmarkFeatured": "Remove featured"
},
"deleteDialog": {
"title": "Delete offering",
"message": "Are you sure you want to delete '{{title}}'?",
"confirmButton": "Delete",
"cancelButton": "Cancel"
},
"form": {
"createTitle": "New Offering",
"editTitle": "Edit Offering",
"titleLabel": "Title",
"descriptionLabel": "Description",
"priceLabel": "Price",
"priceNoteLabel": "Price note",
"featuresLabel": "Features",
"addFeatureButton": "Add feature",
"removeFeatureButton": "Remove",
"ctaLabelLabel": "Button text",
"featuredLabel": "Featured",
"submitButton": "Save",
"successToast": "Offering saved successfully"
}
},
"error": {
"403": {
"title": "Access Denied",
@@ -10,6 +10,7 @@
"dashboard": "Dashboard",
"users": "Gebruikers",
"cms": "CMS",
"offerings": "Aanbiedingen",
"profile": "Profiel",
"settings": "Instellingen",
"openMenu": "Navigatie openen",
@@ -230,6 +231,50 @@
"successUnreachableToast": "Status opgeslagen — cliënt niet bereikbaar"
}
},
"offerings": {
"title": "Aanbiedingen",
"addButton": "Aanbieding toevoegen",
"emptyState": {
"heading": "Nog geen aanbiedingen",
"description": "Voeg je eerste aanbieding toe om te beginnen."
},
"table": {
"title": "Titel",
"price": "Prijs",
"featured": "Uitgelicht",
"actions": "Acties"
},
"actions": {
"drag": "Sleep om te herordenen",
"edit": "Bewerken",
"delete": "Verwijderen",
"moveUp": "Omhoog verplaatsen",
"moveDown": "Omlaag verplaatsen",
"markFeatured": "Markeren als uitgelicht",
"unmarkFeatured": "Uitgelicht verwijderen"
},
"deleteDialog": {
"title": "Aanbieding verwijderen",
"message": "Weet je zeker dat je '{{title}}' wilt verwijderen?",
"confirmButton": "Verwijderen",
"cancelButton": "Annuleren"
},
"form": {
"createTitle": "Nieuwe aanbieding",
"editTitle": "Aanbieding bewerken",
"titleLabel": "Titel",
"descriptionLabel": "Beschrijving",
"priceLabel": "Prijs",
"priceNoteLabel": "Prijsnotitie",
"featuresLabel": "Kenmerken",
"addFeatureButton": "Kenmerk toevoegen",
"removeFeatureButton": "Verwijderen",
"ctaLabelLabel": "Knoptekst",
"featuredLabel": "Uitgelicht",
"submitButton": "Opslaan",
"successToast": "Aanbieding succesvol opgeslagen"
}
},
"error": {
"403": {
"title": "Toegang geweigerd",
+3 -1
View File
@@ -5,9 +5,10 @@ import { invitationHandlers } from '@/features/invitation/mocks/handlers';
import { availabilityHandlers } from '@/features/availability/mocks/handlers';
import { cmsHandlers } from '@/features/cms/mocks/handlers';
import { systemHandlers } from '@/features/system/mocks/handlers';
import { offeringsHandlers } from '@/features/offerings/mocks/handlers';
/** All default MSW handlers, composed from feature folders (Q3-B). */
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers, ...systemHandlers];
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers, ...systemHandlers, ...offeringsHandlers];
export { authHandlers } from '@/features/auth/mocks/handlers';
export { userHandlers } from '@/features/users/mocks/handlers';
@@ -16,4 +17,5 @@ export { invitationHandlers } from '@/features/invitation/mocks/handlers';
export { availabilityHandlers } from '@/features/availability/mocks/handlers';
export { cmsHandlers, resetMockCmsInstances, getMockCmsInstances } from '@/features/cms/mocks/handlers';
export { systemHandlers } from '@/features/system/mocks/handlers';
export { offeringsHandlers, resetMockOfferings, getMockOfferings } from '@/features/offerings/mocks/handlers';
export * from '@/features/auth/mocks/fixtures';
+46 -1
View File
@@ -197,6 +197,42 @@ const cmsRoute = createRoute({
),
});
const offeringsRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/offerings',
component: () => (
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
<ModuleGuard requiredModule="Offerings">
{lazyPage(() => import('@/features/offerings/pages/OfferingsListPage'), 'OfferingsListPage')()}
</ModuleGuard>
</RoleGuard>
),
});
const offeringsNewRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/offerings/new',
component: () => (
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
<ModuleGuard requiredModule="Offerings">
{lazyPage(() => import('@/features/offerings/pages/OfferingFormPage'), 'OfferingFormPage')()}
</ModuleGuard>
</RoleGuard>
),
});
const offeringsEditRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/offerings/$id/edit',
component: () => (
<RoleGuard allowedRoles={['Owner', 'Administrator']}>
<ModuleGuard requiredModule="Offerings">
{lazyPage(() => import('@/features/offerings/pages/OfferingFormPage'), 'OfferingFormPage')()}
</ModuleGuard>
</RoleGuard>
),
});
const settingsRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: '/settings',
@@ -225,7 +261,16 @@ export const routeTree = rootRoute.addChildren([
setupRoute,
inviteCompleteRoute,
accessDeniedRoute,
authenticatedRoute.addChildren([dashboardRoute, usersRoute, cmsRoute, settingsRoute, profileRoute]),
authenticatedRoute.addChildren([
dashboardRoute,
usersRoute,
cmsRoute,
offeringsRoute,
offeringsNewRoute,
offeringsEditRoute,
settingsRoute,
profileRoute,
]),
]);
export const router = createRouter({