# 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
(create and edit)"] list["OfferingsList
(dnd-kit SortableContext)"] row["OfferingRow
(dnd-kit useSortable)"] delete_dialog["DeleteOfferingDialog"] form["OfferingForm
(shared by create/edit)"] dnd_hook["useOfferingsDnd
(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/`.