Haal pakketten-data op via /api/v1/packages i.p.v. hardcoded content
Continuous Integration / config (pull_request) Successful in 3m1s
Continuous Integration / prepare (pull_request) Successful in 1m13s
Continuous Integration / build-production (pull_request) Skipped
Continuous Integration / build (pull_request) Successful in 2m14s
Continuous Integration / test (pull_request) Successful in 1m52s
Continuous Integration / deploy-production (pull_request) Skipped
Deploy / deploy (pull_request) Successful in 43s
Continuous Integration / deploy-test (pull_request) Successful in 44s
Continuous Integration / config (pull_request) Successful in 3m1s
Continuous Integration / prepare (pull_request) Successful in 1m13s
Continuous Integration / build-production (pull_request) Skipped
Continuous Integration / build (pull_request) Successful in 2m14s
Continuous Integration / test (pull_request) Successful in 1m52s
Continuous Integration / deploy-production (pull_request) Skipped
Deploy / deploy (pull_request) Successful in 43s
Continuous Integration / deploy-test (pull_request) Successful in 44s
PackagesSection gebruikte tot nu toe statische data via een placeholder query-hook (FR-5). usePackagesQuery haalt nu echt op bij /api/v1/packages (relatief pad, zelfde domein op test en productie), met een foutmelding in de UI als het request faalt. Voegt ook een hand-off-document toe voor de agent die de CMS/API-kant gaat bouwen: het endpoint-contract, per veld het type/gebruik, en de huidige statische content als seed-data.
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
- **Project Type**: Greenfield
|
||||
- **Start Date**: 2026-07-18T00:37:00Z
|
||||
- **Current Stage**: Complete (post-completion follow-ups tracked in `audit.md` / `operations/production-readiness-checklist.md`)
|
||||
- **Branch**: feature/analytics_uptime_dashboard
|
||||
- **Branch**: feature/packages-api-integration
|
||||
|
||||
## Workspace State
|
||||
- **Existing Code**: No
|
||||
|
||||
@@ -636,3 +636,15 @@
|
||||
**Context**: Post-workflow follow-up (feature already marked Complete); continues the `feature/analytics_uptime_dashboard` branch, resolving a blocker discovered while the user was setting up the systemd auto-start service on Pi Main. Documentation-only change (no code/build impact); the user still needs to apply the fix on Pi Main.
|
||||
|
||||
---
|
||||
|
||||
## Post-Completion Follow-up: Packages Section Wired to Real API + CMS/API Hand-off Doc
|
||||
|
||||
**Timestamp**: 2026-08-01T00:00:00Z
|
||||
|
||||
**User Input**: "Ik wil de data van de pakketten uit een API gaan halen. Kan je dat component aanpassen zodat hij de data uit de api haalt op /api/v1/ van hetzelfde domein (op de test en productie-omgevingen). Bereid de boel voor en maak een hand-off voor de agent die de CMS/API changes moet gaan implementeren zodat die weet wat er nodig is voor de verschillende velden. Wat voor type content erin zit en misschien kort waar die voor gebruikt wordt. Maak een nieuwe feature-branch. Zodra je klaar bent commit en push de changes en maak een PR. Je hebt aan API-token gekregen die je in windows credentials manager hebt gestopt met een script om hem op te halen."
|
||||
|
||||
**AI Response**: "Resolved the previously-placeholder `usePackagesQuery.ts` (FR-5) to a real `fetch('/api/v1/packages')` call — a relative path so it resolves against whichever domain serves the app on both `test.slpsoftware.nl` and production. Added an `isError` branch in `PackagesSection.tsx` showing a Dutch error message when the request fails. Kept the static `packages` array in `content.ts` as reference/seed content and updated `PackagesSection.test.tsx` to mock `fetch` (success + error cases) instead of relying on the old synchronous placeholder. Created `construction/react-frontend-app/functional-design/packages-api-handoff.md` documenting the endpoint contract for whoever implements the CMS/API: method/path/same-origin requirement (including the open nginx-proxy gap, since no `/api/` route exists yet on either Pi), full response JSON contract, a field-by-field table (type, content type, required-ness, what each field is used for in the UI) cross-referenced with the existing `domain-entities.md` `PackageCard` entity, and the seed content to migrate. Created feature branch `feature/packages-api-integration` from `origin/master`. Verified `vitest run` (23/23 passed), `eslint .` (0 errors, 2 pre-existing unrelated warnings), and `tsc -b` (clean) before committing."
|
||||
|
||||
**Context**: Post-workflow follow-up (feature already marked Complete); addresses a new, explicitly scoped request (frontend API wiring + backend hand-off) rather than resuming an open item from `production-readiness-checklist.md`. Given the clear, bounded scope of the request, this was executed directly (code + hand-off doc + verification) rather than re-running the full inception question-file ceremony. Work done on `feature/packages-api-integration`, to be committed/pushed and opened as a PR per the user's request.
|
||||
|
||||
---
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# Hand-off: Packages API (`GET /api/v1/packages`)
|
||||
|
||||
**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/packages
|
||||
```
|
||||
|
||||
- **Same domain as the site, relative path.** The frontend calls `fetch('/api/v1/packages')`
|
||||
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",
|
||||
"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](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).
|
||||
@@ -1,11 +1,13 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { PackagesSection } from '../components/PackagesSection';
|
||||
import { packages } from '../data/content';
|
||||
|
||||
function renderWithQueryClient() {
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PackagesSection />
|
||||
@@ -13,14 +15,28 @@ function renderWithQueryClient() {
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchOnce(response: Response) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(response),
|
||||
);
|
||||
}
|
||||
|
||||
describe('PackagesSection', () => {
|
||||
it('renders one PackageCard per package once loaded', async () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('fetches packages from /api/v1/packages and renders one PackageCard per package', async () => {
|
||||
mockFetchOnce(new Response(JSON.stringify(packages), { status: 200 }));
|
||||
|
||||
renderWithQueryClient();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('packages-grid').children.length).toBe(packages.length);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/v1/packages');
|
||||
for (const pkg of packages) {
|
||||
expect(screen.getByTestId(`package-card-${pkg.id}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(pkg.title)).toBeInTheDocument();
|
||||
@@ -28,10 +44,22 @@ describe('PackagesSection', () => {
|
||||
});
|
||||
|
||||
it('marks the featured package as "Meest gekozen"', async () => {
|
||||
mockFetchOnce(new Response(JSON.stringify(packages), { status: 200 }));
|
||||
|
||||
renderWithQueryClient();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Meest gekozen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error message when the API request fails', async () => {
|
||||
mockFetchOnce(new Response(null, { status: 500, statusText: 'Internal Server Error' }));
|
||||
|
||||
renderWithQueryClient();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('packages-error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { usePackagesQuery } from '../hooks/usePackagesQuery';
|
||||
import { PackageCard } from './PackageCard';
|
||||
|
||||
export function PackagesSection() {
|
||||
const { data: packages, isLoading } = usePackagesQuery();
|
||||
const { data: packages, isLoading, isError } = usePackagesQuery();
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -23,9 +23,13 @@ export function PackagesSection() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-[22px] sm:grid-cols-3" data-testid="packages-grid">
|
||||
{isLoading || !packages
|
||||
? null
|
||||
: packages.map((pkg) => <PackageCard key={pkg.id} pkg={pkg} />)}
|
||||
{isError ? (
|
||||
<p className="col-span-full text-center text-muted" data-testid="packages-error">
|
||||
Pakketten konden niet worden geladen. Probeer het later opnieuw.
|
||||
</p>
|
||||
) : isLoading || !packages ? null : (
|
||||
packages.map((pkg) => <PackageCard key={pkg.id} pkg={pkg} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* Shapes mirror the domain entities defined in
|
||||
* aidlc-docs/features/react-frontend/construction/react-frontend-app/functional-design/domain-entities.md
|
||||
*
|
||||
* This module is the current source of truth for content (requirements FR-1/FR-6).
|
||||
* It is deliberately structured so it can be swapped for TanStack Query data later
|
||||
* (see src/features/landing/hooks/usePackagesQuery.ts) with minimal changes (requirements FR-5).
|
||||
* This module is the source of truth for content that isn't (yet) API-backed
|
||||
* (requirements FR-1/FR-6). `packages` is the exception: it now also doubles as
|
||||
* the reference/seed data for the `/api/v1/packages` contract consumed via
|
||||
* src/features/landing/hooks/usePackagesQuery.ts (requirements FR-5) — see
|
||||
* aidlc-docs/features/react-frontend/construction/react-frontend-app/functional-design/packages-api-handoff.md.
|
||||
*/
|
||||
|
||||
export interface NavLink {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { packages, type PackageCardData } from '../data/content';
|
||||
import type { PackageCardData } from '../data/content';
|
||||
|
||||
// Relative path so it resolves against whichever domain served the app
|
||||
// (test.slpsoftware.nl / slpsoftware.nl) — see aidlc-docs/features/react-frontend/
|
||||
// construction/react-frontend-app/functional-design/packages-api-handoff.md
|
||||
// for the API contract this endpoint must implement.
|
||||
const PACKAGES_ENDPOINT = '/api/v1/packages';
|
||||
|
||||
/**
|
||||
* Placeholder query hook (requirements FR-5). The queryFn currently just resolves the
|
||||
* static package data, wrapped in a Promise so the calling component already consumes
|
||||
* it exactly the way it will once a real API exists — only this queryFn's body will
|
||||
* need to change in a future iteration.
|
||||
*/
|
||||
async function fetchPackages(): Promise<PackageCardData[]> {
|
||||
return Promise.resolve(packages);
|
||||
const response = await fetch(PACKAGES_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch packages: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function usePackagesQuery() {
|
||||
|
||||
Reference in New Issue
Block a user