Files
SluijsensandClaude Sonnet 5 cfb06b28b6
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
Adds the Offerings module and retargets the CI/CD pipeline to Api.SlpSoftware
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
2026-08-02 16:23:09 +02:00

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 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/.