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
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:
@@ -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",
|
||||
|
||||
Generated
+120
-62
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user