# Hand-off: Packages API (`GET /api/v1/offerings`) **Audience**: whoever implements the CMS/backend that will serve package content. **Status**: frontend is already wired up and waiting on this endpoint to exist. ## Context The "Drie manieren om te starten" section on the marketing site ([PackagesSection.tsx](../../../../../../../src/features/landing/components/PackagesSection.tsx)) used to render hardcoded package cards. It now fetches them from an API instead, via [usePackagesQuery.ts](../../../../../../../src/features/landing/hooks/usePackagesQuery.ts), so package content (title, price, features, ...) can be managed in a CMS without a frontend deploy. Until this endpoint exists, the section will show the error state ("Pakketten konden niet worden geladen. Probeer het later opnieuw.") in any environment that doesn't have it yet. ## What the frontend needs ### Endpoint ``` GET /api/v1/offerings ``` - **Same domain as the site, relative path.** The frontend calls `fetch('/api/v1/offerings')` with no protocol/host — it relies on the API being reachable under the *same* origin the React app is served from, both on `test.slpsoftware.nl` and on production (`slpsoftware.nl`). Today, both Pis only serve the static React build (see `aidlc-docs/features/react-frontend/operations/deployment/nginx/*.conf.example` — there is no `/api/` location anywhere yet). **Whoever builds the backend also needs to add an nginx `location /api/v1/ { proxy_pass ... }` block (reverse-proxy Pi and/or webserver Pi, wherever the backend process actually runs) for both the test and production nginx configs** — the frontend change alone does not create that route. - **Method**: `GET` only. No mutation endpoints are needed by this frontend (CMS authoring is out of scope for the marketing site itself). - **Auth**: none — this is public marketing content, same as the rest of the page. - **CORS**: not needed if served same-origin as above. If the backend ends up on a different origin instead, CORS must be enabled for `test.slpsoftware.nl` and `slpsoftware.nl` and the frontend's relative URL would need to become absolute (not the current plan, but flagging the coupling). ### Response contract `200 OK`, `Content-Type: application/json`, body is a **JSON array** of package objects, **in display order** (the array order is rendered left-to-right as-is — no client-side sorting): ```json [ { "id": "pakket_01", "slug": "landingspagina", "title": "Landingspagina", "description": "Eén overtuigende pagina die je product of dienst helder neerzet.", "price": "€ 300", "priceNote": "eenmalig, excl. btw", "features": [ "Eén pagina in HTML & CSS", "Ontwerp op maat, geen template", "Responsive op elk scherm", "Snelle laadtijd & SEO-basis" ], "ctaLabel": "Kies landingspagina", "featured": false } ] ``` Any non-2xx response (or a network error) is treated by the frontend as a failure — it shows a generic error message and logs nothing card-specific, so there's no need for a structured error body. ### Field reference | Field | Type | Required | Content type / description | Used for | |---|---|---|---|---| | `id` | `string` | Yes | Stable record identifier (the CMS entry's own key, a GUID in practice). | React list key + `data-testid` on the card, and referenced in tests. Must be unique and stable across edits (don't regenerate on every content change, or it'll be treated as a different card). Not rendered on the page. | | `slug` | `string` | Yes | Readable, URL-safe identifier derived from `title` (lowercase, hyphen-separated), e.g. `landingspagina`. Unique among offerings; regenerate it when `title` changes, keep it otherwise. | Displayed on the card in place of the raw `id`, so visitors see readable text instead of a GUID. | | `title` | `string` | Yes | Short package name (e.g. "Landingspagina"). Plain text, no markup. | Card heading. | | `description` | `string` | Yes | One-sentence pitch, plain text. | Subheading under the title. | | `price` | `string` | Yes | Pre-formatted price display string — e.g. `"€ 300"` or `"Op maat"` for the quote-based package. **Not a number**: it's rendered verbatim, so formatting (currency symbol, spacing, "Op maat") is the CMS editor's responsibility, not computed client-side. | Large price figure on the card. | | `priceNote` | `string` | Yes | Small print under the price, e.g. `"eenmalig, excl. btw"` or `"offerte na intake"`. Plain text. | Caption under the price. | | `features` | `string[]` | Yes | Ordered bullet list of what's included, plain text per item (no markup). Typically 3-4 items. | Rendered as a `→`-bulleted feature list; array order = display order. | | `ctaLabel` | `string` | Yes | Button text, e.g. `"Kies landingspagina"` or `"Vraag offerte aan"`. | Call-to-action button label (the button itself always links to the contact section — the API does not control the link target). | | `featured` | `boolean` | No (defaults to falsy if omitted) | Marks the "most chosen" package. **Exactly one package in the list should have this `true`** — the frontend doesn't enforce or dedupe this, it will just visually highlight and badge every package where it's true. | Adds the "Meest gekozen" badge and a visually distinct card style. | This mirrors the existing `PackageCard` domain entity documented in [domain-entities.md](domain-entities.md) — that table is the authoritative field list; this doc adds the API/content-type framing on top of it. ### Seed / reference content The three packages currently hardcoded in [content.ts](../../../../../../../src/features/landing/data/content.ts) (`pakket_01` Landingspagina, `pakket_02` Website, `pakket_03` Maatwerk) are the real, currently-live copy (per business rule BR-4, content fidelity matters — see [business-rules.md](business-rules.md)). Use that file as the seed data / source of truth when populating the CMS for the first time, so the site's content doesn't change the moment this endpoint goes live. ### Caching behaviour on the frontend (informational) The frontend uses TanStack Query with default settings (query key `['packages']`, no custom `staleTime`) — it refetches on every mount/window-refocus rather than caching indefinitely. No `Cache-Control`/ETag support is required from the API for correctness, but adding reasonable HTTP caching is welcome since this content changes rarely. ## Open items for the API implementer - [ ] Add the `/api/v1/` nginx proxy location for **both** test and production (see "Same domain" note above) — without this, the relative fetch has nothing to reach even once the backend exists. - [ ] Decide/confirm hosting for the backend + CMS (not decided by this hand-off). - [ ] Seed initial content from `content.ts` (see "Seed / reference content" above).