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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWyStNL2ZsjrS7FLd7xvvN
This commit is contained in:
2026-08-02 16:23:09 +02:00
co-authored by Claude Sonnet 5
parent b6e9c07c06
commit cfb06b28b6
79 changed files with 4069 additions and 94 deletions
@@ -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).
@@ -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).
@@ -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.
@@ -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/`.