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
8.2 KiB
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
%%{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 likeuseCmsInstances.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 justuseOfferingsDnd(drag-and-drop orchestration, which calls aservices/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/.