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:
+139
@@ -0,0 +1,139 @@
|
||||
# Business Logic Model — Unit: Offerings
|
||||
|
||||
## Flow 1: Create/Update an Offering (with Featured Exclusivity)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
box rgba(246,224,94,0.4) Admin
|
||||
participant Admin as CMS Administrator
|
||||
end
|
||||
box rgba(99,179,237,0.4) Frontend
|
||||
participant Form as OfferingFormPage
|
||||
end
|
||||
box rgba(159,122,234,0.4) Backend
|
||||
participant Ctrl as OfferingsController
|
||||
participant Svc as OfferingsService
|
||||
participant Repo as OfferingRepository
|
||||
end
|
||||
|
||||
Admin->>Form: Fills form, toggles Featured, submits
|
||||
Form->>Form: Validate against zod schema (BR-OFF-04, client-side mirror)
|
||||
Form->>Ctrl: POST or PUT with offering data
|
||||
Ctrl->>Svc: CreateAsync or UpdateAsync
|
||||
Svc->>Svc: Re-validate (BR-OFF-04, server-side, authoritative)
|
||||
alt Featured set to true
|
||||
Svc->>Repo: GetFeaturedAsync
|
||||
Repo-->>Svc: currently-featured Offering or none
|
||||
Svc->>Repo: UpdateAsync (unfeature previous, if any)
|
||||
end
|
||||
Svc->>Repo: AddAsync or UpdateAsync (this offering)
|
||||
Repo-->>Svc: saved Offering
|
||||
Svc-->>Ctrl: OfferingAdminDto
|
||||
Ctrl-->>Form: 200/201 with the saved offering
|
||||
Form-->>Admin: Redirect to offerings list
|
||||
```
|
||||
|
||||
Text alternative: the admin fills the create/edit page (a full page, not a modal — Functional Design Q2), client-side validation runs first for immediate feedback, then the server re-validates authoritatively; if `Featured` is being set to true, the service un-features any previously-featured offering in the same operation before saving (yellow = admin actor, blue = frontend page, purple = backend layers).
|
||||
|
||||
## Flow 2: Delete an Offering (with Confirmation)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
box rgba(246,224,94,0.4) Admin
|
||||
participant Admin as CMS Administrator
|
||||
end
|
||||
box rgba(99,179,237,0.4) Frontend
|
||||
participant List as OfferingsListPage
|
||||
participant Dialog as DeleteOfferingDialog
|
||||
end
|
||||
box rgba(159,122,234,0.4) Backend
|
||||
participant Ctrl as OfferingsController
|
||||
participant Svc as OfferingsService
|
||||
end
|
||||
|
||||
Admin->>List: Clicks delete on a row
|
||||
List->>Dialog: Open confirmation dialog (Functional Design Q4)
|
||||
Admin->>Dialog: Confirms
|
||||
Dialog->>Ctrl: DELETE request
|
||||
Ctrl->>Svc: DeleteAsync (always allowed, BR-OFF-02)
|
||||
Svc-->>Ctrl: success
|
||||
Ctrl-->>List: 204, remove row from list
|
||||
```
|
||||
|
||||
Text alternative: deletion always shows a confirmation dialog first (a frontend-only safeguard); once confirmed, the delete request is unconditionally accepted by the backend, including for the last remaining offering.
|
||||
|
||||
## Flow 3: Reorder via Drag-and-Drop (US-08)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
box rgba(246,224,94,0.4) Admin
|
||||
participant Admin as CMS Administrator
|
||||
end
|
||||
box rgba(99,179,237,0.4) Frontend
|
||||
participant List as OfferingsListPage
|
||||
end
|
||||
box rgba(159,122,234,0.4) Backend
|
||||
participant Ctrl as OfferingsController
|
||||
participant Svc as OfferingsService
|
||||
end
|
||||
|
||||
Admin->>List: Drags a row to a new position (dnd-kit)
|
||||
List->>List: Reorders local row state optimistically
|
||||
List->>Ctrl: PUT reorder with the full ordered id list
|
||||
Ctrl->>Svc: ReorderAsync(orderedIds)
|
||||
Svc->>Svc: Reassign DisplayOrder sequentially to match
|
||||
Svc-->>Ctrl: success
|
||||
Ctrl-->>List: 204 (or revert local state on failure)
|
||||
```
|
||||
|
||||
Text alternative: dragging a row reorders the frontend's local list immediately (perceived responsiveness), then sends the complete new order to the backend, which reassigns every offering's `DisplayOrder` to match in one operation.
|
||||
|
||||
## Flow 4: Reorder via Up/Down Buttons (US-09)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
box rgba(246,224,94,0.4) Admin
|
||||
participant Admin as CMS Administrator
|
||||
end
|
||||
box rgba(99,179,237,0.4) Frontend
|
||||
participant List as OfferingsListPage
|
||||
end
|
||||
box rgba(159,122,234,0.4) Backend
|
||||
participant Ctrl as OfferingsController
|
||||
participant Svc as OfferingsService
|
||||
end
|
||||
|
||||
Admin->>List: Clicks "move up" on a row
|
||||
List->>Ctrl: POST move-up for that offering id
|
||||
Ctrl->>Svc: MoveUpAsync(id)
|
||||
Svc->>Svc: Swap DisplayOrder with the<br/>preceding offering (BR-OFF-03)
|
||||
Svc-->>Ctrl: success
|
||||
Ctrl-->>List: 204, list refetches or reorders locally
|
||||
```
|
||||
|
||||
Text alternative: an explicit per-row button swaps the offering's position with its immediate neighbor — the accessible alternative to drag-and-drop, symmetric for "move down".
|
||||
|
||||
## Flow 5: Featured Toggle from the List Row (Functional Design Q5)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
box rgba(246,224,94,0.4) Admin
|
||||
participant Admin as CMS Administrator
|
||||
end
|
||||
box rgba(99,179,237,0.4) Frontend
|
||||
participant List as OfferingsListPage
|
||||
end
|
||||
box rgba(159,122,234,0.4) Backend
|
||||
participant Ctrl as OfferingsController
|
||||
participant Svc as OfferingsService
|
||||
end
|
||||
|
||||
Admin->>List: Clicks the "featured" star icon on a row
|
||||
List->>Ctrl: PUT update using the row's already-loaded data,<br/>with Featured flipped
|
||||
Ctrl->>Svc: UpdateAsync
|
||||
Note over Svc: Same BR-OFF-01 exclusivity logic as the form path
|
||||
Svc-->>Ctrl: success
|
||||
Ctrl-->>List: 200, list reflects the new featured offering
|
||||
```
|
||||
|
||||
Text alternative: the quick list-row star action is not a separate backend capability — it calls the exact same update endpoint as the full edit form, just pre-filled from data the list already has in memory, so BR-OFF-01's exclusivity rule applies identically no matter which UI path the admin used (resolves Functional Design Q5: both the form toggle and the list-row action work, backed by one shared mechanism).
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# Business Rules — Unit: Offerings
|
||||
|
||||
## BR-OFF-01: Featured Exclusivity Rule (US-10)
|
||||
|
||||
At most one non-deleted `Offering` may have `Featured = true` at any time.
|
||||
|
||||
```mermaid
|
||||
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
|
||||
graph TD
|
||||
start{"Create or Update request<br/>has Featured = true?"}
|
||||
find_current{"A different Offering<br/>is currently Featured?"}
|
||||
unfeature["Set that Offering's<br/>Featured = false"]
|
||||
save["Save the request's Offering<br/>with Featured = true"]
|
||||
save_asis["Save the request's Offering<br/>as submitted (Featured value unchanged)"]
|
||||
|
||||
start -->|"No"| save_asis
|
||||
start -->|"Yes"| find_current
|
||||
find_current -->|"Yes"| unfeature --> save
|
||||
find_current -->|"No"| save
|
||||
|
||||
classDef decision fill:#fbd38d,stroke:#92400e,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
classDef outcome fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
|
||||
class start,find_current decision;
|
||||
class unfeature,save,save_asis outcome;
|
||||
```
|
||||
|
||||
Text alternative: if a create/update request sets `Featured = true`, the service first checks for a different currently-featured offering and un-features it before saving; if the request does not set `Featured = true`, the offering is saved with whatever `Featured` value was submitted (allowing an admin to explicitly un-feature the current one, per US-10's third scenario).
|
||||
|
||||
**Applies identically regardless of UI entry point** (Functional Design Q5): whether the admin sets `Featured` via the create/edit form's toggle, or via the list-row quick-action, both call the same `IOfferingsService.CreateAsync`/`UpdateAsync` methods — this rule lives in the service layer, not in either UI path.
|
||||
|
||||
## BR-OFF-02: Soft Delete Never Blocked (US-06/US-07)
|
||||
|
||||
```mermaid
|
||||
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
|
||||
graph TD
|
||||
delete_req{"Delete request for Offering X"}
|
||||
is_last{"X is the last remaining<br/>non-deleted Offering?"}
|
||||
proceed["Set X.IsDeleted = true,<br/>X.DeletedAt = now"]
|
||||
|
||||
delete_req --> is_last
|
||||
is_last -->|"Yes"| proceed
|
||||
is_last -->|"No"| proceed
|
||||
|
||||
classDef decision fill:#fbd38d,stroke:#92400e,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
classDef outcome fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
|
||||
class delete_req,is_last decision;
|
||||
class proceed outcome;
|
||||
```
|
||||
|
||||
Text alternative: deletion is always permitted regardless of how many offerings remain — the "last remaining offering" case is drawn explicitly to show it is not a special case that blocks the operation (D-5/Q4 in requirements.md).
|
||||
|
||||
**Admin UI adds a confirmation step** (Functional Design Q4) before the delete request is even sent — a UI/UX safeguard, not a backend rule; the backend itself does not require confirmation semantics.
|
||||
|
||||
## BR-OFF-03: Reorder Boundary Rules (US-08/US-09)
|
||||
|
||||
```mermaid
|
||||
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
|
||||
graph TD
|
||||
move_up{"MoveUp requested<br/>for Offering X"}
|
||||
is_first{"X has the lowest<br/>DisplayOrder (already first)?"}
|
||||
noop_up["No-op — X is already<br/>first, nothing to swap with"]
|
||||
swap_up["Swap DisplayOrder with the<br/>Offering immediately before X"]
|
||||
|
||||
move_up --> is_first
|
||||
is_first -->|"Yes"| noop_up
|
||||
is_first -->|"No"| swap_up
|
||||
|
||||
classDef decision fill:#fbd38d,stroke:#92400e,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
classDef outcome fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
|
||||
class move_up,is_first decision;
|
||||
class noop_up,swap_up outcome;
|
||||
```
|
||||
|
||||
Text alternative: `MoveUp` on the first item in the list is a no-op (the frontend disables the button in this state per US-09's acceptance criteria); `MoveDown` on the last item is symmetric. `ReorderAsync` (drag-and-drop, US-08) receives the complete ordered list of IDs and reassigns `DisplayOrder` sequentially (0, 1, 2, ...) to match — it has no boundary case, since it always resequences the entire list at once.
|
||||
|
||||
## BR-OFF-04: Field Validation Rules (SECURITY-05, Functional Design Q3)
|
||||
|
||||
```mermaid
|
||||
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
|
||||
graph TD
|
||||
submit{"Create/Update request submitted"}
|
||||
check_required{"Title, Description, Price,<br/>PriceNote, CtaLabel all non-empty,<br/>and at least 1 Feature?"}
|
||||
check_lengths{"Title ≤100, Description ≤500,<br/>Price ≤50, PriceNote ≤100, CtaLabel ≤50,<br/>each Feature ≤200, Features count ≤10?"}
|
||||
reject["Reject: 400 with field-level<br/>validation errors (US-11)"]
|
||||
accept["Proceed to BR-OFF-01"]
|
||||
|
||||
submit --> check_required
|
||||
check_required -->|"No"| reject
|
||||
check_required -->|"Yes"| check_lengths
|
||||
check_lengths -->|"No"| reject
|
||||
check_lengths -->|"Yes"| accept
|
||||
|
||||
classDef decision fill:#fbd38d,stroke:#92400e,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
classDef outcome fill:#f56565,stroke:#9b2c2c,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
classDef success fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
|
||||
class submit,check_required,check_lengths decision;
|
||||
class reject outcome;
|
||||
class accept success;
|
||||
```
|
||||
|
||||
Text alternative: every create/update request is checked for required fields first, then for length/count bounds; either failure rejects the request with field-level errors (no partial save), matching US-11's acceptance criteria. Bounds are enforced identically on the backend (source of truth, SECURITY-05) and mirrored in the frontend form schema for immediate user feedback (Functional Design Q3 = A: Title 100, Description 500, Price 50, PriceNote 100, CtaLabel 50 characters; Features 1-10 items, each ≤200 characters).
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Domain Entities — Unit: Offerings
|
||||
|
||||
## Entity Relationships
|
||||
|
||||
```mermaid
|
||||
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
|
||||
graph TD
|
||||
offering["Offering<br/>(entity)"]
|
||||
public_dto["OfferingDto<br/>(public contract, FR-6)"]
|
||||
admin_dto["OfferingAdminDto<br/>(admin view, FR-7)"]
|
||||
|
||||
offering -->|"projected to (excludes IsDeleted/DeletedAt)"| public_dto
|
||||
offering -->|"projected to (adds DisplayOrder)"| admin_dto
|
||||
|
||||
classDef entity fill:#bee3f8,stroke:#0d47a1,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
classDef dto fill:#fed7aa,stroke:#e65100,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
|
||||
class offering entity;
|
||||
class public_dto,admin_dto dto;
|
||||
```
|
||||
|
||||
Text alternative: the single `Offering` entity projects into two different DTO shapes — the public contract (no internal/administrative fields) and the admin view (adds `DisplayOrder` for the list UI) — blue for the stateful entity, orange for the two value-object projections.
|
||||
|
||||
## Entity Definitions
|
||||
|
||||
### Offering
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `Id` | `Guid` | Yes | System-generated (Application Design Q4 = A). Public contract exposes this as a `string`. |
|
||||
| `Title` | `string` | Yes | Max 100 characters (Functional Design Q3 = A). |
|
||||
| `Description` | `string` | Yes | Max 500 characters. |
|
||||
| `Price` | `string` | Yes | Max 50 characters. Pre-formatted display string, not numeric (FR-5) — e.g. `"€ 300"` or `"Op maat"`. |
|
||||
| `PriceNote` | `string` | Yes | Max 100 characters. |
|
||||
| `Features` | `List<string>` | Yes | Min 1, max 10 items; each item max 200 characters. Ordered — array order is display order. |
|
||||
| `CtaLabel` | `string` | Yes | Max 50 characters. |
|
||||
| `Featured` | `bool` | No (default `false`) | At most one non-deleted `Offering` may have this `true` at any time (US-10, enforced in `IOfferingsService`, not at the database/constraint level — see business-rules.md). |
|
||||
| `DisplayOrder` | `int` | Yes | Determines public array order and admin list order. Unique among non-deleted offerings; not necessarily contiguous after deletions (soft delete does not renumber). |
|
||||
| `IsDeleted` | `bool` | Yes (default `false`) | Soft-delete flag (Application Design Q5 = B). Never exposed on either DTO. |
|
||||
| `DeletedAt` | `DateTimeOffset?` | No | Set when `IsDeleted` becomes `true`. Never exposed on either DTO. |
|
||||
| `CreatedAt` | `DateTimeOffset` | Yes | Set once on creation. Part of the SECURITY-13 audit trail ("when"). |
|
||||
| `UpdatedAt` | `DateTimeOffset` | Yes | Set on every create/update. Same rationale as `CreatedAt`. |
|
||||
| `LastModifiedByUserId` | `Guid` | Yes | The authenticated admin's user id, set on every create/update/delete (NFR Requirements Q3 = A). Closes the "who" half of the SECURITY-13 open item alongside `CreatedAt`/`UpdatedAt`'s "when" — together a minimal audit trail without building a full audit-log table. |
|
||||
|
||||
### OfferingDto (public contract, FR-6)
|
||||
|
||||
| Field | Type | Maps From |
|
||||
|---|---|---|
|
||||
| `id` | `string` | `Offering.Id.ToString()` |
|
||||
| `title` | `string` | `Offering.Title` |
|
||||
| `description` | `string` | `Offering.Description` |
|
||||
| `price` | `string` | `Offering.Price` |
|
||||
| `priceNote` | `string` | `Offering.PriceNote` |
|
||||
| `features` | `string[]` | `Offering.Features` |
|
||||
| `ctaLabel` | `string` | `Offering.CtaLabel` |
|
||||
| `featured` | `bool` | `Offering.Featured` |
|
||||
|
||||
### OfferingAdminDto (admin view, FR-7)
|
||||
|
||||
Same fields as `OfferingDto`, plus:
|
||||
|
||||
| Field | Type | Maps From |
|
||||
|---|---|---|
|
||||
| `displayOrder` | `int` | `Offering.DisplayOrder` |
|
||||
|
||||
`IsDeleted`/`DeletedAt`/`CreatedAt`/`UpdatedAt`/`LastModifiedByUserId` are intentionally not exposed on either DTO — soft-deleted rows are never returned to any caller (repository-level filtering, per services.md), and the audit fields exist for potential future audit tooling, not for display in this unit's admin UI.
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# Frontend Components — Unit: Offerings
|
||||
|
||||
New feature folder `frontend/src/features/offerings/`, following the existing `frontend/src/features/cms/` structural pattern (pages/components/services/schemas) — the closest existing precedent for an admin list+CRUD screen in this codebase (Functional Design Q2 investigation).
|
||||
|
||||
**New dependency** (Functional Design Q1 = A): `@dnd-kit/core` + `@dnd-kit/sortable`, added to `frontend/package.json`.
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```mermaid
|
||||
%%{init: {'themeVariables': {'primaryTextColor':'#000000','textColor':'#000000','tertiaryTextColor':'#000000'}}}%%
|
||||
graph TD
|
||||
router["Admin Router"]
|
||||
list_page["OfferingsListPage"]
|
||||
form_page["OfferingFormPage<br/>(create and edit)"]
|
||||
list["OfferingsList<br/>(dnd-kit SortableContext)"]
|
||||
row["OfferingRow<br/>(dnd-kit useSortable)"]
|
||||
delete_dialog["DeleteOfferingDialog"]
|
||||
form["OfferingForm<br/>(shared by create/edit)"]
|
||||
dnd_hook["useOfferingsDnd<br/>(hooks/)"]
|
||||
|
||||
router -->|"/admin/offerings"| list_page
|
||||
router -->|"/admin/offerings/new"| form_page
|
||||
router -->|"/admin/offerings/:id/edit"| form_page
|
||||
list_page --> list
|
||||
list --> row
|
||||
list --> dnd_hook
|
||||
list_page --> delete_dialog
|
||||
form_page --> form
|
||||
|
||||
classDef root fill:#c6f6d5,stroke:#2e7d32,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
classDef page fill:#bee3f8,stroke:#0d47a1,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
classDef component fill:#e9d8fd,stroke:#4a148c,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
classDef hook fill:#fed7aa,stroke:#c05621,stroke-width:2px,color:#000000,font-weight:bold;
|
||||
|
||||
class router root;
|
||||
class list_page,form_page page;
|
||||
class list,row,delete_dialog,form component;
|
||||
class dnd_hook hook;
|
||||
```
|
||||
|
||||
Text alternative: the admin router has three Offerings routes — a list page, and a shared form page used for both create and edit (Functional Design Q2: separate pages, not modals). The list page composes a sortable list component (dnd-kit) made of individual rows, a delete-confirmation dialog, and the `useOfferingsDnd` hook that owns the drag-and-drop orchestration; the form page composes a single shared form component (green = router root, blue = pages, purple = components, orange = the feature-local hook).
|
||||
|
||||
## `OfferingsListPage` (`pages/OfferingsListPage.tsx`)
|
||||
|
||||
**Route**: `/admin/offerings`
|
||||
|
||||
**Responsibilities**: Fetches the admin offering list, renders `OfferingsList`, hosts `DeleteOfferingDialog`, links to `/admin/offerings/new` and per-row `/admin/offerings/:id/edit`.
|
||||
|
||||
**State**: `offeringPendingDelete: OfferingAdminDto | null` (controls whether `DeleteOfferingDialog` is open).
|
||||
|
||||
**API calls**: `useOfferings()` (`GET /api/v1/offerings/admin`).
|
||||
|
||||
## `OfferingsList` (`components/OfferingsList.tsx`)
|
||||
|
||||
**Props**: `offerings: OfferingAdminDto[]`, `onDeleteRequested: (offering: OfferingAdminDto) => void`.
|
||||
|
||||
**Responsibilities**: Wraps rows in a dnd-kit `DndContext`/`SortableContext` (Functional Design Q1), delegating the drag-end orchestration to `useOfferingsDnd` rather than handling it inline.
|
||||
|
||||
**Hooks used**: `useOfferingsDnd(offerings)` (see below).
|
||||
|
||||
## `useOfferingsDnd` (`hooks/useOfferingsDnd.ts`)
|
||||
|
||||
**Not an API-calling hook** — deliberately kept out of `services/`, since it owns dnd-kit sensor setup and local drag-state, and only calls into a `services/` hook at the end. A feature's own `hooks/` folder is a legitimate place for this kind of feature-local, non-API hook logic — it doesn't need to be either "in `services/`" or "promoted to the top-level `frontend/src/hooks/`"; those aren't the only two options.
|
||||
|
||||
**Responsibilities**: Configures dnd-kit sensors, computes the new order on drag end, optimistically updates local list state, and calls `useReorderOfferings()` (from `services/`) with the resulting ordered id list.
|
||||
|
||||
**Returns**: `{ items, sensors, handleDragEnd }` for `OfferingsList` to spread onto its `DndContext`/`SortableContext`.
|
||||
|
||||
**API calls (indirect, via the hook below)**: `useReorderOfferings()` (`PUT /api/v1/offerings/admin/reorder`).
|
||||
|
||||
## `OfferingRow` (`components/OfferingRow.tsx`)
|
||||
|
||||
**Props**: `offering: OfferingAdminDto`, `isFirst: boolean`, `isLast: boolean`, `onDeleteRequested: () => void`.
|
||||
|
||||
**Responsibilities**: Displays title/price/featured badge; a "featured" star icon (toggle, Functional Design Q5 — calls `useUpdateOffering()` with only `featured` flipped, reusing the row's already-loaded data); "move up"/"move down" buttons (disabled per `isFirst`/`isLast`, US-09); edit link to `/admin/offerings/:id/edit`; delete button (calls `onDeleteRequested`).
|
||||
|
||||
**Data-testid convention**: `offering-row-{id}-edit-link`, `offering-row-{id}-delete-button`, `offering-row-{id}-move-up-button`, `offering-row-{id}-move-down-button`, `offering-row-{id}-featured-toggle`.
|
||||
|
||||
**API calls**: `useUpdateOffering()` (featured toggle), `useMoveOffering(direction)` (`POST /api/v1/offerings/admin/{id}/move-up` or `/move-down`).
|
||||
|
||||
## `DeleteOfferingDialog` (`components/DeleteOfferingDialog.tsx`)
|
||||
|
||||
**Props**: `offering: OfferingAdminDto | null` (null = closed), `onConfirm: () => void`, `onCancel: () => void`.
|
||||
|
||||
**Responsibilities**: Confirmation dialog (Functional Design Q4) — "Weet je zeker dat je '[title]' wilt verwijderen?" / English equivalent per i18n.
|
||||
|
||||
**API calls**: none directly — the parent page calls `useDeleteOffering()` on confirm.
|
||||
|
||||
## `OfferingFormPage` (`pages/OfferingFormPage.tsx`)
|
||||
|
||||
**Routes**: `/admin/offerings/new` (create) and `/admin/offerings/:id/edit` (edit — loads the existing offering via `useOffering(id)` first).
|
||||
|
||||
**Responsibilities**: Hosts `OfferingForm`; on successful submit, navigates back to `/admin/offerings`.
|
||||
|
||||
## `OfferingForm` (`components/OfferingForm.tsx`)
|
||||
|
||||
**Props**: `initialValues?: OfferingAdminDto` (undefined for create), `onSubmit: (values: OfferingFormData) => void`, `isSubmitting: boolean`.
|
||||
|
||||
**Form fields**: `title`, `description`, `price`, `priceNote`, `features` (dynamic list, add/remove item, 1-10 items), `ctaLabel`, `featured` (checkbox/toggle, Functional Design Q5).
|
||||
|
||||
**Validation**: `react-hook-form` + `zod`, schema in `schemas/offering.ts`, mirroring BR-OFF-04's server-side bounds (Functional Design Q3): `title` ≤100, `description` ≤500, `price` ≤50, `priceNote` ≤100, `ctaLabel` ≤50, `features` 1-10 items each ≤200 characters, all required except `featured` (defaults `false`).
|
||||
|
||||
**API calls**: `useCreateOffering()` (`POST /api/v1/offerings/admin`) or `useUpdateOffering()` (`PUT /api/v1/offerings/admin/{id}`), selected by whether `initialValues` is present.
|
||||
|
||||
## Hooks
|
||||
|
||||
Two hook locations in this feature, split by what the hook actually does — not by a "shared vs. feature-specific" rule (a feature's own `hooks/` folder is a legitimate, separate option; it isn't limited to either "lives in `services/`" or "gets promoted to the top-level `frontend/src/hooks/`"):
|
||||
|
||||
- **`services/`** — React Query hooks that call the backend API, following the existing codebase convention (`frontend/src/features/cms/services/` already holds hooks like `useCmsInstances.ts`, not OOP-style service classes — the folder name is the established convention, the contents are hooks). Table below.
|
||||
- **`hooks/`** — feature-local hooks that are not themselves API calls. Currently just `useOfferingsDnd` (drag-and-drop orchestration, which calls a `services/` hook internally but isn't one itself).
|
||||
|
||||
### `services/` (API-calling hooks)
|
||||
|
||||
| Hook | Method/Route | Purpose |
|
||||
|---|---|---|
|
||||
| `useOfferings()` | `GET /api/v1/offerings/admin` | List for `OfferingsListPage` |
|
||||
| `useOffering(id)` | (derived from `useOfferings()` cache, or a dedicated fetch if not cached) | Prefill `OfferingFormPage` in edit mode |
|
||||
| `useCreateOffering()` | `POST /api/v1/offerings/admin` | Create |
|
||||
| `useUpdateOffering()` | `PUT /api/v1/offerings/admin/{id}` | Edit form submit, and the list-row featured toggle |
|
||||
| `useDeleteOffering()` | `DELETE /api/v1/offerings/admin/{id}` | Delete, after confirmation |
|
||||
| `useReorderOfferings()` | `PUT /api/v1/offerings/admin/reorder` | Drag-and-drop |
|
||||
| `useMoveOffering(direction)` | `POST /api/v1/offerings/admin/{id}/move-up` or `/move-down` | Button-based reorder |
|
||||
|
||||
All mutating hooks invalidate the `['offerings', 'admin']` query key on success, matching the existing `useAddCmsInstance`/`useUpdateCmsInstanceStatus` pattern in `features/cms/services/`.
|
||||
Reference in New Issue
Block a user