De frontend haalt de pakketten-data nu op via /api/v1/offerings i.p.v. /api/v1/packages.
6.4 KiB
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) used to render hardcoded package cards. It now fetches them from an API instead, via 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 ontest.slpsoftware.nland on production (slpsoftware.nl). Today, both Pis only serve the static React build (seeaidlc-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 nginxlocation /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:
GETonly. 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.nlandslpsoftware.nland 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):
[
{
"id": "pakket_01",
"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 slug-like identifier, e.g. pakket_01. Currently just an ordinal slug, not a business meaning — reuse as the CMS entry's own key/slug. |
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). |
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 — 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 (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). 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).