From 89d5daf7a09ad3bec79e762c6b21ce289b9c107a Mon Sep 17 00:00:00 2001
From: kris1027
Date: Tue, 20 Oct 2026 19:37:53 +0200
Subject: [PATCH 01/27] docs(specs): spec 0017 legal pages and cookie consent
Co-Authored-By: Claude Opus 5.5
---
docs/scope/scope.md | 14 +-
.../0017-legal-pages-cookie-consent/index.md | 224 ++++++++++++++++++
.../rationale.md | 135 +++++++++++
3 files changed, 370 insertions(+), 3 deletions(-)
create mode 100644 docs/specs/0017-legal-pages-cookie-consent/index.md
create mode 100644 docs/specs/0017-legal-pages-cookie-consent/rationale.md
diff --git a/docs/scope/scope.md b/docs/scope/scope.md
index bfac72e..a3664da 100644
--- a/docs/scope/scope.md
+++ b/docs/scope/scope.md
@@ -30,7 +30,7 @@ _These are recommendations to keep your build orderly, not requirements. Skip an
| 14 | Discounts & promo codes | Slice 8 | done |
| 15 | Admin sales dashboard | Slice 9 | done |
| 23 | Native controls in GET forms | Slice 9 follow up | done |
-| 16 | Legal pages & cookie consent | Launch | planned |
+| 16 | Legal pages & cookie consent | Launch | in-progress |
| 17 | SEO for storefront pages | Launch | planned |
| 18 | Analytics & error tracking | Launch | planned |
| 19 | Production deploy | Launch | planned |
@@ -311,10 +311,18 @@ spec [0016](../specs/0016-native-controls-get-forms.md) · code in [src/componen
## Launch readiness
-### 16. Legal pages & cookie consent · needs a decision
+### 16. Legal pages & cookie consent · in-progress
Terms, privacy policy, returns and shipping policy, and a cookie banner that blocks non essential cookies until the customer agrees.
**Done when:** the legal pages are linked from every page footer and from checkout; no analytics or marketing cookies are set before consent; the customer can change their choice later.
-- [ ] Design it (spec): `/architect legal pages & cookie consent`
+spec [0017](../specs/0017-legal-pages-cookie-consent/index.md) · code in [src/lib/legal/](../../src/lib/legal/), [src/lib/consent/](../../src/lib/consent/), [src/features/legal/](../../src/features/legal/)
+- [x] Design it (spec): `/architect legal pages & cookie consent`
+- [x] Build it: `/develop legal pages & cookie consent`
+ - [x] Thin thread: the terms page from Markdown, the footer link, the checkout checkbox recording the version on the order, the admin line and the confirmation email (AC-1, AC-2, AC-4, AC-7 to AC-11, AC-13, AC-14)
+ - [x] The other four pages with live shipping values, the model withdrawal form, print styles and the sign up notice (AC-1 to AC-3, AC-5 to AC-7, AC-12)
+ - [x] Consent core: the cookie, the registry, `ConsentProvider`, `useConsent` and `ConsentGate`, and the cookie table (AC-15, AC-17, AC-22)
+ - [x] Banner, settings dialog, withdrawal, GPC, the style guide demo and the first visit privacy e2e (AC-16, AC-18 to AC-21, AC-23, AC-24)
+- [ ] Verify it: `/check verify legal pages & cookie consent`
+- [ ] Test it: `/test legal pages & cookie consent`
### 17. SEO for storefront pages
Make products findable: titles, descriptions, canonical links, sitemap, product rich results, and social share cards on every public page.
diff --git a/docs/specs/0017-legal-pages-cookie-consent/index.md b/docs/specs/0017-legal-pages-cookie-consent/index.md
new file mode 100644
index 0000000..49161d5
--- /dev/null
+++ b/docs/specs/0017-legal-pages-cookie-consent/index.md
@@ -0,0 +1,224 @@
+# 0017. Legal pages and cookie consent
+
+**Date**: 2026-10-20
+**Status**: In Progress
+
+## Summary
+
+The storefront gets five legal pages (terms, privacy, returns, shipping, cookies) written as Markdown files in the repo, linked from every store footer and from checkout. Checkout gains a required "I accept the terms" checkbox, and each new order records which terms version (the terms file's date) the customer accepted and when, shown to admins and in the order confirmation email. A small first party consent system (a cookie holding the customer's choice, a banner, a settings dialog and a gate component) is built now for feature 18 to plug analytics into; the banner stays hidden until a cookie that needs consent actually exists. One small migration adds two nullable columns to `orders`; nothing else touches the database.
+
+## Requirements
+
+**User stories**:
+- As a customer, I want to read the terms, privacy, returns, shipping and cookie rules before I buy, so I know what I'm agreeing to and how to send something back.
+- As a customer, I want to accept the terms at checkout and get a record of which version applied, so a later dispute is about the text I actually saw.
+- As a customer, I want to refuse optional cookies as easily as I accept them, and change my mind later, so the store tracks me only if I say yes.
+- As an admin, I want each order to show the terms version the customer accepted, so I handle a dispute against the right text.
+- As the developer of feature 18, I want one small API that tells me whether analytics or marketing is allowed, so I never load a tracking script without consent.
+
+**Acceptance criteria** (the contract):
+
+Legal pages
+
+- **AC-1**: `/terms`, `/privacy`, `/returns`, `/shipping` and `/cookies` render inside the store shell. Each shows its title as the only `h1`, a "Last updated " line, and its body from `src/lib/legal/content/.md`. The date is the file's front matter `updated` (a `YYYY-MM-DD` calendar date), formatted as a long date in `STORE_LOCALE` with `timeZone: "UTC"`, so it never shifts by a day. Every page except `/shipping` is fully static; `/shipping` is static plus the cached settings read (AC-5). None reads cookies or other request data.
+- **AC-2**: Each page sets `title` (the front matter `title`), `description` (the front matter `description`) and `alternates.canonical` (`NEXT_PUBLIC_SITE_URL` plus the path). The pages are indexable: no `noindex`, no `X-Robots-Tag`.
+- **AC-3**: Every page that renders `StoreShell` (the store layout, `app/not-found.tsx`, the style guide) lists, in its footer nav, links to Terms of sale, Privacy policy, Returns, Shipping and Cookie policy (in that order), then a "Cookie settings" control (AC-16). The admin panel shows none of them. In print, the store header, the footer and the consent banner are hidden (`print:hidden`), so printing a legal page prints only its text.
+- **AC-4**: The Markdown may use placeholders `{{name}}`. The seller placeholders (`{{seller.name}}`, `{{seller.address}}`, `{{seller.taxId}}`, `{{seller.registration}}`, `{{seller.email}}`, `{{seller.phone}}`) are filled from `brand.legal` in `src/lib/brand/brand.ts`; the shipping ones from AC-5. A placeholder with no known value throws when the module loads, so the build and `pnpm test` fail rather than ship `{{…}}` text. Drafts mark every store specific blank as `[[TO FILL: what goes here]]`; a `brand.legal` field still `null`, or any `[[TO FILL` left in a legal file, makes the loader throw when `VERCEL_ENV` is `production` (other environments render the marker as is, so the drafts stay reviewable).
+- **AC-5**: `/shipping` fills `{{deliveryFee}}` with the flat fee and `{{freeDeliveryRule}}` with "Delivery is free on orders of or more." when a threshold is set, or "The same fee applies to every order." when it is not. Both come from `getShippingSettings()` (the existing cached read tagged `storeSettingsTag`), formatted in `STORE_CURRENCY` and `STORE_LOCALE`. Saving new values in `/admin/settings` changes the page on its next load with no deploy, because that save already expires the tag.
+- **AC-6**: `/returns` covers the 14 day right of withdrawal and how to use it, and ends with a "Model withdrawal form" section holding the EU model form text, the seller's name, address and email filled in from AC-4, and a line saying it can be printed or copied into an email to `{{seller.email}}`.
+- **AC-7**: Each draft covers at least the sections listed under *Content outline* below, in plain language, with a note at the top of each file (an HTML comment, not rendered) saying it is a draft that needs legal review before launch.
+
+Checkout and accounts
+
+- **AC-8**: The `/checkout` form shows an unticked checkbox above Pay, labelled "I accept the [terms of sale] and have read the [privacy policy]." Both links open in a new tab and say so in their accessible name ("terms of sale, opens in a new tab"), so the typed form survives. Under the Pay button, next to the existing Stripe line, one line links Returns and Shipping the same way. Pay with the box unticked shows "Tick the box to accept the terms of sale." under it, moves focus to it, and sends nothing to the server. The Pay button is never disabled because of the box.
+- **AC-9**: `startCheckout` takes two new inputs: `acceptTerms` (must be `true`) and `termsVersion` (a `YYYY-MM-DD` string, the version the page showed). A missing or false `acceptTerms` returns `validation` with the AC-8 message on the `acceptTerms` field. A `termsVersion` that is not the current terms `updated` returns the new error `{ code: "terms_changed", currentVersion }`. Both checks run before any database work, so a refusal creates no order and takes no lock.
+- **AC-10**: On `terms_changed` the form unticks the box, switches its hidden version to `currentVersion`, shows "Our terms of sale changed on . Please read them and tick the box again." with a link to `/terms` (new tab), above the box in an alert, and moves focus to the box. Every other field keeps its value. The next Pay with the box ticked succeeds.
+- **AC-11**: A successful `startCheckout` writes `terms_version` (the accepted version) and `terms_accepted_at` (the server time at Pay) on the new order, in the same transaction that creates it. A database CHECK makes the two columns either both null or both set. Orders created before this feature keep both null. The PII purge (spec 0008) leaves both columns alone.
+- **AC-12**: `/account/sign-up` shows, under the submit button, "By creating an account you accept the [terms of sale] and confirm you have read the [privacy policy]." Both links open in a new tab, as in AC-8. Nothing new is stored.
+- **AC-13**: `/admin/orders/[id]` shows "Terms accepted: version , on " (the version as in AC-1, the time in `STORE_TIMEZONE`), or "Terms accepted: not recorded" when both columns are null.
+- **AC-14**: The order confirmation email (spec 0011) gains, above its footer, the line "Terms of sale version applied to this order." and links to Terms of sale, Returns and Privacy policy (absolute URLs from `NEXT_PUBLIC_SITE_URL`). An order without a recorded version (paid before this feature) omits the version line and keeps the links. The template still never reads env (spec 0011, AC-18): both arrive as props.
+
+Cookie consent
+
+- **AC-15**: The choice lives in one first party cookie `bestore_consent`, written only by the browser (`document.cookie`, not `httpOnly`), `path=/`, `SameSite=Lax`, `Secure` when `location.protocol` is `https:`, `max-age` 12 months. Its value is URL encoded JSON `{"v":"","at":,"analytics":,"marketing":}`. A missing cookie, a value that fails the Zod schema, a `v` different from the current `/cookies` front matter `updated`, or an `at` older than 12 months all count as "no choice yet". The cookie is itself necessary (it records a choice) and is listed as such.
+- **AC-16**: The "Cookie settings" footer control is a link to `/cookies` in the server HTML; after hydration it becomes a button that opens the settings dialog instead. `/cookies` also shows a "Cookie settings" button (after hydration) that opens the same dialog. The dialog lists Necessary (checked and disabled, "Always on"), Analytics and Marketing, each with a one line purpose, and has three buttons: Save choices, Accept all, Reject all. A category with no entry in the registry (AC-17) shows "Not used right now" and its checkbox is disabled and unticked. Saving writes the AC-15 cookie and closes the dialog.
+- **AC-17**: `src/lib/consent/registry.ts` lists every cookie the store sets, with name (or a name pattern), category (`necessary`, `analytics`, `marketing`), purpose, lifetime and who sets it. It ships with the necessary ones only: `bestore_cart`, `bestore_consent`, the Supabase auth cookies (`sb--auth-token` and its numbered chunks) and a line for Stripe's own cookies on its hosted payment page (set on Stripe's domain, not ours). `/cookies` renders the registry as a table after its Markdown body.
+- **AC-18**: The banner shows on storefront pages only, and only when all three hold: the registry has at least one `analytics` or `marketing` entry, there is no valid choice (AC-15), and the browser does not send Global Privacy Control (AC-21). With today's registry it never shows. It mounts after hydration, so the server HTML never contains it and a returning visitor never sees it flash.
+- **AC-19**: The banner is a bar fixed to the bottom of the viewport that does not block the page and takes no focus. It is a `section` with `aria-label="Cookie consent"`, placed in the DOM right after the skip link so keyboard and screen reader users reach it early. It holds one or two sentences naming what optional cookies are for, a link to the cookie policy, and three buttons of the same size and style: Accept all, Reject all, Choose cookies (which opens the AC-16 dialog). While it shows, the page gets a bottom scroll padding equal to its height, so a focused element is never hidden behind it (WCAG 2.4.11). Accept all and Reject all write the cookie and hide the banner.
+- **AC-20**: When a saved choice turns a category from on to off, the browser deletes every cookie the registry lists for that category (matching each registry name or pattern against the names currently in `document.cookie`, deleted on `path=/` for the host only, since the store sets no `Domain` attribute), writes the new choice, then reloads the page, so a script already running stops. Turning a category on needs no reload.
+- **AC-21**: When `navigator.globalPrivacyControl === true` and there is no valid choice, analytics and marketing count as refused, the banner does not show and nothing is written. The customer can still opt in from Cookie settings; that saved choice then wins over the signal.
+- **AC-22**: `src/lib/consent/` exports, for feature 18: `ConsentProvider` (mounted once in the store layout, taking the cookie policy version and the registry as props), `useConsent()` returning `{ ready, analytics, marketing, openSettings }`, and `` which renders its children only when `ready` and the category is granted. Before mount `ready` is false and every gate renders nothing.
+- **AC-23**: On a first visit to the home page, a product page, `/cart` and `/checkout` (no consent cookie set), the browser holds no cookie outside the registry's `necessary` entries (checked with JavaScript on and off), and `localStorage` and `sessionStorage` hold no keys (checked with JavaScript on, the only case where a script could write them).
+- **AC-24**: `/style-guide` (non production only, as today) gets a Consent section that renders the provider, banner and dialog with a demo registry holding one analytics cookie, so the banner, the three buttons, the dialog and the withdrawal reload can be exercised end to end. The demo cookie name starts with `demo_` and is set by the demo only.
+
+**Content outline** (what each draft must cover; `/develop` writes the text, a lawyer reviews it):
+- **Terms of sale** (`/terms`): who the seller is (AC-4 placeholders); scope and how to contact; accounts (optional, how to delete); placing an order and when the contract is formed (on payment); prices (tax inclusive, `STORE_CURRENCY`) and payment by card through Stripe; promo codes (one per order, rules from spec 0014); delivery (points to `/shipping`); right of withdrawal (points to `/returns`); complaints (rękojmia, the statutory warranty for non conforming goods: how to file, 14 day answer); out of court dispute resolution; governing law; how the terms change and which version applies to an order (the one accepted at checkout).
+- **Privacy policy** (`/privacy`): the controller (seller placeholders); what data is collected (checkout email, name, address, phone; account data; cart; order history); purposes and legal bases (contract, legal obligation for tax records, legitimate interest for fraud prevention); processors (Supabase for hosting and auth, Vercel for hosting, Stripe for payments, Resend for email); retention (expired order PII purged after the spec 0008 period, account deletion under spec 0012, paid orders kept for tax law); data subject rights and how to use them; the right to complain to the supervisory authority (UODO in Poland); cookies (points to `/cookies`).
+- **Returns** (`/returns`): the 14 day withdrawal right from delivery, how to tell the seller, sending the goods back within 14 days, who pays return shipping, refunds within 14 days to the original card (spec 0010 refunds), exceptions; the model withdrawal form (AC-6).
+- **Shipping** (`/shipping`): the one country served (`STORE_COUNTRY`), the live fee and free delivery rule (AC-5), carriers and typical delivery time (`[[TO FILL]]`), the shipped email with tracking (spec 0011).
+- **Cookie policy** (`/cookies`): what cookies are, the three categories, how to change your choice (the Cookie settings button), GPC respected, then the registry table (AC-17).
+
+## Decision
+
+**Chosen option**: Markdown in the repo for the pages, a required and recorded terms checkbox at checkout, and a small first party consent system gated on the cookie registry.
+
+The five legal texts are Markdown files bundled into the build with a validated front matter date that doubles as the version; checkout records the accepted terms version on each order; consent is a browser written cookie read through a tiny React API, and the banner appears only once an optional cookie is registered.
+
+**Implementation skills**: `shadcn` (`shadcn-ui/ui`, `.agents/skills/shadcn/`) for the dialog, checkbox and alert · `prisma-cli` (`prisma/skills`, `.agents/skills/prisma-cli/`) for the migration · `supabase-postgres-best-practices` (`supabase/agent-skills`, `.agents/skills/supabase-postgres-best-practices/`) for the CHECK and the migration · `next-cache-components` (Vercel plugin skill) for keeping the legal pages and the store shell static.
+
+## Rationale
+
+Reasoning and options: see [rationale.md](rationale.md).
+
+## Feature design
+
+**Module layout** (shared code in `src/lib/`, so checkout, account, orders and emails never import another feature's internals):
+
+| Path | Owns |
+|---|---|
+| `src/lib/legal/content/{terms,privacy,returns,shipping,cookies}.md` | The texts, each with front matter `title`, `description`, `updated`. |
+| `src/lib/legal/front-matter.ts` | Pure. Splits `---` front matter from the body and parses the three keys with Zod (no YAML dependency; only `key: value` lines). |
+| `src/lib/legal/placeholders.ts` | Pure. `fillPlaceholders(body, values)`, throwing on an unknown `{{name}}`; `findFillMarkers(text)`. |
+| `src/lib/legal/documents.ts` | `import "server-only"`. Imports the five files as raw strings, parses them once at module load, and exports `legalDocuments` (slug, path, title, description, `updated`, body) and `currentTermsVersion`. Applies the AC-4 production guard. |
+| `src/lib/legal/paths.ts` | Pure, no server import. `legalPaths` (`/terms`, …) and `legalLinkLabels`, so client components and the email builder can link without the texts. |
+| `src/features/legal/` | `components/legal-page.tsx` (title, date, body), `components/legal-markdown.tsx` (the renderer), `components/cookie-table.tsx`, `components/footer-links.ts`, `shipping-values.ts` (AC-5). |
+| `app/(store)/{terms,privacy,returns,shipping,cookies}/page.tsx` | Thin routes: `generateMetadata`, then ``. |
+| `src/lib/consent/consent.ts` | Pure. The cookie schema, `parseConsent(raw, policyVersion, nowMs)`, `serializeConsent`, `cookiesToDelete(previous, next, registry)`, `shouldShowBanner(...)`. |
+| `src/lib/consent/registry.ts` | The AC-17 registry (data only). |
+| `src/lib/consent/consent-provider.tsx`, `consent-gate.tsx`, `consent-banner.tsx`, `consent-dialog.tsx`, `cookie-settings-control.tsx` | `"use client"`. The React side of AC-15 to AC-22. |
+
+**Raw Markdown import**: the `.md` files are bundled as strings at build time (a Turbopack rule giving `src/lib/legal/content/*.md` the `raw` module type in `next.config.ts`, and the same transform for Vitest in its config), never read with `fs` at runtime, so a revalidated `/shipping` render and `startCheckout` never depend on file tracing. Add a `declare module "*.md"` typing beside them. Confirm the exact rule shape in `node_modules/next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/turbopack.md` (module types table, `raw`).
+
+**Renderer**: `legal-markdown.tsx` is its own renderer, not the product description one (that one drops relative links and demotes `h1`). Allowed: `h2`, `h3`, `p`, `ul`, `ol`, `li`, `strong`, `em`, `a`, `blockquote`, `hr`. Raw HTML skipped. Links allowed: relative paths starting with `/`, `#` anchors, `https:` and `mailto:`; anything else becomes plain text. A Markdown `#` heading is an error in the draft (the title is the page `h1`), caught by a unit test over the five files.
+
+**Build details** (settled in the cross check, so the build does not invent them):
+
+1. **Shell and provider.** `StoreShell`'s `footerLinks` defaults to the legal links from `src/features/legal/components/footer-links.ts` instead of `[]` (the style guide keeps passing its demo links), so `app/not-found.tsx` gets them too. The `CookieSettingsControl` works with no `ConsentProvider` above it: it then stays a plain link to `/cookies`. `ConsentProvider` and the banner are mounted only in `app/(store)/layout.tsx`. The admin panel never renders `StoreShell`, which is what keeps it free of legal links and the banner. `store-shell.test.ts` gets a mock for the new client control.
+2. **Dates.** One new pure helper, `formatCalendarDate(date: Date, locale: string)` in `src/lib/dates.ts` (`dateStyle: "long"`, `timeZone: "UTC"`), used for every terms or policy version: the legal pages, the `terms_changed` alert, the admin line, the email. A `YYYY-MM-DD` string becomes `new Date(\`${ymd}T00:00:00Z\`)`, and Prisma returns `@db.Date` at UTC midnight, so it is never formatted through `formatDate(..., STORE_TIMEZONE)`. The acceptance time on the admin page uses the existing `formatDateTime` in `STORE_TIMEZONE`. Front matter `updated` is checked as a real calendar date with `parseCalendarDay` (rejects 2026-02-31).
+3. **Email.** `EmailFormat` (`src/lib/email/props.ts`) gains `siteUrl`, filled by `emailFormat()` in `src/lib/email/content.ts` from `NEXT_PUBLIC_SITE_URL`. The confirmation select in `content.ts` adds `termsVersion`, and `ConfirmationSource` gains `termsVersion: Date | null`. The pure `confirmationProps` builds `termsVersionLabel` (with `formatCalendarDate`) and `legalLinks` (`siteUrl` + `legalPaths`). `fixtures.ts`, `PreviewProps` and the `render.test.ts` snapshots cover both a version and none.
+4. **Checkout schema and action.** `acceptTerms` joins `checkoutSchema(country)` as `z.literal(true, { error: "Tick the box to accept the terms of sale." })`; the form default is `false`. `checkoutFields` and `checkoutFieldErrors` learn `acceptTerms`, so the server field error and focus work like any other field; `CheckoutPrefill` and `emptyPrefill` do not change. `termsVersion` is not a user field: `startCheckout(input)` reads it from the same input object but parses it with its own schema (`YYYY-MM-DD`, a real date), and the form holds it in component state from the page prop, never in an editable field. The version comparison is the first thing `run` does after parsing, before `getCustomer()` and any database call. `StartCheckoutError` gains the `terms_changed` arm. The form maps it to the AC-10 alert through its existing `FormNotice`, and `logCheckoutRefused` logs `code` only. The checkbox is wired with `aria-invalid` and `aria-describedby` to its error, like the other fields.
+5. **Order write.** `run` takes `acceptedAt = new Date()` once, after the version check. `createPendingOrder` gains `terms: { version: Date; acceptedAt: Date }` and writes both columns in its create. A replaced or restarted checkout creates a new order with its own fresh values and copies nothing from the old one. The CHECK lives only in the SQL migration (Prisma can't express it). A db test proves it with a raw insert that sets only one column, and the purge db test (`purge-expired`) asserts both columns survive a purge.
+6. **Raw Markdown.** `next.config.ts` gains a `turbopack.rules` entry giving only `src/lib/legal/content/*.md` the `raw` module type (scoped with a path condition, Turbopack only). `vitest.config.mts` gains a small inline plugin that turns those same files into `export default `. A `declare module "*.md"` (default export `string`) sits in `src/lib/legal/content/markdown.d.ts`. Check that `next dev` reloads on a text edit.
+7. **Text normalisation and the guard.** The loader strips a leading BOM and turns CRLF into LF before parsing. The draft note is an HTML comment at the top of the body, and the loader removes it before `findFillMarkers` and rendering, so the note may mention the marker without tripping the guard. A seller placeholder whose `brand.legal` field is `null` renders `[[TO FILL: seller.address]]` (the field name), which the production guard then catches. The guard test runs `next build` with `VERCEL_ENV=production` against a fixture text holding a marker, and expects it to fail. At runtime the same throw also protects `startCheckout`, which imports `documents.ts`.
+8. **Renderer.** `legal-markdown.tsx` is built on `react-markdown`, like the product renderer, with no plugins beyond its own element filter. External `https:` links get `rel="noopener noreferrer"`. Headings get no ids (no anchor links in v1).
+9. **Hydration and the banner.** The link to button switch, the provider's first read and the banner use `useSyncExternalStore` with a server snapshot of "not ready", the pattern the checkout form already uses (`noSubscription`). While the banner shows, a `ResizeObserver` on it sets `scroll-padding-bottom` on `` to its height, and the padding is removed when it hides. Syncing a choice made in another tab is out of scope: the other tab picks it up on its next load.
+10. **Registry.** `CART_COOKIE` moves to a pure module (`src/lib/cart/cookie-name.ts`) so the client registry imports it instead of repeating the string; `cookie.ts` imports it from there. A unit test greps `src/` and `app/` for cookie names the code sets (`cookies().set`, `document.cookie =`) and asserts each is in the registry. A policy version is a date, so the cookie policy can change at most once a day without a new version, which is accepted.
+11. **Named files.** The admin line reads both columns through `getAdminOrder` in `src/features/orders/admin-queries.ts` and renders in `components/admin-order-detail.tsx`. The sign up notice goes in `src/features/account/components/sign-up-form.tsx`, with an e2e assertion on `/account/sign-up`. The checkout page (`app/(store)/checkout/page.tsx`) imports `currentTermsVersion` from `documents.ts` and passes it down to `checkout-form.tsx` as a prop.
+12. **E2E GPC.** GPC is emulated with `page.addInitScript` defining `navigator.globalPrivacyControl` as `true`. No request header is used, because the store reads the signal only in the browser.
+13. **Static rendering.** The legal pages need no `dynamic` or `revalidate` export: under `cacheComponents`, a page that reads no request data prerenders. `/shipping`'s cached settings read needs the database at build time, exactly like the catalog pages today.
+
+**Data model sketch**:
+
+| Table | Column | Type | Null | Notes |
+|---|---|---|---|---|
+| `orders` | `terms_version` | `date` | yes | The accepted terms `updated` date. |
+| `orders` | `terms_accepted_at` | `timestamptz(3)` | yes | Server time at Pay. |
+
+- CHECK `orders_terms_acceptance_check`: `(terms_version IS NULL) = (terms_accepted_at IS NULL)`.
+- No index (never filtered on). No new table, so no new RLS statement; `orders` already has RLS on.
+- Prisma: `termsVersion DateTime? @map("terms_version") @db.Date`, `termsAcceptedAt DateTime? @map("terms_accepted_at") @db.Timestamptz(3)`.
+- The consent choice is not in the database (cookie only, AC-15).
+
+**State transitions**: none on orders (the columns are written once at creation, never changed). Consent in the browser: `no choice` → `decided` (Accept all, Reject all, Save) → `decided` (Save again) ; `decided` → `no choice` when the cookie policy `updated` changes or 12 months pass. GPC is a default, not a state.
+
+**API surface**:
+
+| Surface | Kind | Key inputs | Key outputs | Auth | Key errors |
+|---|---|---|---|---|---|
+| `/terms`, `/privacy`, `/returns`, `/shipping`, `/cookies` | GET pages | none | static HTML | public | none (a broken file fails the build) |
+| `startCheckout` (changed) | server action | existing inputs + `acceptTerms: true` (req), `termsVersion: string YYYY-MM-DD` (req) | `{ url }` as today | as today | `validation` (field `acceptTerms`), new `terms_changed { currentVersion }` |
+| `ConsentProvider` / `useConsent` / `ConsentGate` | client API | `policyVersion`, `registry` | `{ ready, analytics, marketing, openSettings }` | browser only | invalid cookie reads as no choice |
+| Order confirmation email (changed) | template props | `termsVersionLabel: string \| null`, `legalLinks: { terms, returns, privacy }` | email | n/a | none |
+
+**Value sourcing**:
+
+| Action | Value produced / displayed | Source |
+|---|---|---|
+| Legal page | title, description, "Last updated" | the file's front matter (`legalDocuments`) |
+| Legal page | canonical URL | `NEXT_PUBLIC_SITE_URL` + `legalPaths[slug]` |
+| Legal page | seller name, address, tax id, registration, email, phone | `brand.legal` in `src/lib/brand/brand.ts` (new block, all `string \| null`) |
+| `/shipping` | delivery fee, free delivery rule | `getShippingSettings()` + `formatMoney` with `STORE_CURRENCY`, `STORE_LOCALE` |
+| Any formatted calendar date | long date text | `Intl.DateTimeFormat(STORE_LOCALE, { dateStyle: "long", timeZone: "UTC" })` on `YYYY-MM-DDT00:00:00Z` |
+| Checkout page | `termsVersion` the form sends | `currentTermsVersion`, read by the server page and passed to the form as a prop |
+| `startCheckout` | current version to compare | `currentTermsVersion` |
+| `startCheckout` | `terms_version`, `terms_accepted_at` | the validated input; `new Date()` at Pay |
+| `terms_changed` alert | the new date | `currentVersion` in the error, formatted as above |
+| Admin order page | version and time | `orders.terms_version`, `orders.terms_accepted_at` in `STORE_TIMEZONE` |
+| Confirmation email | version line, links | `orders.terms_version` formatted by the sender; `NEXT_PUBLIC_SITE_URL` + `legalPaths` |
+| Consent cookie | `v` | `/cookies` front matter `updated`, passed from the store layout to `ConsentProvider` |
+| Consent cookie | `at` | `Date.now()` in the browser at the choice |
+| Banner visibility | optional cookies exist | `registry` entries with category `analytics` or `marketing` |
+| Banner visibility | GPC | `navigator.globalPrivacyControl` |
+| Withdrawal | cookie names to delete | `cookiesToDelete(previous, next, registry)` |
+
+**Key invariants**:
+- No order is created after this feature ships without `terms_version` and `terms_accepted_at`; the version stored equals the version the customer's form showed.
+- The two columns are both null or both set (DB CHECK).
+- No `analytics` or `marketing` cookie, script or storage exists in the browser unless `useConsent()` reports that category granted. Every optional cookie is in the registry; adding one means bumping the `/cookies` `updated` date, which asks everyone again.
+- Refusing is never harder than accepting: Accept all and Reject all are always side by side with the same style, in the banner and the dialog.
+- Rendering a page never sets a cookie (spec 0001); only the browser writes `bestore_consent`.
+- The legal pages and the store shell stay static: neither the pages nor the provider read cookies on the server.
+- Legal texts in production contain no `[[TO FILL` marker and no unfilled `{{…}}`.
+
+**Security model**:
+- Compliance scope: GDPR (consent for optional cookies must be prior, informed, granular and as easy to withdraw as to give; ability to show what was offered), the ePrivacy rule on storing anything on a device (only strictly necessary storage without consent), EU and Polish consumer law (pre contract information, the 14 day withdrawal right with the model form, terms available free before the contract in a printable form).
+- The legal pages are public and read only. `bestore_consent` is not trusted by the server for anything; it only controls what the browser loads.
+- Terms acceptance is enforced on the server in `startCheckout`, never only in the form.
+- The admin order page stays behind `requireAdmin()` as today; the new line adds no new data path.
+- Audit trail: the order columns are the record of acceptance; consent proof rests on the cookie plus the banner code and registry in git history (the engineer chose no server log).
+- Logs: `checkout.refused` carries `code: "terms_changed"` or the `validation` field names, never the email.
+
+**Configuration required**: no new environment variables. New values in `brand.legal` (code, filled before launch). `next.config.ts` and the Vitest config gain the raw Markdown rule.
+
+**Critical test scenarios**:
+- Happy path: a guest ticks the box and pays; the order row has `terms_version` = the terms date and `terms_accepted_at` set; the admin order page and the confirmation email show the version, verifies **AC-8**, **AC-11**, **AC-13**, **AC-14**
+- Failure case: `startCheckout` called with an older `termsVersion` returns `terms_changed`, creates no order (db test counts orders before and after), and the form shows the alert with the box unticked, verifies **AC-9**, **AC-10**
+- Failure case: Pay unticked shows the field error and no request reaches the server; a direct call with `acceptTerms: false` returns `validation`, verifies **AC-8**, **AC-9**
+- Consent: on the style guide demo, Reject all writes `analytics:false`, no `demo_` cookie appears, reload hides the banner; Accept all then turning analytics off in the dialog deletes the `demo_` cookie and reloads, verifies **AC-15**, **AC-19**, **AC-20**, **AC-24**
+- Consent: with GPC emulated, the demo banner does not show and the gate stays closed until the customer opts in, verifies **AC-21**
+- Privacy: a first visit to home, product, cart and checkout, with JavaScript on and off, leaves only necessary cookies and empty web storage, verifies **AC-23**
+- Static: a build with a `{{unknown}}` placeholder fails; `VERCEL_ENV=production` with a `[[TO FILL` marker throws, verifies **AC-4**
+- Live values: changing the fee in `/admin/settings` changes `/shipping` on the next load, verifies **AC-5**
+- Permission: an anonymous visitor reaches every legal page; the admin panel shows no legal footer and no banner, verifies **AC-3**, **AC-18**
+
+## Build plan
+
+Tracer Bullet: the first slice runs one legal page through every layer (content file, loader, page, footer, checkout, database, admin, email) before the other pages and the consent system are added.
+
+1. **Thin thread, terms end to end.** Raw Markdown rule in `next.config.ts` and the Vitest config; `front-matter.ts`, `placeholders.ts`, `paths.ts`, `documents.ts` with unit tests; `brand.legal` block; `legal-markdown.tsx` and `legal-page.tsx`; `app/(store)/terms/page.tsx` with metadata; the terms draft; footer link through `StoreShell`'s `footerLinks`, satisfies **AC-1**, **AC-2**, **AC-4**, **AC-7**
+2. Migration for the two `orders` columns and the CHECK; `startCheckout` takes `acceptTerms` and `termsVersion`, checks them before any database work, writes both columns in the order transaction; checkout form checkbox, field error, links and the Returns and Shipping line; the checkout page passes `currentTermsVersion`; db test plus e2e for tick and pay, satisfies **AC-8**, **AC-9**, **AC-11**
+3. Admin order page "Terms accepted" line; confirmation email version line and links (props built in the sender, `PreviewProps` updated, both with and without a version), satisfies **AC-13**, **AC-14**
+4. `terms_changed` handling in the form (alert, untick, new version, focus) with an e2e that calls the action with an old version, satisfies **AC-9**, **AC-10**
+5. The other four pages and drafts: privacy, returns (with the model withdrawal form), shipping (with `shipping-values.ts` and the live settings), cookies; all five footer links in order; print styles; sign up notice; a unit test that every draft has front matter, no `#` heading and only known placeholders, satisfies **AC-1**, **AC-2**, **AC-3**, **AC-5**, **AC-6**, **AC-7**, **AC-12**
+6. Consent core: `consent.ts` (schema, parse, serialize, `cookiesToDelete`, `shouldShowBanner`) with unit tests; `CART_COOKIE` moved to a pure module; `registry.ts` with the test that every cookie the code sets is listed; `ConsentProvider`, `useConsent`, `ConsentGate` mounted in the store layout; the cookie table on `/cookies`, satisfies **AC-15**, **AC-17**, **AC-22**
+7. Settings dialog and the Cookie settings control (link before hydration, button after) in the footer and on `/cookies`; banner with the visibility rule, DOM position, scroll padding; withdrawal delete and reload; GPC, satisfies **AC-16**, **AC-18**, **AC-19**, **AC-20**, **AC-21**
+8. Style guide Consent demo with the demo registry and its e2e (banner buttons, dialog, withdrawal reload, GPC through an init script defining `navigator.globalPrivacyControl`), and the first visit cookie and storage e2e with scripts on and blocked (`blockAppScripts`), plus axe checks on a legal page, the banner and the dialog, satisfies **AC-19**, **AC-20**, **AC-21**, **AC-23**, **AC-24**
+
+## Consequences
+
+**Positive**:
+- Every order from now on carries proof of which terms applied, visible where disputes are handled.
+- Feature 18 gets a ready consent API and only has to register its cookies and wrap its script in `ConsentGate`; the banner then appears by itself.
+- The pages stay static and cheap, and every text change is a reviewed commit with history.
+- No new dependency, no third party script, no new table.
+
+**Negative / tradeoffs**:
+- Editing a legal text needs a developer and a deploy; admins cannot fix a typo themselves.
+- Old versions of the terms are only in git history, not on the site; answering "show me the version from March" means checking out a commit.
+- Consent proof is the cookie plus git history, not a server log; if a regulator asks for per visitor records, there are none.
+- Checkout gains one more required step, which costs a little conversion, and a new refusal (`terms_changed`) a customer can hit when the terms change during their visit.
+- The drafts are not legal advice; until a lawyer reviews them, launching on them is a risk the production guard only partly covers (it catches blanks, not wrong law).
+
+**Neutral**:
+- One migration with two nullable columns and a CHECK; old orders read "not recorded".
+- `next.config.ts` gains a Turbopack rule; Vitest gains the matching transform.
+- A new `src/lib/legal/` and `src/lib/consent/` area, and a `src/features/legal/` feature, each worth a nested `AGENTS.md` at `/sync` time.
+
+## Follow-up
+
+- [ ] Language: the store sells to consumers in Poland, where consumer contract terms generally have to be offered in Polish. The drafts follow the store's current language (`STORE_LOCALE`, English today). Settle the final language with the lawyer before launch; a Polish version may mean switching `STORE_LOCALE` or adding translated files.
+- [ ] Legal review of all five drafts and the checkout and sign up wording before launch (feature 19 should not ship while any `[[TO FILL` remains, which the AC-4 guard enforces).
+- [ ] Fill `brand.legal` (and `brand.contactEmail`) with the real seller details.
+- [ ] Feature 18: register each analytics cookie in `src/lib/consent/registry.ts`, bump the `/cookies` `updated` date, and wrap scripts in `ConsentGate`. If it picks a cookieless analytics tool, decide whether it needs consent at all; server side events (the paid webhook) carry no cookie and are outside this gate.
+- [ ] Feature 17: add the five legal pages to the sitemap.
+- [ ] `/sync`: add nested `AGENTS.md` files for `src/lib/legal/`, `src/lib/consent/` and `src/features/legal/`, and a root rule that every new cookie or web storage key goes in the consent registry.
diff --git a/docs/specs/0017-legal-pages-cookie-consent/rationale.md b/docs/specs/0017-legal-pages-cookie-consent/rationale.md
new file mode 100644
index 0000000..a442fdc
--- /dev/null
+++ b/docs/specs/0017-legal-pages-cookie-consent/rationale.md
@@ -0,0 +1,135 @@
+# 0017. Legal pages and cookie consent: rationale
+
+The decision record behind [index.md](index.md). `/develop` builds from `index.md` and skips this file.
+
+## Context
+
+> ⚠️ Premise note: the store has no analytics or marketing cookies today; they arrive with feature 18. A banner asking for consent to cookies that don't exist collects meaningless consent and teaches customers to click past it. The right framing is to build and test the consent system now, and let the banner appear only once an optional cookie is registered. The engineer agreed.
+
+> ⚠️ Premise note: the store sells only to consumers in Poland (`storeCountries = ["PL"]`), and Polish law generally expects consumer terms in Polish, while the store's language is set by `STORE_LOCALE` (English today). Drafting in English is fine for review, but the launch text's language is a legal call, recorded as a Follow-up rather than decided here.
+
+BeStore is a single brand shop selling to consumers in one EU country (Poland), with guest and account checkout and card payment through Stripe's hosted page. Before launch it needs the texts a consumer shop must publish: terms of sale (the contract), a privacy policy (GDPR information duties), the withdrawal right with the EU model form, delivery terms, and a cookie policy. Polish law also requires the terms to be available free, before the contract, in a form the customer can save and print. The compliance scope is GDPR, the ePrivacy rule on device storage, and EU and Polish consumer law.
+
+The terms matter beyond being published. When a customer disputes an order, the store has to know which version of the terms applied. Today nothing links an order to any text. The terms will change over time (new promo rules, carriers, prices), so "the current terms" is not an answer for an order placed last year.
+
+On cookies, the app today sets only strictly necessary ones: the signed cart cookie and the Supabase auth cookies; no code touches `localStorage` or `sessionStorage`. Feature 18 (analytics and error tracking) will want to load tracking, and the scope says that must respect consent. EU regulators check that consent is prior (nothing loads before a yes), granular (per purpose), informed, and that refusing is as easy as accepting and withdrawing as easy as giving. Feature 18 should not have to design that from scratch.
+
+The stack shapes the solution: Cache Components keep the store shell static, so anything reading request cookies on the server pushes pages behind Suspense. Features must not import each other's internals, yet checkout, accounts, admin orders and the confirmation email all need to know the terms version and the legal paths. `react-markdown` is already installed for product descriptions, and `StoreShell` already has an empty `footerLinks` slot reserved for this feature.
+
+## Options considered
+
+### Where the legal text lives
+
+#### Option 1: Markdown files in the repo (chosen)
+
+One `.md` file per page with a small front matter block (`title`, `description`, `updated`), bundled into the build and rendered with `react-markdown`.
+
+**Pros**:
+- Every change is a reviewed commit with author, diff and date; git history is the archive of old versions.
+- Pages stay fully static with no new table, editor or cache tag.
+- Reuses an installed dependency.
+
+**Cons**:
+- Only a developer can change the text, and every change needs a deploy.
+- Old versions are not browsable on the site.
+
+#### Option 2: Admin editable pages in the database
+
+A `legal_pages` table and a Markdown editor in `/admin`, with a cache tag per page.
+
+**Pros**:
+- Non developers can edit without a deploy.
+- Versions could be stored as rows and shown on the site.
+
+**Cons**:
+- A migration, an editor page, actions, cache tags, tests, and a versioning scheme to get the "which version applied" guarantee right.
+- Legal text changes stop being reviewed like code; a stray edit goes live instantly.
+
+#### Option 3: Text written in TSX pages
+
+The text lives directly in React components.
+
+**Pros**:
+- No parser and no raw import rule.
+
+**Cons**:
+- Legal prose mixed with markup is hard to edit and to review, and a lawyer cannot work on the file.
+
+### How terms acceptance is captured
+
+#### Option 1: Required checkbox, recorded on the order (chosen)
+
+An unticked checkbox above Pay, enforced by `startCheckout`, saving the terms version and time on the order.
+
+**Pros**:
+- Standard practice for Polish shops, and proof of exactly which text applied to each order.
+- Enforced on the server, so no client trick skips it.
+
+**Cons**:
+- One more step at checkout, plus a migration and a new refusal when the terms change mid visit.
+
+#### Option 2: Required checkbox, not recorded
+
+**Pros**:
+- No migration.
+
+**Cons**:
+- No proof of which version applied to a disputed order.
+
+#### Option 3: Notice text only
+
+"By paying you accept the terms" under Pay.
+
+**Pros**:
+- No friction, no new code path.
+
+**Cons**:
+- Weaker under Polish practice, and still no record.
+
+#### Option 4: Stripe Checkout's terms checkbox
+
+`consent_collection.terms_of_service` on Stripe's hosted page.
+
+**Pros**:
+- Stripe shows and enforces it.
+
+**Cons**:
+- Acceptance happens after our order exists and lives only in Stripe; the order row and admin page know nothing.
+
+### How consent is managed
+
+#### Option 1: A small first party consent system (chosen)
+
+A browser written cookie with the choice, a registry of every cookie, a React provider with `useConsent` and `ConsentGate`, a shadcn styled banner and dialog.
+
+**Pros**:
+- No third party script; the static shell, the visual style and the WCAG baseline stay under our control.
+- The registry drives the cookie policy table and the banner's visibility, so they cannot drift apart.
+
+**Cons**:
+- We own the edge cases (cookie deletion on withdrawal, GPC, re asking on policy change) and their tests.
+- No automatic cookie scanning: a developer must register each new cookie.
+
+#### Option 2: An open source consent library (vanilla-cookieconsent)
+
+**Pros**:
+- Banner, categories, script blocking and translations are done and widely used.
+
+**Cons**:
+- Its own markup and styles fight the shadcn look; one more dependency to keep current; React integration is a wrapper around an imperative API.
+
+#### Option 3: A hosted consent platform (Cookiebot, CookieYes)
+
+**Pros**:
+- Cookie scanning, a server side consent log, Google Consent Mode support.
+
+**Cons**:
+- A third party script that must load before everything else on every page, a monthly cost, and a banner style we only partly control.
+
+## Rationale
+
+The texts belong in the repo because the hard requirement here is knowing which text applied to which order, and git already gives an exact, reviewed, dated history for free. With the version being the file's `updated` date, the order only needs to store that date: no versions table, no editor, no new cache tag. An admin editor would solve a problem this shop does not have yet (frequent non developer edits) and would make the important guarantee harder, because any edit would go live unreviewed. The cost, a deploy per text change, is acceptable for documents that change a few times a year. Bundling the files as raw strings (rather than reading them with `fs`) keeps `startCheckout` and the revalidated `/shipping` page independent of file tracing on Vercel.
+
+The recorded checkbox is chosen because the record is the point. Polish shops conventionally ask for explicit acceptance of the terms, and storing the version on the order turns "the customer agreed" into "the customer agreed to the 2026-10-20 text". Comparing the version the form showed with the current one closes the one gap left: a customer who opened checkout before a deploy is never bound to text they had no chance to open. The check runs before any lock or write, so it costs nothing on the hot path. Stripe's checkbox was rejected because the acceptance would land after our order exists and outside our data.
+
+A first party consent system fits because the store has no optional cookies yet, so a hosted platform would be paying for a scanner with nothing to scan, and both third party options add a script that sits in front of the static shell the project worked to keep. The registry is the piece that makes the small system safe: it is the single list behind the cookie policy table, the withdrawal deletion and the banner's visibility, so the store cannot show a banner for nothing or forget to delete a cookie it knows about. Mounting the banner after hydration keeps the server HTML free of request data; without JavaScript no optional script can run, so there is nothing to consent to. Keeping proof to the cookie (no server log) was the engineer's call; it is enough for a small first party shop, and the follow up is cheap if feature 18 brings a tool that needs a log.
From 9cc580f06efb9c7a66344154c544495d1226bd66 Mon Sep 17 00:00:00 2001
From: kris1027
Date: Tue, 20 Oct 2026 19:37:56 +0200
Subject: [PATCH 02/27] feat(legal): legal pages from Markdown with live
shipping values
Co-Authored-By: Claude Opus 5.5
---
app/(store)/privacy/page.tsx | 7 +
app/(store)/returns/page.tsx | 7 +
app/(store)/shipping/page.tsx | 20 +++
app/(store)/terms/page.tsx | 7 +
next.config.test.ts | 13 ++
next.config.ts | 14 ++
scripts/raw-text-loader.cjs | 6 +
scripts/raw-text-loader.test.ts | 17 ++
.../legal/components/cookie-table.tsx | 60 ++++++++
src/features/legal/components/footer-links.ts | 9 ++
.../legal/components/legal-markdown.test.ts | 76 +++++++++
.../legal/components/legal-markdown.tsx | 83 ++++++++++
src/features/legal/components/legal-page.tsx | 55 +++++++
src/features/legal/shipping-values.test.ts | 31 ++++
src/features/legal/shipping-values.ts | 18 +++
src/lib/brand/brand.ts | 24 +++
src/lib/dates.test.ts | 14 ++
src/lib/dates.ts | 11 ++
src/lib/legal/content.test.ts | 95 ++++++++++++
src/lib/legal/content/cookies.md | 36 +++++
src/lib/legal/content/markdown.d.ts | 6 +
src/lib/legal/content/privacy.md | 79 ++++++++++
src/lib/legal/content/returns.md | 73 +++++++++
src/lib/legal/content/shipping.md | 34 ++++
src/lib/legal/content/terms.md | 94 ++++++++++++
src/lib/legal/documents.ts | 31 ++++
src/lib/legal/front-matter.test.ts | 42 +++++
src/lib/legal/front-matter.ts | 43 ++++++
src/lib/legal/load.test.ts | 145 ++++++++++++++++++
src/lib/legal/load.ts | 79 ++++++++++
src/lib/legal/paths.ts | 23 +++
src/lib/legal/placeholders.test.ts | 48 ++++++
src/lib/legal/placeholders.ts | 33 ++++
tests/e2e/admin/settings.spec.ts | 15 ++
vitest.config.mts | 17 +-
35 files changed, 1364 insertions(+), 1 deletion(-)
create mode 100644 app/(store)/privacy/page.tsx
create mode 100644 app/(store)/returns/page.tsx
create mode 100644 app/(store)/shipping/page.tsx
create mode 100644 app/(store)/terms/page.tsx
create mode 100644 scripts/raw-text-loader.cjs
create mode 100644 scripts/raw-text-loader.test.ts
create mode 100644 src/features/legal/components/cookie-table.tsx
create mode 100644 src/features/legal/components/footer-links.ts
create mode 100644 src/features/legal/components/legal-markdown.test.ts
create mode 100644 src/features/legal/components/legal-markdown.tsx
create mode 100644 src/features/legal/components/legal-page.tsx
create mode 100644 src/features/legal/shipping-values.test.ts
create mode 100644 src/features/legal/shipping-values.ts
create mode 100644 src/lib/legal/content.test.ts
create mode 100644 src/lib/legal/content/cookies.md
create mode 100644 src/lib/legal/content/markdown.d.ts
create mode 100644 src/lib/legal/content/privacy.md
create mode 100644 src/lib/legal/content/returns.md
create mode 100644 src/lib/legal/content/shipping.md
create mode 100644 src/lib/legal/content/terms.md
create mode 100644 src/lib/legal/documents.ts
create mode 100644 src/lib/legal/front-matter.test.ts
create mode 100644 src/lib/legal/front-matter.ts
create mode 100644 src/lib/legal/load.test.ts
create mode 100644 src/lib/legal/load.ts
create mode 100644 src/lib/legal/paths.ts
create mode 100644 src/lib/legal/placeholders.test.ts
create mode 100644 src/lib/legal/placeholders.ts
diff --git a/app/(store)/privacy/page.tsx b/app/(store)/privacy/page.tsx
new file mode 100644
index 0000000..018e26d
--- /dev/null
+++ b/app/(store)/privacy/page.tsx
@@ -0,0 +1,7 @@
+import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page";
+
+export const metadata = legalMetadata("privacy");
+
+export default function PrivacyPage() {
+ return ;
+}
diff --git a/app/(store)/returns/page.tsx b/app/(store)/returns/page.tsx
new file mode 100644
index 0000000..4ecdd0d
--- /dev/null
+++ b/app/(store)/returns/page.tsx
@@ -0,0 +1,7 @@
+import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page";
+
+export const metadata = legalMetadata("returns");
+
+export default function ReturnsPage() {
+ return ;
+}
diff --git a/app/(store)/shipping/page.tsx b/app/(store)/shipping/page.tsx
new file mode 100644
index 0000000..ccb5ad4
--- /dev/null
+++ b/app/(store)/shipping/page.tsx
@@ -0,0 +1,20 @@
+import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page";
+import { shippingValues } from "@/features/legal/shipping-values";
+import { env } from "@/lib/env";
+import { getShippingSettings } from "@/lib/shipping/settings";
+
+export const metadata = legalMetadata("shipping");
+
+// spec 0017, AC-5: static plus the cached settings read, which an admin save expires.
+export default async function ShippingPage() {
+ const settings = await getShippingSettings();
+ return (
+
+ );
+}
diff --git a/app/(store)/terms/page.tsx b/app/(store)/terms/page.tsx
new file mode 100644
index 0000000..4d84df3
--- /dev/null
+++ b/app/(store)/terms/page.tsx
@@ -0,0 +1,7 @@
+import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page";
+
+export const metadata = legalMetadata("terms");
+
+export default function TermsPage() {
+ return ;
+}
diff --git a/next.config.test.ts b/next.config.test.ts
index 0ccf8a5..940a099 100644
--- a/next.config.test.ts
+++ b/next.config.test.ts
@@ -9,6 +9,19 @@ describe("next config", () => {
});
});
+// spec 0017: only the legal texts are bundled as strings, so no other Markdown changes meaning.
+describe("legal text bundling", () => {
+ const rule = nextConfig.turbopack?.rules?.["*.md"];
+ const path = (rule as { condition?: { path?: RegExp } } | undefined)?.condition?.path;
+
+ it("runs the raw text loader on the legal content folder only", () => {
+ expect(rule).toMatchObject({ loaders: ["./scripts/raw-text-loader.cjs"], as: "*.js" });
+ expect(path?.test("src/lib/legal/content/terms.md")).toBe(true);
+ expect(path?.test("docs/specs/0017-legal-pages-cookie-consent/index.md")).toBe(false);
+ expect(path?.test("src/lib/legal/content/nested/terms.md")).toBe(false);
+ });
+});
+
describe("confirmation page headers", () => {
// spec 0006, AC-11 and spec 0012, AC-2: both URLs carry a key.
it.each(["/checkout/complete", "/account/confirm"])(
diff --git a/next.config.ts b/next.config.ts
index 1d8c987..5bb2827 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
import { productImagesConfig } from "./src/lib/image-config";
+// A RegExp matches anywhere in the project relative path.
+const legalContent = /^src\/lib\/legal\/content\/[^/]+\.md$/;
+
const nextConfig: NextConfig = {
// Static catalog shell, request data behind Suspense (spec 0005, Caching model).
cacheComponents: true,
@@ -9,6 +12,17 @@ const nextConfig: NextConfig = {
// prints admin passwords and TOTP codes to the terminal (spec 0004, AC-13).
logging: { serverFunctions: false },
images: productImagesConfig(process.env.NEXT_PUBLIC_SUPABASE_URL),
+ // The legal texts are bundled as strings, never read with fs at runtime (spec 0017). Scoped to
+ // their folder, so no other Markdown file changes meaning.
+ turbopack: {
+ rules: {
+ "*.md": {
+ condition: { path: legalContent },
+ loaders: ["./scripts/raw-text-loader.cjs"],
+ as: "*.js",
+ },
+ },
+ },
// The confirmation URL carries a Stripe session id, the key to the order it shows; it must
// never reach another site through a Referer header or a search index (spec 0006, AC-11).
async headers() {
diff --git a/scripts/raw-text-loader.cjs b/scripts/raw-text-loader.cjs
new file mode 100644
index 0000000..5e04a89
--- /dev/null
+++ b/scripts/raw-text-loader.cjs
@@ -0,0 +1,6 @@
+// A Turbopack loader (next.config.ts): a legal text becomes a module whose default export is the
+// file's text, so the texts are bundled at build time and never read with fs (spec 0017).
+// Turbopack's own `raw` module type exports nothing a JavaScript import can read.
+module.exports = function rawTextLoader(source) {
+ return `export default ${JSON.stringify(source)};`;
+};
diff --git a/scripts/raw-text-loader.test.ts b/scripts/raw-text-loader.test.ts
new file mode 100644
index 0000000..bc5c175
--- /dev/null
+++ b/scripts/raw-text-loader.test.ts
@@ -0,0 +1,17 @@
+import { createRequire } from "node:module";
+
+import { describe, expect, it } from "vitest";
+
+const require = createRequire(import.meta.url);
+const loader = require("./raw-text-loader.cjs") as (source: string) => string;
+
+// spec 0017: what Turbopack bundles for a legal text.
+describe("raw text loader", () => {
+ it("exports the file text as the default string, quotes and newlines intact", () => {
+ const source = '---\ntitle: "A"\n---\n\nIt\'s `{{seller.name}}` \\ done\n';
+ const code = loader(source);
+
+ expect(code).toBe(`export default ${JSON.stringify(source)};`);
+ expect(JSON.parse(code.slice("export default ".length, -1))).toBe(source);
+ });
+});
diff --git a/src/features/legal/components/cookie-table.tsx b/src/features/legal/components/cookie-table.tsx
new file mode 100644
index 0000000..dbc4847
--- /dev/null
+++ b/src/features/legal/components/cookie-table.tsx
@@ -0,0 +1,60 @@
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { type ConsentCategory, type CookieRegistry } from "@/lib/consent/consent";
+
+const categoryLabels: Readonly> = {
+ necessary: "Necessary",
+ analytics: "Analytics",
+ marketing: "Marketing",
+};
+
+// spec 0017, AC-17: the registry as the /cookies table. Cells wrap; on a narrow screen the box
+// scrolls sideways and can take keyboard focus, so a keyboard user can scroll it too.
+export function CookieTable({ registry }: { readonly registry: CookieRegistry }) {
+ return (
+
+
+ Every cookie the store sets
+
+
+
+ Cookie
+ Category
+ Purpose
+ Lifetime
+ Set by
+
+
+
+ {registry.map((entry) => (
+
+
+ {entry.name}
+
+
+ {categoryLabels[entry.category]}
+
+ {entry.purpose}
+ {entry.lifetime}
+ {entry.setBy}
+
+ ))}
+
+
+ );
+}
diff --git a/src/features/legal/components/footer-links.ts b/src/features/legal/components/footer-links.ts
new file mode 100644
index 0000000..10c73d3
--- /dev/null
+++ b/src/features/legal/components/footer-links.ts
@@ -0,0 +1,9 @@
+import type { NavItem } from "@/components/layout/nav";
+import { legalLinkLabels, legalPaths, legalSlugs } from "@/lib/legal/paths";
+
+// spec 0017, AC-3: the store footer's links, in the order the spec fixes. StoreShell uses them
+// by default, so app/not-found.tsx gets them too.
+export const legalFooterLinks: readonly NavItem[] = legalSlugs.map((slug) => ({
+ href: legalPaths[slug],
+ label: legalLinkLabels[slug],
+}));
diff --git a/src/features/legal/components/legal-markdown.test.ts b/src/features/legal/components/legal-markdown.test.ts
new file mode 100644
index 0000000..61e9029
--- /dev/null
+++ b/src/features/legal/components/legal-markdown.test.ts
@@ -0,0 +1,76 @@
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("next/link", () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) =>
+ createElement("a", { href, ...props }, children),
+}));
+
+const { LegalMarkdown, legalUrl } = await import("./legal-markdown");
+
+function render(markdown: string) {
+ return renderToStaticMarkup(createElement(LegalMarkdown, { markdown }));
+}
+
+// spec 0017, Renderer.
+describe("legalUrl", () => {
+ it.each(["/returns", "#model-form", "https://uodo.gov.pl", "mailto:hi@example.com"])(
+ "keeps %s",
+ (url) => {
+ expect(legalUrl(url)).toBe(url);
+ },
+ );
+
+ it.each([
+ "javascript:alert(1)",
+ "http://example.com",
+ "//evil.example",
+ "data:text/html,x",
+ "returns",
+ ])("drops %s", (url) => {
+ expect(legalUrl(url)).toBe("");
+ });
+});
+
+describe("LegalMarkdown", () => {
+ it("renders the allowed elements", () => {
+ const html = render(
+ "## Who we are\n\n### Contact\n\nPlain **bold** _em_\n\n- a\n\n1. b\n\n> quoted\n\n---",
+ );
+ for (const tag of [
+ "
| {
+ const html = render(
+ "[returns](/returns) and [UODO](https://uodo.gov.pl) and [x](javascript:x)",
+ );
+ expect(html).toContain('href="/returns"');
+ expect(html).toMatch(
+ /href="https:\/\/uodo.gov.pl"[^>]*rel="noopener noreferrer"|rel="noopener noreferrer"[^>]*href="https:\/\/uodo.gov.pl"/,
+ );
+ expect(html).not.toContain("javascript:");
+ expect(html).toContain("x");
+ });
+});
diff --git a/src/features/legal/components/legal-markdown.tsx b/src/features/legal/components/legal-markdown.tsx
new file mode 100644
index 0000000..818e7dc
--- /dev/null
+++ b/src/features/legal/components/legal-markdown.tsx
@@ -0,0 +1,83 @@
+import Link from "next/link";
+import Markdown, { type Components } from "react-markdown";
+
+// spec 0017, Renderer: its own renderer, not the product description one (that one drops
+// relative links and demotes headings). The page title is the only h1, so a `#` heading in a
+// draft is an error (content.test.ts), never rendered. Raw HTML is skipped (the draft note is an
+// HTML comment), and anything outside the list below is unwrapped to its text.
+
+export const allowedElements = [
+ "h2",
+ "h3",
+ "p",
+ "ul",
+ "ol",
+ "li",
+ "strong",
+ "em",
+ "a",
+ "blockquote",
+ "hr",
+];
+
+// Relative paths, in page anchors, https and mailto; anything else becomes plain text.
+export function legalUrl(url: string): string {
+ const trimmed = url.trim();
+ if (/^\/(?!\/)/.test(trimmed) || trimmed.startsWith("#")) return trimmed;
+ return /^(https:|mailto:)/i.test(trimmed) ? trimmed : "";
+}
+
+const linkClass = "text-foreground underline underline-offset-4 hover:text-foreground/80";
+
+const components: Components = {
+ h2: ({ children }) => (
+
{children}
+ ),
+ h3: ({ children }) =>
{children}
,
+ p: ({ children }) =>
{children}
,
+ ul: ({ children }) =>
{children}
,
+ ol: ({ children }) => {children},
+ li: ({ children }) =>
{children}
,
+ strong: ({ children }) => {children},
+ blockquote: ({ children }) => (
+
+ );
+}
diff --git a/src/features/legal/components/legal-page.tsx b/src/features/legal/components/legal-page.tsx
new file mode 100644
index 0000000..5baebc5
--- /dev/null
+++ b/src/features/legal/components/legal-page.tsx
@@ -0,0 +1,55 @@
+import "server-only";
+
+import type { Metadata } from "next";
+
+import { storeContainer } from "@/components/layout/container";
+import { calendarDate, formatCalendarDate } from "@/lib/dates";
+import { env } from "@/lib/env";
+import { legalDocuments } from "@/lib/legal/documents";
+import type { LegalSlug } from "@/lib/legal/paths";
+import { fillPlaceholders } from "@/lib/legal/placeholders";
+import { cn } from "@/lib/utils";
+
+import { LegalMarkdown } from "./legal-markdown";
+
+// spec 0017, AC-2: the front matter title and description, a canonical URL, and indexable.
+export function legalMetadata(slug: LegalSlug): Metadata {
+ const document = legalDocuments[slug];
+ return {
+ title: document.title,
+ description: document.description,
+ alternates: { canonical: new URL(document.path, env.NEXT_PUBLIC_SITE_URL).toString() },
+ };
+}
+
+// spec 0017, AC-1: the title as the only h1, the version date, then the body. It reads no
+// request data, so every page prerenders; `/shipping` passes its live values (AC-5).
+export function LegalPage({
+ slug,
+ values = {},
+ children,
+}: {
+ readonly slug: LegalSlug;
+ readonly values?: Readonly>;
+ // What follows the text: the cookie table and settings button on /cookies.
+ readonly children?: React.ReactNode;
+}) {
+ const document = legalDocuments[slug];
+ return (
+
+
+
{document.title}
+
+ Last updated{" "}
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/features/legal/shipping-values.test.ts b/src/features/legal/shipping-values.test.ts
new file mode 100644
index 0000000..41b6ca3
--- /dev/null
+++ b/src/features/legal/shipping-values.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { shippingValues } from "./shipping-values";
+
+// spec 0017, AC-5.
+describe("shippingValues", () => {
+ const format = { currency: "EUR", locale: "en" };
+
+ it("names the fee and the threshold when one is set", () => {
+ expect(
+ shippingValues({ flatShippingCents: 999, freeShippingThresholdCents: 5000 }, format),
+ ).toEqual({
+ deliveryFee: "€9.99",
+ freeDeliveryRule: "Delivery is free on orders of €50.00 or more.",
+ });
+ });
+
+ it("says the same fee applies to every order without a threshold", () => {
+ expect(
+ shippingValues({ flatShippingCents: 450, freeShippingThresholdCents: null }, format),
+ ).toEqual({ deliveryFee: "€4.50", freeDeliveryRule: "The same fee applies to every order." });
+ });
+
+ it("formats in the store currency and locale", () => {
+ const values = shippingValues(
+ { flatShippingCents: 1299, freeShippingThresholdCents: null },
+ { currency: "PLN", locale: "pl-PL" },
+ );
+ expect(values.deliveryFee.replace(/\s/g, " ")).toBe("12,99 zł");
+ });
+});
diff --git a/src/features/legal/shipping-values.ts b/src/features/legal/shipping-values.ts
new file mode 100644
index 0000000..2e28b2b
--- /dev/null
+++ b/src/features/legal/shipping-values.ts
@@ -0,0 +1,18 @@
+import { type MoneyFormat, formatMoney } from "@/lib/money";
+import type { ShippingSettings } from "@/lib/shipping/rule";
+
+// Pure. spec 0017, AC-5: the live delivery fee and free delivery rule `/shipping` fills in, from
+// the same settings the cart and checkout show.
+export function shippingValues(
+ settings: ShippingSettings,
+ format: MoneyFormat,
+): { readonly deliveryFee: string; readonly freeDeliveryRule: string } {
+ const threshold = settings.freeShippingThresholdCents;
+ return {
+ deliveryFee: formatMoney(settings.flatShippingCents, format),
+ freeDeliveryRule:
+ threshold === null
+ ? "The same fee applies to every order."
+ : `Delivery is free on orders of ${formatMoney(threshold, format)} or more.`,
+ };
+}
diff --git a/src/lib/brand/brand.ts b/src/lib/brand/brand.ts
index 26accd1..745e0cc 100644
--- a/src/lib/brand/brand.ts
+++ b/src/lib/brand/brand.ts
@@ -1,10 +1,34 @@
// The store's identity, read everywhere the name or contact shows. A rebrand edits this file,
// fonts.ts beside it and the token block in app/globals.css (see docs/design.md).
// No next/* imports: React Email templates (feature 11) import it too.
+export type SellerDetails = {
+ // The legal name of the business, as registered.
+ readonly name: string | null;
+ // One line, as it should appear in the terms and the withdrawal form.
+ readonly address: string | null;
+ // NIP in Poland.
+ readonly taxId: string | null;
+ // The business register entry (KRS or CEIDG) and number.
+ readonly registration: string | null;
+ readonly email: string | null;
+ readonly phone: string | null;
+};
+
export const brand: {
readonly name: string;
readonly contactEmail: string | null;
+ // The seller named in the legal pages (spec 0017, AC-4). A null field renders a
+ // `[[TO FILL]]` marker, which a production build refuses.
+ readonly legal: SellerDetails;
} = {
name: "BeStore",
contactEmail: null,
+ legal: {
+ name: null,
+ address: null,
+ taxId: null,
+ registration: null,
+ email: null,
+ phone: null,
+ },
};
diff --git a/src/lib/dates.test.ts b/src/lib/dates.test.ts
index 5bbda9a..9f1cae8 100644
--- a/src/lib/dates.test.ts
+++ b/src/lib/dates.test.ts
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
addDays,
+ calendarDate,
+ formatCalendarDate,
formatDate,
formatDateTime,
parseCalendarDay,
@@ -27,6 +29,18 @@ describe("formatDate", () => {
});
});
+// spec 0017, AC-1: a version date is the same day in every zone.
+describe("formatCalendarDate", () => {
+ it("formats a YYYY-MM-DD as a long date with no day shift", () => {
+ expect(formatCalendarDate(calendarDate("2026-10-20"), "en")).toBe("October 20, 2026");
+ expect(formatCalendarDate(calendarDate("2026-01-01"), "pl-PL")).toBe("1 stycznia 2026");
+ });
+
+ it("reads a Postgres date at UTC midnight as that day", () => {
+ expect(formatCalendarDate(new Date("2026-03-31T00:00:00.000Z"), "en")).toBe("March 31, 2026");
+ });
+});
+
describe("formatDateTime", () => {
it("shows the store's local time", () => {
const moment = new Date("2026-03-31T23:30:00Z");
diff --git a/src/lib/dates.ts b/src/lib/dates.ts
index b9f4425..f907334 100644
--- a/src/lib/dates.ts
+++ b/src/lib/dates.ts
@@ -18,6 +18,17 @@ export function formatDateTime(date: Date, { locale, timeZone }: DateFormat): st
}).format(date);
}
+// A calendar date with no time of day (a legal text's version, spec 0017, AC-1): read at UTC
+// midnight and formatted in UTC, so it never shifts by a day whatever the server or store zone.
+export function formatCalendarDate(date: Date, locale: string): string {
+ return new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(date);
+}
+
+// A YYYY-MM-DD text as the UTC midnight formatCalendarDate and a Postgres `date` column expect.
+export function calendarDate(ymd: string): Date {
+ return new Date(`${ymd}T00:00:00Z`);
+}
+
// A calendar day typed as YYYY-MM-DD, only when it is a real day (no 2026-02-30).
export function parseCalendarDay(text: string): { y: number; m: number; d: number } | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
diff --git a/src/lib/legal/content.test.ts b/src/lib/legal/content.test.ts
new file mode 100644
index 0000000..549dcce
--- /dev/null
+++ b/src/lib/legal/content.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from "vitest";
+
+import cookies from "./content/cookies.md";
+import privacy from "./content/privacy.md";
+import returns from "./content/returns.md";
+import shipping from "./content/shipping.md";
+import terms from "./content/terms.md";
+import { parseFrontMatter } from "./front-matter";
+import { loadLegalDocuments, sellerValues, shippingPlaceholders } from "./load";
+import { legalSlugs } from "./paths";
+import { placeholderNames } from "./placeholders";
+
+// spec 0017, AC-4 and AC-7: the five drafts as they ship.
+const raw = { terms, privacy, returns, shipping, cookies };
+
+const seller = {
+ name: null,
+ address: null,
+ taxId: null,
+ registration: null,
+ email: null,
+ phone: null,
+};
+const known = [
+ ...Object.keys(sellerValues(seller)),
+ "store.country",
+ "store.currency",
+ ...shippingPlaceholders,
+];
+
+describe.each(legalSlugs)("the %s draft", (slug) => {
+ const text = raw[slug];
+
+ it("has front matter with a title, a description and a real date", () => {
+ expect(() => parseFrontMatter(text, slug)).not.toThrow();
+ });
+
+ it("opens its body with the draft note, as an HTML comment", () => {
+ expect(parseFrontMatter(text, slug).body.trimStart()).toMatch(/^/);
+ });
+
+ // The page title is the only h1.
+ it("has no level 1 heading", () => {
+ expect(parseFrontMatter(text, slug).body).not.toMatch(/^# /m);
+ });
+
+ it("uses only known placeholders", () => {
+ for (const name of placeholderNames(text)) expect(known).toContain(name);
+ });
+});
+
+describe("the drafts together", () => {
+ it("load outside production with every seller field blank", () => {
+ expect(() =>
+ loadLegalDocuments(raw, {
+ seller,
+ store: { country: "Poland", currency: "EUR" },
+ production: false,
+ }),
+ ).not.toThrow();
+ });
+
+ // Feature 19 cannot ship while a blank is left (spec 0017, Follow-up).
+ it("refuse to load in production while blanks remain", () => {
+ expect(() =>
+ loadLegalDocuments(raw, {
+ seller,
+ store: { country: "Poland", currency: "EUR" },
+ production: true,
+ }),
+ ).toThrow("still has blanks to fill before launch");
+ });
+
+ it("put the shipping placeholders on /shipping only", () => {
+ for (const slug of legalSlugs) {
+ const names = placeholderNames(raw[slug]);
+ const live = names.filter((name) =>
+ (shippingPlaceholders as readonly string[]).includes(name),
+ );
+ expect(live.length > 0).toBe(slug === "shipping");
+ }
+ });
+
+ // AC-6: the model withdrawal form, with the seller's name, address and email.
+ it("end /returns with the model withdrawal form", () => {
+ const body = parseFrontMatter(raw.returns, "returns").body;
+ const form = body.slice(body.indexOf("## Model withdrawal form"));
+ expect(body.indexOf("## Model withdrawal form")).toBeGreaterThan(0);
+ expect(form).not.toMatch(/\n## /);
+ for (const name of ["{{seller.name}}", "{{seller.address}}", "{{seller.email}}"]) {
+ expect(form).toContain(name);
+ }
+ expect(form).toMatch(/print it, or copy it into an email to \{\{seller\.email\}\}/);
+ });
+});
diff --git a/src/lib/legal/content/cookies.md b/src/lib/legal/content/cookies.md
new file mode 100644
index 0000000..28b3558
--- /dev/null
+++ b/src/lib/legal/content/cookies.md
@@ -0,0 +1,36 @@
+---
+title: Cookie policy
+description: Which cookies our store uses, what they are for, and how to change your choice.
+updated: 2026-10-20
+---
+
+
+
+## What cookies are
+
+Cookies are small text files a website stores in your browser. Some are needed for the site to work. Others are optional, and we only set them if you agree.
+
+## The three kinds we may use
+
+- **Necessary:** they make the store work, for example keeping your cart and your sign in. They are always on, because the store cannot work without them.
+- **Analytics:** they would help us understand how the store is used, so we can improve it.
+- **Marketing:** they would help show you relevant offers on other sites.
+
+Right now we use necessary cookies only. If we ever add analytics or marketing cookies, we will ask you first and list them below.
+
+## Changing your choice
+
+You can change your choice at any time with the Cookie settings button on this page or in the footer of every page. Saying no to optional cookies is as easy as saying yes, and the store works the same either way.
+
+## Global Privacy Control
+
+If your browser sends the Global Privacy Control signal, we treat it as a no to analytics and marketing cookies, unless you later turn them on yourself in Cookie settings.
+
+## The cookies we use
+
+Every cookie the store sets is listed in the table below.
diff --git a/src/lib/legal/content/markdown.d.ts b/src/lib/legal/content/markdown.d.ts
new file mode 100644
index 0000000..4a302ff
--- /dev/null
+++ b/src/lib/legal/content/markdown.d.ts
@@ -0,0 +1,6 @@
+// The legal texts are imported as raw strings (the `raw` Turbopack rule in next.config.ts and
+// the matching plugin in vitest.config.mts).
+declare module "*.md" {
+ const text: string;
+ export default text;
+}
diff --git a/src/lib/legal/content/privacy.md b/src/lib/legal/content/privacy.md
new file mode 100644
index 0000000..faab73a
--- /dev/null
+++ b/src/lib/legal/content/privacy.md
@@ -0,0 +1,79 @@
+---
+title: Privacy policy
+description: What personal data our store collects, why, who processes it, how long we keep it, and your rights.
+updated: 2026-10-20
+---
+
+
+
+This policy explains what personal data we collect when you use our online store, why we collect it, and what rights you have. It applies to the storefront, checkout and customer accounts.
+
+## 1. Who is responsible for your data
+
+The controller of your personal data is {{seller.name}}, {{seller.address}}, tax identification number {{seller.taxId}}.
+
+For anything about your data, write to {{seller.email}} or call {{seller.phone}}.
+
+## 2. What data we collect
+
+- **At checkout:** your email address, full name, delivery address and, if you give it, your phone number. We need these to deliver your order and to send you emails about it.
+- **Your account (optional):** your email address, name, password (stored only as a secure hash by our authentication provider), saved delivery addresses and your order history.
+- **Your cart:** the products you add. A cart is linked to your browser by a cookie and is kept for 30 days after its last change.
+- **Orders:** what you bought, prices, the delivery fee, any promo code, the version of the terms of sale you accepted and when, and the payment status. Card details are entered on Stripe's page; we never see or store your full card number.
+
+## 3. Why we use it, and on what legal basis
+
+- **To take, deliver and support your order** (contract, Article 6(1)(b) GDPR): checkout data, order emails, complaints and returns.
+- **To run your account** (contract, Article 6(1)(b) GDPR), if you create one.
+- **To keep accounting and tax records** (legal obligation, Article 6(1)(c) GDPR): paid orders and invoices.
+- **To prevent fraud and keep the store secure** (our legitimate interest, Article 6(1)(f) GDPR): for example, checking payments and protecting accounts.
+
+We do not sell your data and we do not use it for automated decisions with legal effects.
+
+## 4. Who processes your data for us
+
+We use these service providers. Each processes data only on our instructions and under a data processing agreement.
+
+- **Supabase:** database hosting and customer account sign in.
+- **Vercel:** hosting of the website.
+- **Stripe:** card payments. Stripe is also an independent controller for some payment data, under its own privacy policy.
+- **Resend:** sending order and account emails.
+- **Delivery carriers:** [[TO FILL: the carriers that receive delivery addresses]], to deliver your parcel.
+
+Some of these providers may process data outside the European Economic Area. Where they do, the transfer is protected by the European Commission's standard contractual clauses or an adequacy decision.
+
+## 5. How long we keep it
+
+- **Checkouts that were never paid:** the email, name, address and phone are deleted 30 days after the checkout expired. What remains is anonymous.
+- **Your account:** until you delete it. You can delete it at any time in your account settings; your profile and saved addresses are removed.
+- **Paid orders:** for as long as tax and accounting law requires, usually 5 years from the end of the year the tax was due, and for as long as a claim about the order could still be made.
+- **Your cart:** 30 days after its last change.
+
+## 6. Your rights
+
+You have the right to:
+
+- access your data and get a copy of it,
+- have inaccurate data corrected,
+- have your data deleted, where we no longer need it or the law allows,
+- restrict how we use it,
+- receive your data in a portable format,
+- object to processing based on our legitimate interest.
+
+To use any of these rights, write to {{seller.email}}. We answer within one month.
+
+## 7. Your right to complain
+
+If you think we handle your data unlawfully, you can complain to the supervisory authority. In Poland this is the President of the Personal Data Protection Office (Prezes Urzędu Ochrony Danych Osobowych, UODO), ul. Stawki 2, 00-193 Warszawa, [https://uodo.gov.pl](https://uodo.gov.pl).
+
+## 8. Cookies
+
+We use only the cookies the store needs to work, such as the one that keeps your cart. The [cookie policy](/cookies) lists every cookie and lets you change your choice.
+
+## 9. Changes to this policy
+
+When this policy changes, we update the date at the top of this page.
diff --git a/src/lib/legal/content/returns.md b/src/lib/legal/content/returns.md
new file mode 100644
index 0000000..f499fa6
--- /dev/null
+++ b/src/lib/legal/content/returns.md
@@ -0,0 +1,73 @@
+---
+title: Returns
+description: Your 14 day right to withdraw from a purchase, how to send goods back, refunds, and the model withdrawal form.
+updated: 2026-10-20
+---
+
+
+
+If you are a consumer, you can withdraw from a purchase within 14 days, without giving any reason. This page explains how.
+
+## 1. How long you have
+
+You have 14 days from the day you, or someone you name (other than the carrier), receive the goods. If one order arrives in several parcels, the 14 days run from the day the last parcel arrives.
+
+## 2. How to tell us
+
+Before the 14 days end, send us a clear statement that you are withdrawing. You can:
+
+- email {{seller.email}}, or
+- send a letter to {{seller.name}}, {{seller.address}}.
+
+You can use the model withdrawal form at the bottom of this page, but you do not have to. Please include your order number. Sending your statement before the deadline is enough. We confirm that we received it by email.
+
+## 3. Sending the goods back
+
+Send the goods back to {{seller.address}} within 14 days of telling us you withdraw. Sending them before the 14 days end is enough.
+
+You pay the cost of sending the goods back, unless we agreed otherwise. Please pack the goods safely. You are responsible for any loss in value caused by handling the goods beyond what is needed to check what they are, how they work and their features, as you could in a shop.
+
+## 4. Your refund
+
+We refund everything you paid for the goods, including the cheapest standard delivery fee, within 14 days of receiving your withdrawal statement. We refund to the card you paid with; you pay no fee for the refund. Your bank usually shows the refund within 5 to 10 business days.
+
+We may wait to refund until we receive the goods back, or until you show us proof that you sent them, whichever comes first.
+
+## 5. When the right does not apply
+
+The right of withdrawal does not apply, for example, to:
+
+- goods made to your own specification or clearly personalised,
+- goods that spoil quickly,
+- sealed goods that cannot be returned for health or hygiene reasons once unsealed after delivery,
+- [[TO FILL: any other exceptions that apply to the products this store sells, or delete this line]].
+
+## 6. Faulty goods
+
+If the goods are faulty or not as described, you also have rights under the statutory warranty, which are separate from this right of withdrawal. See section 9 of the [terms of sale](/terms).
+
+## Model withdrawal form
+
+Fill in and send this form only if you want to withdraw from the contract. You can print it, or copy it into an email to {{seller.email}}.
+
+> To: {{seller.name}}, {{seller.address}}, {{seller.email}}
+>
+> I/We (\*) hereby give notice that I/We (\*) withdraw from my/our (\*) contract of sale of the following goods (\*):
+>
+> Ordered on (\*) / received on (\*):
+>
+> Order number:
+>
+> Name of consumer(s):
+>
+> Address of consumer(s):
+>
+> Signature of consumer(s) (only if this form is sent on paper):
+>
+> Date:
+>
+> (\*) Delete as appropriate.
diff --git a/src/lib/legal/content/shipping.md b/src/lib/legal/content/shipping.md
new file mode 100644
index 0000000..9f424fb
--- /dev/null
+++ b/src/lib/legal/content/shipping.md
@@ -0,0 +1,34 @@
+---
+title: Shipping
+description: Where we deliver, what delivery costs, which carriers we use and how long it takes.
+updated: 2026-10-20
+---
+
+
+
+## Where we deliver
+
+We deliver to addresses in {{store.country}} only. Checkout asks for an address there.
+
+## What delivery costs
+
+Standard delivery costs {{deliveryFee}}. {{freeDeliveryRule}}
+
+The delivery fee is shown in your cart and at checkout before you pay, and is included in the total.
+
+## Carriers and delivery times
+
+We ship with [[TO FILL: the carriers you use]]. We usually hand your order to the carrier within [[TO FILL: dispatch time, for example 2 business days]] of payment, and delivery then takes about [[TO FILL: typical delivery time, for example 1 to 3 business days]].
+
+## Tracking your parcel
+
+When your order ships, we send you an email with the carrier and, when the carrier gives one, the tracking number. If you have an account, you can also see the status in your order history.
+
+## If something goes wrong
+
+If your parcel is late, lost or arrives damaged, write to {{seller.email}} with your order number and we will sort it out with the carrier. For returns, see the [returns page](/returns).
diff --git a/src/lib/legal/content/terms.md b/src/lib/legal/content/terms.md
new file mode 100644
index 0000000..9e04ef5
--- /dev/null
+++ b/src/lib/legal/content/terms.md
@@ -0,0 +1,94 @@
+---
+title: Terms of sale
+description: The rules for buying from our online store: orders, prices, payment, delivery, returns and complaints.
+updated: 2026-10-20
+---
+
+
+
+These terms of sale apply to every order placed in this online store. Please read them before you buy. You can print this page or save it for your records.
+
+## 1. Who we are
+
+The store is run by {{seller.name}}, {{seller.address}}.
+
+- Tax identification number: {{seller.taxId}}
+- Business register: {{seller.registration}}
+- Email: {{seller.email}}
+- Phone: {{seller.phone}}
+
+In these terms, "we" and "us" mean the seller named above, and "you" means the customer.
+
+## 2. Scope and how to contact us
+
+These terms cover buying goods through this store. They apply to consumers and, where the law allows, to business customers. If you have a question about an order or these terms, write to {{seller.email}}. We answer within [[TO FILL: usual answer time, for example 2 business days]].
+
+## 3. Accounts
+
+You can buy as a guest or create an account. An account is optional. It lets you see your order history, save delivery addresses and check out faster. Creating an account is free.
+
+You can delete your account at any time from your account settings. Deleting the account removes your profile and saved addresses. Orders you already paid for stay in our records for as long as tax law requires (see the [privacy policy](/privacy)).
+
+## 4. Placing an order
+
+1. Add products to your cart.
+2. Go to checkout and enter your email address and delivery address.
+3. Read these terms and the [privacy policy](/privacy), and tick the box to accept the terms.
+4. Press Pay. You are taken to the secure payment page of our payment provider, Stripe, where you enter your card details.
+
+The contract of sale is formed when your payment succeeds. Until then, you have not placed a binding order and we have not accepted one. Right after a successful payment we send you an order confirmation email with the order details, the version of these terms that applies, and links to these terms, the returns policy and the privacy policy.
+
+## 5. Prices and payment
+
+Prices are shown in {{store.currency}} and include VAT. The delivery fee is shown in the cart and at checkout before you pay, and is part of the total you pay. The price that applies is the one shown at checkout when you press Pay.
+
+You pay by card on Stripe's hosted payment page. We never see or store your full card details. If the payment fails or you leave the payment page, no order is placed and your cart stays saved.
+
+## 6. Promo codes
+
+From time to time we may offer promo codes.
+
+- You can use one code per order.
+- A code may have conditions, such as a minimum order value, a start and end date, a limit on how many times it can be used, or a limit of one use per customer. The conditions are shown when the code is offered.
+- A code is checked again when you press Pay. If it no longer applies, you are told before anything is charged, and you can pay without it.
+- A code cannot be exchanged for cash.
+
+## 7. Delivery
+
+We deliver to {{store.country}} only. Delivery fees, carriers and typical delivery times are described on the [shipping page](/shipping).
+
+## 8. Your right to withdraw
+
+If you are a consumer, you can withdraw from the contract within 14 days of receiving the goods, without giving a reason. How to do this, the model withdrawal form and how refunds work are described on the [returns page](/returns).
+
+## 9. Complaints about faulty goods
+
+We are responsible for delivering goods that conform to the contract. If the goods you receive are faulty or not as described, you have rights under the statutory warranty for non conforming goods (in Polish law, "rękojmia" or the rules on conformity of goods with the contract).
+
+To file a complaint, write to {{seller.email}} or to our postal address above. Please include:
+
+- your order number,
+- a description of the problem and when you noticed it,
+- what you would like us to do: repair, replace, reduce the price, or, where the law allows, end the contract and refund you.
+
+Photos help us handle the complaint faster. We answer every complaint within 14 days of receiving it. If we do not answer within 14 days, the complaint counts as accepted.
+
+## 10. Out of court dispute resolution
+
+If we cannot settle a dispute together, you can use out of court ways to resolve it. In Poland these include the consumer ombudsman of your district or city (powiatowy or miejski rzecznik konsumentów) and the Trade Inspection (Inspekcja Handlowa). The European Commission's online dispute resolution information is available at [https://consumer-redress.ec.europa.eu](https://consumer-redress.ec.europa.eu).
+
+Using these options is voluntary for both sides.
+
+## 11. Governing law
+
+These terms are governed by Polish law. If you are a consumer, this does not take away the protection you have under the mandatory law of the country where you live.
+
+## 12. Changes to these terms
+
+We may change these terms, for example when the law or our services change. The date at the top of this page shows the current version.
+
+The version that applies to your order is the one you accepted at checkout. It is recorded with your order and named in your order confirmation email. A later change never applies to an order you already placed.
diff --git a/src/lib/legal/documents.ts b/src/lib/legal/documents.ts
new file mode 100644
index 0000000..2dfc4d6
--- /dev/null
+++ b/src/lib/legal/documents.ts
@@ -0,0 +1,31 @@
+import "server-only";
+
+import { brand } from "@/lib/brand/brand";
+import { env } from "@/lib/env";
+import { countryDisplayName } from "@/lib/shipping/address";
+
+import cookies from "./content/cookies.md";
+import privacy from "./content/privacy.md";
+import returns from "./content/returns.md";
+import shipping from "./content/shipping.md";
+import terms from "./content/terms.md";
+import { loadLegalDocuments } from "./load";
+
+// spec 0017: the texts are bundled as strings at build time (next.config.ts and the Vitest
+// config), never read with fs, so a revalidated /shipping render and startCheckout never depend
+// on file tracing. Parsed once here; a broken file or a blank left in production throws.
+export const legalDocuments = loadLegalDocuments(
+ { terms, privacy, returns, shipping, cookies },
+ {
+ seller: brand.legal,
+ store: {
+ country: countryDisplayName(env.STORE_COUNTRY, env.STORE_LOCALE),
+ currency: env.STORE_CURRENCY,
+ },
+ production: env.VERCEL_ENV === "production",
+ },
+);
+
+// The version a checkout accepts (AC-9) and the one the consent cookie records (AC-15).
+export const currentTermsVersion = legalDocuments.terms.updated;
+export const cookiePolicyVersion = legalDocuments.cookies.updated;
diff --git a/src/lib/legal/front-matter.test.ts b/src/lib/legal/front-matter.test.ts
new file mode 100644
index 0000000..69132cb
--- /dev/null
+++ b/src/lib/legal/front-matter.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import { normalizeText, parseFrontMatter } from "./front-matter";
+
+const good =
+ "---\ntitle: Terms of sale\ndescription: The rules.\nupdated: 2026-10-20\n---\n\nBody\n";
+
+describe("parseFrontMatter", () => {
+ it("reads the three keys and returns the body after the block", () => {
+ expect(parseFrontMatter(good, "terms.md")).toEqual({
+ meta: { title: "Terms of sale", description: "The rules.", updated: "2026-10-20" },
+ body: "\nBody\n",
+ });
+ });
+
+ it("keeps a colon inside a value", () => {
+ const text = good.replace("The rules.", "Rules: orders and payment.");
+ expect(parseFrontMatter(text, "terms.md").meta.description).toBe("Rules: orders and payment.");
+ });
+
+ it("parses a file saved with a BOM and CRLF line ends", () => {
+ const windows = `${good.replaceAll("\n", "\r\n")}`;
+ expect(parseFrontMatter(windows, "terms.md").meta.updated).toBe("2026-10-20");
+ });
+
+ it.each([
+ ["no block", "Body only"],
+ ["a line that is not key: value", good.replace("title: Terms of sale", "Terms of sale")],
+ ["a missing key", good.replace("description: The rules.\n", "")],
+ ["an empty title", good.replace("title: Terms of sale", "title:")],
+ ["a date that is not a real day", good.replace("2026-10-20", "2026-02-31")],
+ ["a date in another format", good.replace("2026-10-20", "20.10.2026")],
+ ])("throws on %s", (_, text) => {
+ expect(() => parseFrontMatter(text, "terms.md")).toThrow("terms.md");
+ });
+});
+
+describe("normalizeText", () => {
+ it("drops a leading BOM and turns CRLF and CR into LF", () => {
+ expect(normalizeText("a\r\nb\rc")).toBe("a\nb\nc");
+ });
+});
diff --git a/src/lib/legal/front-matter.ts b/src/lib/legal/front-matter.ts
new file mode 100644
index 0000000..4df8165
--- /dev/null
+++ b/src/lib/legal/front-matter.ts
@@ -0,0 +1,43 @@
+import { z } from "zod";
+
+import { parseCalendarDay } from "@/lib/dates";
+
+// Pure. A legal text's `---` block (spec 0017, AC-1): only `key: value` lines, so no YAML parser
+// is needed. `updated` is the text's version, a real calendar day (no 2026-02-31).
+
+const frontMatterSchema = z.object({
+ title: z.string().min(1),
+ description: z.string().min(1),
+ updated: z.string().refine((value) => parseCalendarDay(value) !== null, {
+ error: "updated must be a real YYYY-MM-DD date",
+ }),
+});
+
+export type FrontMatter = z.output;
+
+export type ParsedText = { readonly meta: FrontMatter; readonly body: string };
+
+// A leading BOM goes and CRLF becomes LF first, so a file saved on Windows parses the same.
+export function normalizeText(raw: string): string {
+ return raw.replace(/^/, "").replace(/\r\n?/g, "\n");
+}
+
+// Throws on a missing block, a line that is not `key: value`, or a bad value: a broken legal
+// file must fail the build, never render half a page.
+export function parseFrontMatter(raw: string, name: string): ParsedText {
+ const text = normalizeText(raw);
+ const match = /^---\n([\s\S]*?)\n---\n?/.exec(text);
+ if (!match) throw new Error(`${name}: missing the --- front matter block`);
+ const fields: Record = {};
+ for (const line of (match[1] ?? "").split("\n")) {
+ if (line.trim() === "") continue;
+ const pair = /^([A-Za-z]+):\s*(.*)$/.exec(line);
+ if (!pair) throw new Error(`${name}: front matter line is not "key: value": ${line}`);
+ fields[pair[1] ?? ""] = (pair[2] ?? "").trim();
+ }
+ const parsed = frontMatterSchema.safeParse(fields);
+ if (!parsed.success) {
+ throw new Error(`${name}: bad front matter (${z.prettifyError(parsed.error)})`);
+ }
+ return { meta: parsed.data, body: text.slice(match[0].length) };
+}
diff --git a/src/lib/legal/load.test.ts b/src/lib/legal/load.test.ts
new file mode 100644
index 0000000..7ea93fe
--- /dev/null
+++ b/src/lib/legal/load.test.ts
@@ -0,0 +1,145 @@
+import { describe, expect, it } from "vitest";
+
+import type { SellerDetails } from "@/lib/brand/brand";
+
+import { loadLegalDocuments, sellerValues, stripLeadingComments } from "./load";
+import { legalSlugs, type LegalSlug } from "./paths";
+
+const seller: SellerDetails = {
+ name: "Shop sp. z o.o.",
+ address: "ul. Długa 1, 00-001 Warszawa",
+ taxId: "5250000000",
+ registration: "KRS 0000000000",
+ email: "hello@example.com",
+ phone: "+48 600 000 000",
+};
+
+const store = { country: "Poland", currency: "EUR" };
+
+function file(body: string, updated = "2026-10-20"): string {
+ return `---\ntitle: T\ndescription: D\nupdated: ${updated}\n---\n\n\n\n${body}\n`;
+}
+
+function sources(overrides: Partial> = {}): Record {
+ const all = Object.fromEntries(legalSlugs.map((slug) => [slug, file(`${slug} body`)]));
+ return { ...all, ...overrides } as Record;
+}
+
+describe("loadLegalDocuments", () => {
+ it("parses each file into its path, meta and body without the draft note", () => {
+ const docs = loadLegalDocuments(sources(), { seller, store, production: true });
+
+ expect(docs.terms).toEqual({
+ slug: "terms",
+ path: "/terms",
+ title: "T",
+ description: "D",
+ updated: "2026-10-20",
+ body: "terms body",
+ });
+ expect(docs.cookies.path).toBe("/cookies");
+ });
+
+ it("fills the seller and store placeholders", () => {
+ const docs = loadLegalDocuments(
+ sources({
+ terms: file("{{seller.name}}, {{seller.address}}, {{store.country}}, {{store.currency}}"),
+ }),
+ { seller, store, production: true },
+ );
+ expect(docs.terms.body).toBe("Shop sp. z o.o., ul. Długa 1, 00-001 Warszawa, Poland, EUR");
+ });
+
+ // spec 0017, AC-4 and AC-5.
+ it("keeps the live shipping placeholders on /shipping only", () => {
+ const docs = loadLegalDocuments(
+ sources({ shipping: file("{{deliveryFee}}. {{freeDeliveryRule}}") }),
+ { seller, store, production: false },
+ );
+ expect(docs.shipping.body).toBe("{{deliveryFee}}. {{freeDeliveryRule}}");
+ expect(() =>
+ loadLegalDocuments(sources({ terms: file("{{deliveryFee}}") }), {
+ seller,
+ store,
+ production: false,
+ }),
+ ).toThrow("{{deliveryFee}}");
+ });
+
+ it("throws on an unknown placeholder in any environment", () => {
+ expect(() =>
+ loadLegalDocuments(sources({ privacy: file("{{seller.fax}}") }), {
+ seller,
+ store,
+ production: false,
+ }),
+ ).toThrow("{{seller.fax}}");
+ });
+
+ it("renders a null seller field as its own marker", () => {
+ const docs = loadLegalDocuments(sources({ terms: file("{{seller.address}}") }), {
+ seller: { ...seller, address: null },
+ store,
+ production: false,
+ });
+ expect(docs.terms.body).toBe("[[TO FILL: seller.address]]");
+ });
+
+ describe("the production guard", () => {
+ it("lets a marker through outside production, so drafts stay reviewable", () => {
+ const docs = loadLegalDocuments(sources({ returns: file("[[TO FILL: carriers]]") }), {
+ seller,
+ store,
+ production: false,
+ });
+ expect(docs.returns.body).toBe("[[TO FILL: carriers]]");
+ });
+
+ it("throws in production on a marker left in a text", () => {
+ expect(() =>
+ loadLegalDocuments(sources({ returns: file("[[TO FILL: carriers]]") }), {
+ seller,
+ store,
+ production: true,
+ }),
+ ).toThrow("returns.md still has blanks to fill before launch: [[TO FILL: carriers]]");
+ });
+
+ it("throws in production on a seller field still null", () => {
+ expect(() =>
+ loadLegalDocuments(sources({ terms: file("{{seller.taxId}}") }), {
+ seller: { ...seller, taxId: null },
+ store,
+ production: true,
+ }),
+ ).toThrow("[[TO FILL: seller.taxId]]");
+ });
+
+ it("ignores a marker mentioned in the draft note", () => {
+ expect(() =>
+ loadLegalDocuments(sources(), { seller, store, production: true }),
+ ).not.toThrow();
+ });
+ });
+});
+
+describe("sellerValues", () => {
+ it("names every seller placeholder", () => {
+ expect(Object.keys(sellerValues(seller)).sort()).toEqual([
+ "seller.address",
+ "seller.email",
+ "seller.name",
+ "seller.phone",
+ "seller.registration",
+ "seller.taxId",
+ ]);
+ });
+});
+
+describe("stripLeadingComments", () => {
+ it("removes only comments at the top", () => {
+ expect(stripLeadingComments("\n\n\ntext ")).toBe(
+ "text ",
+ );
+ });
+});
diff --git a/src/lib/legal/load.ts b/src/lib/legal/load.ts
new file mode 100644
index 0000000..f5ec0c7
--- /dev/null
+++ b/src/lib/legal/load.ts
@@ -0,0 +1,79 @@
+import type { SellerDetails } from "@/lib/brand/brand";
+
+import { parseFrontMatter } from "./front-matter";
+import { legalPaths, type LegalSlug, legalSlugs } from "./paths";
+import { fillPlaceholders, findFillMarkers, toFillMarker } from "./placeholders";
+
+// Pure. The five raw legal files to the documents the pages render (spec 0017, AC-1 and AC-4).
+// documents.ts runs it once at module load, so any throw here fails the build and `pnpm test`.
+
+export type LegalDocument = {
+ readonly slug: LegalSlug;
+ readonly path: `/${LegalSlug}`;
+ readonly title: string;
+ readonly description: string;
+ // The front matter date, YYYY-MM-DD: the text's version.
+ readonly updated: string;
+ // Seller placeholders filled; `/shipping` still holds its live ones (AC-5).
+ readonly body: string;
+};
+
+// Filled per render from the cached settings read, so an admin save shows with no deploy.
+export const shippingPlaceholders = ["deliveryFee", "freeDeliveryRule"] as const;
+
+const sellerKeys = ["name", "address", "taxId", "registration", "email", "phone"] as const;
+
+// A null field renders its own marker (`[[TO FILL: seller.address]]`), which the production
+// guard then catches.
+export function sellerValues(seller: SellerDetails): Readonly> {
+ return Object.fromEntries(
+ sellerKeys.map((key) => [`seller.${key}`, seller[key] ?? toFillMarker(`seller.${key}`)]),
+ );
+}
+
+// The draft note is an HTML comment at the top. It goes before the marker check, so the note may
+// mention the marker without tripping the guard (the renderer skips raw HTML anyway).
+export function stripLeadingComments(body: string): string {
+ return body.replace(/^\s*(?:\s*)+/, "");
+}
+
+// What the env fixes for the whole store: the one country served and the price currency, named
+// as the sources in the content outline (spec 0017).
+export type StoreFacts = { readonly country: string; readonly currency: string };
+
+export function loadLegalDocuments(
+ raw: Readonly>,
+ options: {
+ readonly seller: SellerDetails;
+ readonly store: StoreFacts;
+ readonly production: boolean;
+ },
+): Readonly> {
+ const values = {
+ ...sellerValues(options.seller),
+ "store.country": options.store.country,
+ "store.currency": options.store.currency,
+ };
+ const entries = legalSlugs.map((slug): [LegalSlug, LegalDocument] => {
+ const name = `src/lib/legal/content/${slug}.md`;
+ const { meta, body } = parseFrontMatter(raw[slug], name);
+ const keep = slug === "shipping" ? shippingPlaceholders : [];
+ const filled = fillPlaceholders(stripLeadingComments(body), values, keep).trim();
+ const markers = findFillMarkers(filled);
+ if (options.production && markers.length > 0) {
+ throw new Error(`${name} still has blanks to fill before launch: ${markers.join(", ")}`);
+ }
+ return [
+ slug,
+ {
+ slug,
+ path: legalPaths[slug],
+ title: meta.title,
+ description: meta.description,
+ updated: meta.updated,
+ body: filled,
+ },
+ ];
+ });
+ return Object.fromEntries(entries) as Record;
+}
diff --git a/src/lib/legal/paths.ts b/src/lib/legal/paths.ts
new file mode 100644
index 0000000..ae8556a
--- /dev/null
+++ b/src/lib/legal/paths.ts
@@ -0,0 +1,23 @@
+// Pure, no server import: client components and the email builder link to the legal pages
+// without pulling in their texts (spec 0017, Module layout).
+
+export const legalSlugs = ["terms", "privacy", "returns", "shipping", "cookies"] as const;
+
+export type LegalSlug = (typeof legalSlugs)[number];
+
+export const legalPaths: Readonly> = {
+ terms: "/terms",
+ privacy: "/privacy",
+ returns: "/returns",
+ shipping: "/shipping",
+ cookies: "/cookies",
+};
+
+// The footer's order (AC-3).
+export const legalLinkLabels: Readonly> = {
+ terms: "Terms of sale",
+ privacy: "Privacy policy",
+ returns: "Returns",
+ shipping: "Shipping",
+ cookies: "Cookie policy",
+};
diff --git a/src/lib/legal/placeholders.test.ts b/src/lib/legal/placeholders.test.ts
new file mode 100644
index 0000000..2efed42
--- /dev/null
+++ b/src/lib/legal/placeholders.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+
+import { fillPlaceholders, findFillMarkers, placeholderNames, toFillMarker } from "./placeholders";
+
+describe("fillPlaceholders", () => {
+ it("fills every known name, with or without inner spaces", () => {
+ expect(
+ fillPlaceholders("{{seller.name}} at {{ seller.email }}", {
+ "seller.name": "Shop sp. z o.o.",
+ "seller.email": "hi@example.com",
+ }),
+ ).toBe("Shop sp. z o.o. at hi@example.com");
+ });
+
+ // spec 0017, AC-4: `{{...}}` text never ships.
+ it("throws on a name with no value", () => {
+ expect(() => fillPlaceholders("{{unknown}}", {})).toThrow("{{unknown}}");
+ });
+
+ it("leaves the names in keep for a later fill", () => {
+ expect(fillPlaceholders("{{deliveryFee}} and {{a}}", { a: "x" }, ["deliveryFee"])).toBe(
+ "{{deliveryFee}} and x",
+ );
+ });
+
+ it("does not read a value inserted by an earlier fill as a placeholder", () => {
+ expect(fillPlaceholders("{{a}}", { a: "{{b}}" })).toBe("{{b}}");
+ });
+});
+
+describe("placeholderNames", () => {
+ it("lists every name in order", () => {
+ expect(placeholderNames("{{a}} x {{ b.c }} {{a}}")).toEqual(["a", "b.c", "a"]);
+ });
+});
+
+describe("fill markers", () => {
+ it("finds every [[TO FILL ...]] marker", () => {
+ expect(findFillMarkers(`a ${toFillMarker("seller.address")} b [[TO FILL]]`)).toEqual([
+ "[[TO FILL: seller.address]]",
+ "[[TO FILL]]",
+ ]);
+ });
+
+ it("finds none in a filled text", () => {
+ expect(findFillMarkers("All filled [[link]]")).toEqual([]);
+ });
+});
diff --git a/src/lib/legal/placeholders.ts b/src/lib/legal/placeholders.ts
new file mode 100644
index 0000000..fb7b1f1
--- /dev/null
+++ b/src/lib/legal/placeholders.ts
@@ -0,0 +1,33 @@
+// Pure. The `{{name}}` placeholders in the legal texts (spec 0017, AC-4) and the
+// `[[TO FILL: ...]]` markers a draft leaves for the store owner.
+
+const placeholder = /\{\{\s*([A-Za-z][\w.]*)\s*\}\}/g;
+
+// Every `{{name}}` is filled from `values`; one with no value throws, so `{{...}}` text never
+// ships. Names in `keep` stay as they are, for a later fill (the live shipping values).
+export function fillPlaceholders(
+ text: string,
+ values: Readonly>,
+ keep: readonly string[] = [],
+): string {
+ return text.replace(placeholder, (whole, name: string) => {
+ if (keep.includes(name)) return whole;
+ const value = values[name];
+ if (value === undefined) throw new Error(`Unknown legal placeholder {{${name}}}`);
+ return value;
+ });
+}
+
+export function placeholderNames(text: string): readonly string[] {
+ return [...text.matchAll(placeholder)].map((match) => match[1] ?? "");
+}
+
+const fillMarkers = /\[\[TO FILL[^\]]*\]\]/g;
+
+export function findFillMarkers(text: string): readonly string[] {
+ return text.match(fillMarkers) ?? [];
+}
+
+export function toFillMarker(what: string): string {
+ return `[[TO FILL: ${what}]]`;
+}
diff --git a/tests/e2e/admin/settings.spec.ts b/tests/e2e/admin/settings.spec.ts
index add9304..4c10d35 100644
--- a/tests/e2e/admin/settings.spec.ts
+++ b/tests/e2e/admin/settings.spec.ts
@@ -59,6 +59,21 @@ async function saveSettings(page: Page, fee: string, threshold: string | null) {
await expect(page.getByText("Shipping settings saved.")).toBeVisible();
}
+// spec 0017, AC-5: /shipping fills in the live values; the save expires its cached read.
+test("the shipping page shows the saved fee and free delivery rule on its next load", async ({
+ page,
+}) => {
+ await saveSettings(adminPage, "9.99", "50");
+ await page.goto("/shipping");
+ await expect(page.getByText("Standard delivery costs €9.99.")).toBeVisible();
+ await expect(page.getByText("Delivery is free on orders of €50.00 or more.")).toBeVisible();
+
+ await saveSettings(adminPage, "4.50", null);
+ await page.goto("/shipping");
+ await expect(page.getByText("Standard delivery costs €4.50.")).toBeVisible();
+ await expect(page.getByText("The same fee applies to every order.")).toBeVisible();
+});
+
test("an admin sets the fee and threshold, and the cart and checkout show them", async ({
page,
}) => {
diff --git a/vitest.config.mts b/vitest.config.mts
index 850fdfe..e5c30bf 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -1,9 +1,24 @@
-import { defineConfig } from "vitest/config";
+import { defineConfig, type Plugin } from "vitest/config";
+
+// The same as the `raw` Turbopack rule in next.config.ts: a legal text imports as its string.
+const legalContent = /\/src\/lib\/legal\/content\/[^/]+\.md$/;
+
+function rawLegalMarkdown(): Plugin {
+ return {
+ name: "raw-legal-markdown",
+ enforce: "pre",
+ transform(code, id) {
+ if (!legalContent.test(id)) return null;
+ return { code: `export default ${JSON.stringify(code)};`, map: null };
+ },
+ };
+}
// Three projects: `unit` needs no database (`pnpm test`); `db` runs the *.db.test.ts suites
// against TEST_DATABASE_URL (`pnpm test:db`, which migrates that database first); `stack` runs
// the *.stack.test.ts suites against the local Supabase stack's auth schema (`pnpm test:stack`).
export default defineConfig({
+ plugins: [rawLegalMarkdown()],
resolve: { tsconfigPaths: true },
test: {
environment: "node",
From 81764215641c81133295da4117aa8e945d1f046e Mon Sep 17 00:00:00 2001
From: kris1027
Date: Tue, 20 Oct 2026 19:38:00 +0200
Subject: [PATCH 03/27] feat(checkout): require and record terms acceptance on
each order
Co-Authored-By: Claude Opus 5.5
---
app/(store)/checkout/page.tsx | 3 +-
emails/order-confirmation.tsx | 38 ++-
.../migration.sql | 13 +
prisma/schema.prisma | 4 +
src/components/form-notice.tsx | 4 +-
src/components/new-tab-link.tsx | 21 ++
.../account/components/sign-up-form.tsx | 8 +
.../checkout/actions/start-checkout.ts | 40 ++-
.../checkout/components/checkout-form.tsx | 82 +++++-
.../checkout/components/checkout-payment.tsx | 2 +
.../checkout/components/checkout-summary.tsx | 4 +
src/features/checkout/schemas.test.ts | 31 +-
src/features/checkout/schemas.ts | 25 +-
src/features/orders/admin-queries.ts | 10 +
.../orders/components/admin-order-detail.tsx | 8 +-
src/lib/email/content.ts | 2 +
src/lib/email/props.ts | 15 +-
src/lib/email/render.test.ts | 55 +++-
tests/db/admin-orders.db.test.ts | 15 +
tests/db/discounts-checkout.db.test.ts | 3 +
tests/db/order-emails.db.test.ts | 24 ++
tests/db/purge-expired-orders.db.test.ts | 5 +
tests/db/start-checkout.db.test.ts | 106 +++++++
tests/e2e/account/checkout.spec.ts | 2 +
tests/e2e/checkout/support.ts | 6 +
tests/e2e/legal/legal-pages.spec.ts | 269 ++++++++++++++++++
26 files changed, 781 insertions(+), 14 deletions(-)
create mode 100644 prisma/migrations/20261020164331_order_terms_acceptance/migration.sql
create mode 100644 src/components/new-tab-link.tsx
create mode 100644 tests/e2e/legal/legal-pages.spec.ts
diff --git a/app/(store)/checkout/page.tsx b/app/(store)/checkout/page.tsx
index 95ae432..04fe414 100644
--- a/app/(store)/checkout/page.tsx
+++ b/app/(store)/checkout/page.tsx
@@ -6,6 +6,7 @@ import {
CheckoutSummary,
CheckoutSummarySkeleton,
} from "@/features/checkout/components/checkout-summary";
+import { currentTermsVersion } from "@/lib/legal/documents";
import { cn } from "@/lib/utils";
export const metadata: Metadata = {
@@ -18,7 +19,7 @@ export default function CheckoutPage({ searchParams }: PageProps<"/checkout">) {
Checkout
}>
-
+
);
diff --git a/emails/order-confirmation.tsx b/emails/order-confirmation.tsx
index c1a10bb..72262f1 100644
--- a/emails/order-confirmation.tsx
+++ b/emails/order-confirmation.tsx
@@ -1,14 +1,16 @@
-import { Heading, Section, Text } from "react-email";
+import { Heading, Link, Section, Text } from "react-email";
import {
AddressSection,
bodyCellStyle,
+ colors,
DataTable,
type EmailAddressLines,
type EmailFrame,
EmailLayout,
headerCellStyle,
itemLabel,
+ mutedStyle,
subheadingStyle,
textStyle,
} from "./components/layout";
@@ -37,6 +39,14 @@ export type OrderConfirmationProps = {
readonly delivery: string;
readonly total: string;
readonly address: EmailAddressLines | null;
+ // spec 0017, AC-14: the terms version as a long date, or null for an order paid before it was
+ // recorded. The links are absolute; the template never reads env.
+ readonly termsVersionLabel: string | null;
+ readonly legalLinks: {
+ readonly terms: string;
+ readonly returns: string;
+ readonly privacy: string;
+ };
};
export function orderConfirmationSubject(orderNumber: number): string {
@@ -103,10 +113,30 @@ export function OrderConfirmationEmail(props: OrderConfirmationProps) {
We'll email you when your order ships.
+ {props.termsVersionLabel === null ? null : (
+
+ Terms of sale version {props.termsVersionLabel} applied to this order.
+
+ )}
+
+
+ Terms of sale
+
+ {" · "}
+
+ Returns
+
+ {" · "}
+
+ Privacy policy
+
+
);
}
+const legalLinkStyle = { color: colors.text, textDecoration: "underline" } as const;
+
function SummaryRow({
label,
value,
@@ -155,6 +185,12 @@ OrderConfirmationEmail.PreviewProps = {
city: "Warszawa",
countryName: "Poland",
},
+ termsVersionLabel: "October 20, 2026",
+ legalLinks: {
+ terms: "https://example.com/terms",
+ returns: "https://example.com/returns",
+ privacy: "https://example.com/privacy",
+ },
} satisfies OrderConfirmationProps;
// React Email's preview server (`pnpm email:dev`) finds a template by its default export.
diff --git a/prisma/migrations/20261020164331_order_terms_acceptance/migration.sql b/prisma/migrations/20261020164331_order_terms_acceptance/migration.sql
new file mode 100644
index 0000000..c3b6fdf
--- /dev/null
+++ b/prisma/migrations/20261020164331_order_terms_acceptance/migration.sql
@@ -0,0 +1,13 @@
+-- spec 0017, AC-11: the terms of sale version accepted at Pay, and when. Both nullable: orders
+-- from before this feature keep both null and the admin shows "not recorded". orders already has
+-- RLS on, and the columns are never filtered on, so no index.
+-- AlterTable
+ALTER TABLE "orders" ADD COLUMN "terms_accepted_at" TIMESTAMPTZ(3),
+ADD COLUMN "terms_version" DATE;
+
+-- ─── Raw SQL: what Prisma cannot express (spec 0017) ────────────────────────
+-- CHECK constraints are invisible to `prisma migrate dev` drift detection, so they live only here.
+
+ALTER TABLE "orders"
+ ADD CONSTRAINT "orders_terms_acceptance_check"
+ CHECK (("terms_version" IS NULL) = ("terms_accepted_at" IS NULL));
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 606e738..5445822 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -406,6 +406,10 @@ model Order {
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
// Set once by the expired order PII purge (spec 0008), never cleared. Only on expired orders.
piiPurgedAt DateTime? @map("pii_purged_at") @db.Timestamptz(3)
+ // spec 0017, AC-11: the terms of sale version (its `updated` date) accepted at Pay, and when.
+ // Both or neither (a CHECK in the migration); null on orders from before the feature.
+ termsVersion DateTime? @map("terms_version") @db.Date
+ termsAcceptedAt DateTime? @map("terms_accepted_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
diff --git a/src/components/form-notice.tsx b/src/components/form-notice.tsx
index 280a8c8..34df9c3 100644
--- a/src/components/form-notice.tsx
+++ b/src/components/form-notice.tsx
@@ -1,9 +1,11 @@
import { CircleAlertIcon, InfoIcon } from "lucide-react";
+import type { ReactNode } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
type FormNoticeProps = {
- readonly message: string | null;
+ // Text, or text with a link (spec 0017, AC-10).
+ readonly message: ReactNode;
// `error` is announced at once (role alert); `info` politely (role status).
readonly tone?: "error" | "info";
readonly id?: string;
diff --git a/src/components/new-tab-link.tsx b/src/components/new-tab-link.tsx
new file mode 100644
index 0000000..6ea7d40
--- /dev/null
+++ b/src/components/new-tab-link.tsx
@@ -0,0 +1,21 @@
+// spec 0017, AC-8 and AC-12: a legal link beside a form opens in a new tab, so what the customer
+// typed survives, and its accessible name says so.
+export function NewTabLink({
+ href,
+ children,
+}: {
+ readonly href: string;
+ readonly children: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/features/account/components/sign-up-form.tsx b/src/features/account/components/sign-up-form.tsx
index 01bd252..420cde6 100644
--- a/src/features/account/components/sign-up-form.tsx
+++ b/src/features/account/components/sign-up-form.tsx
@@ -4,10 +4,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useRef, useState, useTransition } from "react";
import { useForm } from "react-hook-form";
+import { NewTabLink } from "@/components/new-tab-link";
import { Button } from "@/components/ui/button";
import { FieldGroup } from "@/components/ui/field";
import { Spinner } from "@/components/ui/spinner";
import { applyFieldErrors } from "@/lib/form-errors";
+import { legalPaths } from "@/lib/legal/paths";
import { signUp } from "../actions/sign-up";
import { accountMessages } from "../messages";
@@ -41,6 +43,12 @@ export function SignUpForm() {
{pending ? : null}
Continue
+ {/* spec 0017, AC-12: nothing new is stored; the terms apply to orders placed later. */}
+
+ By creating an account you accept the{" "}
+ terms of sale and confirm you have read
+ the privacy policy.
+
);
diff --git a/src/features/checkout/actions/start-checkout.ts b/src/features/checkout/actions/start-checkout.ts
index 63c0a32..3e4a05b 100644
--- a/src/features/checkout/actions/start-checkout.ts
+++ b/src/features/checkout/actions/start-checkout.ts
@@ -11,7 +11,9 @@ import { db, type Tx } from "@/lib/db";
import { uniqueViolation } from "@/lib/db-errors";
import { redeem } from "@/lib/discounts/codes";
import type { DiscountRefused } from "@/lib/discounts/rule";
+import { calendarDate } from "@/lib/dates";
import { env } from "@/lib/env";
+import { currentTermsVersion } from "@/lib/legal/documents";
import { logOrderExpired } from "@/lib/orders/log";
import { minimumChargeCents } from "@/lib/orders/minimum-charge";
import { orderLineImage } from "@/lib/orders/order-image";
@@ -35,7 +37,13 @@ import {
logStripeFailed,
stripeErrorCode,
} from "../log";
-import { type CheckoutFieldErrors, checkoutFieldErrors, checkoutSchema } from "../schemas";
+import {
+ type CheckoutFieldErrors,
+ checkoutFieldErrors,
+ checkoutMessages,
+ checkoutSchema,
+ startCheckoutInputSchema,
+} from "../schemas";
import { checkoutSessionParams } from "../stripe-session";
export type StartCheckoutError =
@@ -49,7 +57,9 @@ export type StartCheckoutError =
| { readonly code: "checkout_in_progress" }
| { readonly code: "payment_unavailable" }
// spec 0014, AC-4: the code stopped applying since Apply (or never did); no order was made.
- | ({ readonly code: "discount_refused" } & DiscountRefused);
+ | ({ readonly code: "discount_refused" } & DiscountRefused)
+ // spec 0017, AC-9: the terms changed since the page loaded; the form asks again with this one.
+ | { readonly code: "terms_changed"; readonly currentVersion: string };
type Result = ActionResult;
@@ -81,8 +91,25 @@ async function run(input: unknown): Promise> {
email: typedEmail,
saveAddress: saveRequested,
discountCode: codeInput,
+ acceptTerms,
...address
} = parsed.data;
+ // spec 0017, AC-9: the version the page showed must be the current one, checked before any
+ // database work, so a refusal creates no order and takes no lock. A missing or malformed one
+ // reads as changed: the customer never saw the current text.
+ // acceptTerms is `true` by the schema; checked here too, as the server is what enforces it.
+ if (acceptTerms !== true) {
+ return fail({ code: "validation", fields: { acceptTerms: checkoutMessages.acceptTerms } });
+ }
+ const terms = startCheckoutInputSchema.safeParse(input);
+ if (!terms.success || terms.data.termsVersion !== currentTermsVersion) {
+ return fail({ code: "terms_changed", currentVersion: currentTermsVersion });
+ }
+ // AC-11: the moment of acceptance is the server's time at Pay.
+ const acceptance: TermsAcceptance = {
+ version: calendarDate(terms.data.termsVersion),
+ acceptedAt: new Date(),
+ };
const shipTo: ShipTo = { ...address, countryCode: env.STORE_COUNTRY };
// spec 0012, AC-15: a signed in checkout uses the account email, whatever was typed, and a
@@ -97,7 +124,7 @@ async function run(input: unknown): Promise> {
const cleared = await clearPendingOrder(cartId);
if (!cleared.ok) return cleared;
- const created = await createPendingOrder(cartId, email, shipTo, account, codeInput);
+ const created = await createPendingOrder(cartId, email, shipTo, account, codeInput, acceptance);
if (!created.ok) return created;
const order = created.data;
@@ -262,6 +289,10 @@ async function checkoutCartId(customer: CustomerIdentity | null): Promise> {
try {
const created = await db.$transaction(
@@ -352,6 +384,8 @@ async function createPendingOrder(
discountCents: snapshot.discountCents,
shippingCents: snapshot.shippingCents,
totalCents: snapshot.totalCents,
+ termsVersion: terms.version,
+ termsAcceptedAt: terms.acceptedAt,
// spec 0014, AC-4: the code's snapshot, written once and never changed.
...(applied === null
? {}
diff --git a/src/features/checkout/components/checkout-form.tsx b/src/features/checkout/components/checkout-form.tsx
index adf05e3..af3f8fe 100644
--- a/src/features/checkout/components/checkout-form.tsx
+++ b/src/features/checkout/components/checkout-form.tsx
@@ -12,6 +12,7 @@ import {
useTransition,
} from "react";
import {
+ type Control,
Controller,
type FieldError as FormFieldError,
useForm,
@@ -19,11 +20,13 @@ import {
} from "react-hook-form";
import { FormNotice } from "@/components/form-notice";
+import { NewTabLink } from "@/components/new-tab-link";
import { Checkbox } from "@/components/ui/checkbox";
import { useStoreFormat } from "@/components/store-format-provider";
import { Button } from "@/components/ui/button";
import {
Field,
+ FieldContent,
FieldDescription,
FieldError,
FieldGroup,
@@ -34,11 +37,18 @@ import {
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Spinner } from "@/components/ui/spinner";
+import { calendarDate, formatCalendarDate } from "@/lib/dates";
+import { legalPaths } from "@/lib/legal/paths";
import { formatMoney } from "@/lib/money";
import type { StoreCountry } from "@/lib/shipping/address";
import { startCheckout, type StartCheckoutError } from "../actions/start-checkout";
-import { type CheckoutPrefill, checkoutFields, checkoutSchema } from "../schemas";
+import {
+ type CheckoutPrefill,
+ checkoutFields,
+ checkoutSchema,
+ type CheckoutValues,
+} from "../schemas";
// A saved address the signed in customer can swap in (spec 0012, AC-15).
export type SavedAddressChoice = {
@@ -78,6 +88,7 @@ function formError(error: StartCheckoutError, money: (cents: number) => string):
case "cart_changed":
case "already_paid":
case "discount_refused":
+ case "terms_changed":
return null;
}
}
@@ -91,6 +102,7 @@ export function CheckoutForm({
prefill,
account = null,
discountCode,
+ termsVersion: shownTermsVersion,
emailRef,
onDiscountRefused,
}: {
@@ -102,6 +114,8 @@ export function CheckoutForm({
readonly account?: CheckoutAccount | null;
// spec 0014, AC-4: the applied promo code Pay sends, or null.
readonly discountCode: string | null;
+ // spec 0017, AC-9: the terms version this page shows, sent with Pay.
+ readonly termsVersion: string;
// Lets the promo code field read the email as typed, for its per customer check.
readonly emailRef: RefObject<() => string>;
// Pay refused the code: the field shows why and the summary drops it.
@@ -112,6 +126,9 @@ export function CheckoutForm({
const [pending, startTransition] = useTransition();
const [redirecting, setRedirecting] = useState(false);
const [notice, setNotice] = useState(null);
+ // spec 0017, AC-10: never an editable field. A terms_changed answer moves it to the new version.
+ const [termsVersion, setTermsVersion] = useState(shownTermsVersion);
+ const [termsChangedOn, setTermsChangedOn] = useState(null);
// Until React takes over, the button stays disabled: a native submit would put the email in
// the URL as a GET query. A disabled default button also blocks submitting with Enter.
const hydrated = useSyncExternalStore(
@@ -148,9 +165,11 @@ export function CheckoutForm({
const onSubmit = form.handleSubmit((values) => {
setNotice(null);
+ setTermsChangedOn(null);
startTransition(async () => {
const result = await startCheckout({
...values,
+ termsVersion,
...(discountCode === null ? {} : { discountCode }),
});
if (result.ok) {
@@ -176,6 +195,12 @@ export function CheckoutForm({
router.push("/cart");
} else if (error.code === "discount_refused") {
onDiscountRefused(error);
+ } else if (error.code === "terms_changed") {
+ // Every other field keeps its value; the box asks again for the new text.
+ form.resetField("acceptTerms");
+ setTermsVersion(error.currentVersion);
+ setTermsChangedOn(error.currentVersion);
+ form.setFocus("acceptTerms");
} else if (error.code === "already_paid") {
setRedirecting(true);
window.location.assign(error.confirmationUrl);
@@ -295,6 +320,18 @@ export function CheckoutForm({
) : null}
+
+ Our terms of sale changed on{" "}
+ {formatCalendarDate(calendarDate(termsChangedOn), format.locale)}. Please read{" "}
+ them and tick the box again.
+ >
+ )
+ }
+ />
+
+
+ Before you buy, see how returns and{" "}
+ shipping work.
+
);
}
+// spec 0017, AC-8: unticked to start, never disabling Pay. Pay unticked shows the error under
+// the box and focuses it; the server refuses the same (AC-9).
+function TermsField({
+ control,
+ error,
+}: {
+ readonly control: Control;
+ readonly error: FormFieldError | undefined;
+}) {
+ const id = "checkout-accept-terms";
+ const errorId = `${id}-error`;
+ return (
+
+ (
+ field.onChange(checked === true ? true : undefined)}
+ onBlur={field.onBlur}
+ aria-invalid={error ? true : undefined}
+ aria-describedby={error ? errorId : undefined}
+ />
+ )}
+ />
+
+
+ I accept the terms of sale and have read
+ the privacy policy.
+
+ {error ? : null}
+
+
+ );
+}
+
function TextField({
id,
label,
diff --git a/src/features/checkout/components/checkout-payment.tsx b/src/features/checkout/components/checkout-payment.tsx
index a3a184e..64f401c 100644
--- a/src/features/checkout/components/checkout-payment.tsx
+++ b/src/features/checkout/components/checkout-payment.tsx
@@ -47,6 +47,8 @@ export function CheckoutPayment({
readonly countryName: string;
readonly prefill: CheckoutPrefill;
readonly account: CheckoutAccount | null;
+ // spec 0017, AC-9: the terms version the page shows.
+ readonly termsVersion: string;
}) {
const router = useRouter();
const format = useStoreFormat();
diff --git a/src/features/checkout/components/checkout-summary.tsx b/src/features/checkout/components/checkout-summary.tsx
index acd4ac7..22b22c9 100644
--- a/src/features/checkout/components/checkout-summary.tsx
+++ b/src/features/checkout/components/checkout-summary.tsx
@@ -48,8 +48,11 @@ const cartPath = "/cart";
// (AC-9); Pay checks the code again under its lock.
export async function CheckoutSummary({
searchParams,
+ termsVersion,
}: {
readonly searchParams: Promise>;
+ // spec 0017, AC-9: the current terms version, which Pay sends back.
+ readonly termsVersion: string;
}) {
const [cart, params] = await Promise.all([loadOwnedCart(), searchParams]);
if (!cart || !cart.canCheckout) redirect(cartPath);
@@ -177,6 +180,7 @@ export async function CheckoutSummary({
countryName={countryDisplayName(env.STORE_COUNTRY, env.STORE_LOCALE)}
prefill={prefill}
account={account}
+ termsVersion={termsVersion}
/>
diff --git a/src/features/checkout/schemas.test.ts b/src/features/checkout/schemas.test.ts
index 19a6328..e4e9eeb 100644
--- a/src/features/checkout/schemas.test.ts
+++ b/src/features/checkout/schemas.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
-import { checkoutFieldErrors, checkoutSchema, sessionIdSchema } from "./schemas";
+import {
+ checkoutFieldErrors,
+ checkoutSchema,
+ sessionIdSchema,
+ termsVersionSchema,
+} from "./schemas";
// spec 0006, AC-1, AC-2 and AC-11; spec 0007, AC-2.
@@ -13,6 +18,7 @@ const address = {
postalCode: "00950",
city: "Warsaw",
phone: "",
+ acceptTerms: true,
};
describe("checkoutSchema", () => {
@@ -26,6 +32,7 @@ describe("checkoutSchema", () => {
city: "Warsaw",
phone: null,
saveAddress: false,
+ acceptTerms: true,
});
});
@@ -72,6 +79,28 @@ describe("checkoutSchema", () => {
});
});
+// spec 0017, AC-8 and AC-9.
+describe("acceptTerms", () => {
+ it.each([false, undefined, "true", 1])("refuses %j with the tick the box message", (value) => {
+ const result = schema.safeParse({ email: "a@example.com", ...address, acceptTerms: value });
+
+ if (result.success) throw new Error("expected the input to be refused");
+ expect(checkoutFieldErrors(result.error)).toEqual({
+ acceptTerms: "Tick the box to accept the terms of sale.",
+ });
+ });
+});
+
+describe("termsVersionSchema", () => {
+ it("accepts a real YYYY-MM-DD date", () => {
+ expect(termsVersionSchema.safeParse("2026-10-20").success).toBe(true);
+ });
+
+ it.each(["2026-02-31", "20.10.2026", "", 20261020, undefined])("refuses %j", (value) => {
+ expect(termsVersionSchema.safeParse(value).success).toBe(false);
+ });
+});
+
describe("checkoutFieldErrors", () => {
it("maps each invalid field to its first message", () => {
const result = schema.safeParse({ ...address, email: "x", city: "", postalCode: "1" });
diff --git a/src/features/checkout/schemas.ts b/src/features/checkout/schemas.ts
index 9b38baf..4e1cc9d 100644
--- a/src/features/checkout/schemas.ts
+++ b/src/features/checkout/schemas.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
+import { parseCalendarDay } from "@/lib/dates";
import { normalizeEmail } from "@/lib/discounts/schema";
import { shippingAddressSchema, type StoreCountry } from "@/lib/shipping/address";
@@ -7,6 +8,8 @@ import { shippingAddressSchema, type StoreCountry } from "@/lib/shipping/address
export const checkoutMessages = {
emailInvalid: "Enter a valid email address.",
+ // spec 0017, AC-8 and AC-9.
+ acceptTerms: "Tick the box to accept the terms of sale.",
} as const;
// Lowercasing and trimming twice gives the same email, so the server can parse the form's output.
@@ -27,13 +30,24 @@ export function checkoutSchema(country: StoreCountry) {
// spec 0014, AC-4: the applied promo code, checked again by Pay under the code lock. Any
// value is passed on as typed: one that is not a code is refused as "This code isn't valid."
discountCode: z.unknown().optional(),
+ // spec 0017, AC-8 and AC-9: unticked (the form's undefined), false or missing all refuse.
+ acceptTerms: z.literal(true, { error: checkoutMessages.acceptTerms }),
});
}
+// spec 0017, AC-9: the terms version the page showed, held in component state, never a field the
+// customer edits. startCheckout parses it on its own, before any database work.
+export const termsVersionSchema = z
+ .string()
+ .refine((value) => parseCalendarDay(value) !== null, { error: "Not a YYYY-MM-DD date" });
+
+export const startCheckoutInputSchema = z.object({ termsVersion: termsVersionSchema });
+
export type CheckoutValues = z.input>;
export type CheckoutInput = z.output>;
-export const checkoutFields = [
+// The typed fields, which a return visit can fill in.
+const checkoutTextFields = [
"email",
"fullName",
"line1",
@@ -43,10 +57,17 @@ export const checkoutFields = [
"phone",
] as const satisfies readonly (keyof CheckoutValues)[];
+// Every field a validation refusal can name, in the order the form shows them, so the first
+// invalid one gets focus.
+export const checkoutFields = [
+ ...checkoutTextFields,
+ "acceptTerms",
+] as const satisfies readonly (keyof CheckoutValues)[];
+
export type CheckoutField = (typeof checkoutFields)[number];
// What a return visit fills in (spec 0007, AC-9); every field empty for a fresh cart.
-export type CheckoutPrefill = Record;
+export type CheckoutPrefill = Record<(typeof checkoutTextFields)[number], string>;
export const emptyPrefill: CheckoutPrefill = {
email: "",
diff --git a/src/features/orders/admin-queries.ts b/src/features/orders/admin-queries.ts
index b4f8d01..8b105e3 100644
--- a/src/features/orders/admin-queries.ts
+++ b/src/features/orders/admin-queries.ts
@@ -246,6 +246,9 @@ export type AdminOrderDetail = {
readonly shippedAt: Date | null;
readonly deliveredAt: Date | null;
readonly cancelledAt: Date | null;
+ // spec 0017, AC-13: the terms version (a UTC midnight date) accepted at Pay, and when; null on
+ // orders from before the feature.
+ readonly terms: { readonly version: Date; readonly acceptedAt: Date } | null;
readonly carrier: string | null;
readonly trackingNumber: string | null;
readonly phone: string | null;
@@ -317,6 +320,8 @@ export async function getAdminOrder(
shippedAt: true,
deliveredAt: true,
cancelledAt: true,
+ termsVersion: true,
+ termsAcceptedAt: true,
carrier: true,
trackingNumber: true,
phone: true,
@@ -463,6 +468,11 @@ export async function getAdminOrder(
shippedAt: order.shippedAt,
deliveredAt: order.deliveredAt,
cancelledAt: order.cancelledAt,
+ // The CHECK keeps the two together.
+ terms:
+ order.termsVersion === null || order.termsAcceptedAt === null
+ ? null
+ : { version: order.termsVersion, acceptedAt: order.termsAcceptedAt },
carrier: order.carrier,
trackingNumber: order.trackingNumber,
phone: order.phone,
diff --git a/src/features/orders/components/admin-order-detail.tsx b/src/features/orders/components/admin-order-detail.tsx
index e322538..3545992 100644
--- a/src/features/orders/components/admin-order-detail.tsx
+++ b/src/features/orders/components/admin-order-detail.tsx
@@ -23,7 +23,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
-import { type DateFormat, formatDateTime } from "@/lib/dates";
+import { type DateFormat, formatCalendarDate, formatDateTime } from "@/lib/dates";
import { discountLabel } from "@/lib/discounts/rule";
import { deliveryLabel } from "@/lib/shipping/rule";
import { cn } from "@/lib/utils";
@@ -277,6 +277,12 @@ export function AdminOrderDetail({
{formatDateTime(order.cancelledAt, dateFormat)}
) : null}
+ {/* spec 0017, AC-13: which terms text applies in a dispute. */}
+
+ {order.terms === null
+ ? "not recorded"
+ : `version ${formatCalendarDate(order.terms.version, dateFormat.locale)}, on ${formatDateTime(order.terms.acceptedAt, dateFormat)}`}
+
{order.carrier || order.trackingNumber ? (
{[order.carrier, order.trackingNumber].filter(Boolean).join(", ")}
diff --git a/src/lib/email/content.ts b/src/lib/email/content.ts
index db9da74..84f9e69 100644
--- a/src/lib/email/content.ts
+++ b/src/lib/email/content.ts
@@ -30,6 +30,7 @@ function emailFormat(): EmailFormat {
timeZone: env.STORE_TIMEZONE,
storeName: brand.name,
contactAddress: env.EMAIL_FROM_ADDRESS,
+ siteUrl: env.NEXT_PUBLIC_SITE_URL,
};
}
@@ -53,6 +54,7 @@ export async function loadEmailContent(row: ContentRow): Promise {
discountCode: true,
shippingCents: true,
totalCents: true,
+ termsVersion: true,
...deliveryAddressColumns,
lines: {
orderBy: lineOrder,
diff --git a/src/lib/email/props.ts b/src/lib/email/props.ts
index 0ec842d..ef23089 100644
--- a/src/lib/email/props.ts
+++ b/src/lib/email/props.ts
@@ -2,8 +2,9 @@ import type { EmailFrame } from "../../../emails/components/layout";
import type { OrderConfirmationProps } from "../../../emails/order-confirmation";
import type { OrderRefundedProps } from "../../../emails/order-refunded";
import type { OrderShippedProps } from "../../../emails/order-shipped";
-import { formatDate } from "@/lib/dates";
+import { formatCalendarDate, formatDate } from "@/lib/dates";
import { discountRowLabel } from "@/lib/discounts/rule";
+import { legalPaths } from "@/lib/legal/paths";
import { formatMoney } from "@/lib/money";
import type { DeliveryAddress } from "@/lib/shipping/address";
@@ -14,7 +15,10 @@ export type EmailFormat = {
readonly locale: string;
readonly timeZone: string;
readonly storeName: string;
+ // The address part of EMAIL_FROM.
readonly contactAddress: string;
+ // NEXT_PUBLIC_SITE_URL, for the legal links (spec 0017, AC-14).
+ readonly siteUrl: string;
};
export type EmailContent =
@@ -65,6 +69,8 @@ export type ConfirmationSource = OrderBasics & {
readonly discountCode: string | null;
readonly shippingCents: number;
readonly totalCents: number;
+ // spec 0017, AC-14: the accepted terms version, a UTC midnight date; null before the feature.
+ readonly termsVersion: Date | null;
readonly lines: readonly (SourceItem & {
readonly unitPriceCents: number;
readonly lineTotalCents: number;
@@ -97,6 +103,13 @@ export function confirmationProps(
delivery: order.shippingCents === 0 ? "Free" : money(order.shippingCents),
total: money(order.totalCents),
address: order.address,
+ termsVersionLabel:
+ order.termsVersion === null ? null : formatCalendarDate(order.termsVersion, format.locale),
+ legalLinks: {
+ terms: new URL(legalPaths.terms, format.siteUrl).toString(),
+ returns: new URL(legalPaths.returns, format.siteUrl).toString(),
+ privacy: new URL(legalPaths.privacy, format.siteUrl).toString(),
+ },
};
}
diff --git a/src/lib/email/render.test.ts b/src/lib/email/render.test.ts
index f7f70eb..876de53 100644
--- a/src/lib/email/render.test.ts
+++ b/src/lib/email/render.test.ts
@@ -17,6 +17,7 @@ const format: EmailFormat = {
timeZone: "Europe/Warsaw",
storeName: "Shop",
contactAddress: "orders@example.com",
+ siteUrl: "https://shop.example",
};
const address = {
@@ -59,6 +60,7 @@ function confirmation(overrides: Partial[0]
discountCode: null,
shippingCents: 999,
totalCents: 10699,
+ termsVersion: new Date("2026-10-20T00:00:00.000Z"),
lines,
...overrides,
},
@@ -141,6 +143,7 @@ describe("the confirmation", () => {
discountCode: null,
shippingCents: 0,
totalCents: 1000,
+ termsVersion: null,
lines: [],
},
{ ...format, locale: "pl-PL" },
@@ -152,16 +155,64 @@ describe("the confirmation", () => {
expect(props.paidDate).toBe("13 paź 2026");
});
- it("is accessible and private: one h1, presentation layout, a data table, no links or images", async () => {
+ // spec 0017, AC-14: the only links are the three legal pages, so nothing in the email leads
+ // back to the order or the checkout.
+ it("is accessible and private: one h1, presentation layout, a data table, only legal links, no images", async () => {
const { html } = await renderEmail({ kind: "order_confirmation", props: confirmation() });
expect(html.match(/
]*href="([^"]+)"/g)].map((match) => match[1])).toEqual([
+ "https://shop.example/terms",
+ "https://shop.example/returns",
+ "https://shop.example/privacy",
+ ]);
expect(html).not.toMatch(/ {
+ it("names the accepted version as a long date, with the legal links", async () => {
+ const email = await renderEmail({ kind: "order_confirmation", props: confirmation() });
+
+ expect(email.text).toContain("Terms of sale version October 20, 2026 applied to this order.");
+ for (const text of ["Terms of sale", "Returns", "Privacy policy"]) {
+ expect(email.text).toContain(text);
+ }
+ });
+
+ it("leaves the version line out for an order paid before it was recorded, keeping the links", async () => {
+ const email = await renderEmail({
+ kind: "order_confirmation",
+ props: confirmation({ termsVersion: null }),
+ });
+
+ expect(email.text).not.toContain("applied to this order");
+ expect(email.html).toContain('href="https://shop.example/terms"');
+ });
+
+ it("formats the version in the store locale, on the same day", () => {
+ const props = confirmationProps(
+ {
+ number: 1,
+ customerName: null,
+ currency: "PLN",
+ address: null,
+ paidAt: new Date("2026-10-13T10:00:00Z"),
+ subtotalCents: 1000,
+ discountCents: 0,
+ discountCode: null,
+ shippingCents: 0,
+ totalCents: 1000,
+ termsVersion: new Date("2026-01-01T00:00:00.000Z"),
+ lines: [],
+ },
+ { ...format, locale: "pl-PL", timeZone: "America/Los_Angeles" },
+ );
+ expect(props.termsVersionLabel).toBe("1 stycznia 2026");
+ });
+ });
});
describe("shipped", () => {
diff --git a/tests/db/admin-orders.db.test.ts b/tests/db/admin-orders.db.test.ts
index fba1766..c4e9b6a 100644
--- a/tests/db/admin-orders.db.test.ts
+++ b/tests/db/admin-orders.db.test.ts
@@ -196,6 +196,21 @@ describe("getAdminOrder", () => {
expect(detail?.events.map((event) => event.type)).toEqual(["created"]);
});
+ // spec 0017, AC-13.
+ it("returns the accepted terms version and time, or null when none was recorded", async () => {
+ const { order } = await seedPendingOrder();
+ expect((await getAdminOrder("1001"))?.terms).toBeNull();
+
+ const version = new Date("2026-10-20T00:00:00.000Z");
+ const acceptedAt = new Date("2026-10-20T21:30:00.000Z");
+ await testDb.order.update({
+ where: { id: order.id },
+ data: { termsVersion: version, termsAcceptedAt: acceptedAt },
+ });
+
+ expect((await getAdminOrder("1001"))?.terms).toEqual({ version, acceptedAt });
+ });
+
it("shows the name and SKU the customer bought, even after the catalog changes", async () => {
const { product, variant } = await seedPendingOrder();
const bought = { productName: product.name, sku: variant.sku };
diff --git a/tests/db/discounts-checkout.db.test.ts b/tests/db/discounts-checkout.db.test.ts
index 493ff3b..bbd3c4a 100644
--- a/tests/db/discounts-checkout.db.test.ts
+++ b/tests/db/discounts-checkout.db.test.ts
@@ -68,6 +68,7 @@ vi.mock("@/lib/customer/session", () => ({
}));
const { startCheckout } = await import("@/features/checkout/actions/start-checkout");
+const { currentTermsVersion } = await import("@/lib/legal/documents");
const { applyDiscountCode } = await import("@/features/checkout/actions/apply-discount");
const { prefillDiscountCode } = await import("@/features/checkout/queries");
const { countRedemptions, lockCode, previewDiscount, redeem } =
@@ -99,6 +100,8 @@ const address = {
postalCode: "00950",
city: "Warsaw",
phone: "",
+ acceptTerms: true,
+ termsVersion: currentTermsVersion,
};
function pay(discountCode?: string, email = "a@example.com") {
diff --git a/tests/db/order-emails.db.test.ts b/tests/db/order-emails.db.test.ts
index d2dfa3b..a969b23 100644
--- a/tests/db/order-emails.db.test.ts
+++ b/tests/db/order-emails.db.test.ts
@@ -49,6 +49,7 @@ vi.mock("@/lib/env", () => ({
EMAIL_TRANSPORT: "log",
EMAIL_FROM: "BeStore ",
EMAIL_FROM_ADDRESS: "orders@example.com",
+ NEXT_PUBLIC_SITE_URL: "https://shop.example",
},
}));
vi.mock("@/lib/stripe", async () => ({
@@ -225,6 +226,29 @@ describe("the confirmation (AC-1, AC-8, AC-16)", () => {
expect(scheduledIds()).toEqual([]);
});
+ // spec 0017, AC-14: the version comes from the order row, formatted at send time.
+ it.each([
+ ["names the accepted terms version", new Date("2026-10-20T00:00:00.000Z"), true],
+ ["leaves the version out for an order without one", null, false],
+ ])("%s, with the legal links", async (_, termsVersion, named) => {
+ const { order } = await seedPendingOrder();
+ await testDb.order.update({
+ where: { id: order.id },
+ data: { termsVersion, termsAcceptedAt: termsVersion === null ? null : new Date() },
+ });
+ await post(
+ sessionEvent("checkout.session.completed", { orderId: order.id, amountTotal: 5000 }),
+ );
+ const { transport, sent } = stubTransport();
+
+ await deliver((await onlyEmail(order.id)).id, transport);
+
+ expect(sent[0]?.text.includes("Terms of sale version October 20, 2026 applied")).toBe(named);
+ expect(sent[0]?.html).toContain('href="https://shop.example/terms"');
+ expect(sent[0]?.html).toContain('href="https://shop.example/returns"');
+ expect(sent[0]?.html).toContain('href="https://shop.example/privacy"');
+ });
+
it("queues nothing and schedules nothing when the paid transaction rolls back", async () => {
const { order } = await seedPendingOrder();
mocks.failAfterPaid = true;
diff --git a/tests/db/purge-expired-orders.db.test.ts b/tests/db/purge-expired-orders.db.test.ts
index 89df7f4..704cd0f 100644
--- a/tests/db/purge-expired-orders.db.test.ts
+++ b/tests/db/purge-expired-orders.db.test.ts
@@ -80,6 +80,9 @@ async function seedOrder(
shipCountryCode: "FR",
stripeCheckoutSessionId: `cs_test_${order.number}`,
stripePaymentIntentId: `pi_test_${order.number}`,
+ // spec 0017, AC-11: the accepted terms are not personal data, so the purge keeps them.
+ termsVersion: new Date("2026-10-20T00:00:00.000Z"),
+ termsAcceptedAt: new Date("2026-10-20T09:15:00.000Z"),
},
});
}
@@ -127,6 +130,8 @@ const keptFields = [
"stripePaymentIntentId",
"needsAttention",
"expiredAt",
+ "termsVersion",
+ "termsAcceptedAt",
"lines",
"events",
] as const;
diff --git a/tests/db/start-checkout.db.test.ts b/tests/db/start-checkout.db.test.ts
index 13c6d25..6a7f7b0 100644
--- a/tests/db/start-checkout.db.test.ts
+++ b/tests/db/start-checkout.db.test.ts
@@ -68,6 +68,7 @@ vi.mock("@/lib/customer/session", () => ({
}));
const { startCheckout } = await import("@/features/checkout/actions/start-checkout");
+const { currentTermsVersion } = await import("@/lib/legal/documents");
resetDatabaseBeforeEach();
@@ -92,6 +93,9 @@ const address = {
postalCode: "00950",
city: "Warsaw",
phone: "",
+ // spec 0017, AC-9: the ticked box and the version the page showed.
+ acceptTerms: true,
+ termsVersion: currentTermsVersion,
};
// What the checkout form sends: the email plus a valid delivery address.
@@ -398,6 +402,108 @@ describe("delivery (spec 0007)", () => {
});
});
+// spec 0017, AC-9 and AC-11.
+describe("terms of sale", () => {
+ it("records the accepted version and the time of Pay on the new order", async () => {
+ const { variant } = await createSimpleProduct("a", 5);
+ await cartWith([{ variantId: variant.id, quantity: 1 }]);
+ const before = Date.now();
+
+ expect((await startCheckout(checkout())).ok).toBe(true);
+
+ const order = await testDb.order.findFirstOrThrow();
+ expect(order.termsVersion?.toISOString()).toBe(`${currentTermsVersion}T00:00:00.000Z`);
+ expect(order.termsAcceptedAt?.getTime()).toBeGreaterThanOrEqual(before - 1000);
+ expect(order.termsAcceptedAt?.getTime()).toBeLessThanOrEqual(Date.now() + 1000);
+ });
+
+ it("gives a replaced checkout's new order its own acceptance time", async () => {
+ const { variant } = await createSimpleProduct("a", 5);
+ await cartWith([{ variantId: variant.id, quantity: 1 }]);
+ await startCheckout(checkout());
+ const first = await testDb.order.findFirstOrThrow();
+ mocks.retrieve.mockResolvedValueOnce({
+ id: first.stripeCheckoutSessionId,
+ status: "open",
+ payment_status: "unpaid",
+ });
+
+ expect((await startCheckout(checkout())).ok).toBe(true);
+
+ const second = await testDb.order.findFirstOrThrow({ where: { id: { not: first.id } } });
+ expect(second.termsVersion).toEqual(first.termsVersion);
+ expect(second.termsAcceptedAt!.getTime()).toBeGreaterThanOrEqual(
+ first.termsAcceptedAt!.getTime(),
+ );
+ });
+
+ it.each([
+ ["unticked", { acceptTerms: false }],
+ ["missing", { acceptTerms: undefined }],
+ ])("refuses the box %s with the field message, before any database work", async (_, terms) => {
+ const { variant } = await createSimpleProduct("a", 5);
+ await cartWith([{ variantId: variant.id, quantity: 1 }]);
+
+ expect(await startCheckout(checkout("a@example.com", terms))).toEqual({
+ ok: false,
+ error: {
+ code: "validation",
+ fields: { acceptTerms: "Tick the box to accept the terms of sale." },
+ },
+ });
+ expect(await testDb.order.count()).toBe(0);
+ expect(mocks.create).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["an older version", "2020-01-01"],
+ ["no version", undefined],
+ ["a version that is not a date", "latest"],
+ ])("answers terms_changed for %s and creates no order", async (_, termsVersion) => {
+ const { variant } = await createSimpleProduct("a", 5);
+ const cart = await cartWith([{ variantId: variant.id, quantity: 1 }]);
+
+ expect(await startCheckout(checkout("a@example.com", { termsVersion }))).toEqual({
+ ok: false,
+ error: { code: "terms_changed", currentVersion: currentTermsVersion },
+ });
+ expect(await testDb.order.count()).toBe(0);
+ expect(mocks.create).not.toHaveBeenCalled();
+ // No lock was taken: the cart is free for the next Pay.
+ expect((await startCheckout(checkout())).ok).toBe(true);
+ expect(await testDb.order.count({ where: { cartId: cart.id } })).toBe(1);
+ });
+
+ it("logs a terms_changed refusal by its code only", async () => {
+ const { variant } = await createSimpleProduct("a", 5);
+ await cartWith([{ variantId: variant.id, quantity: 1 }]);
+
+ await startCheckout(checkout("a@example.com", { termsVersion: "2020-01-01" }));
+
+ expect(mocks.info).toHaveBeenCalledWith(
+ { event: "checkout.refused", reason: "terms_changed" },
+ "checkout.refused",
+ );
+ });
+
+ it("keeps the two columns together with a CHECK", async () => {
+ const { variant } = await createSimpleProduct("a", 5);
+ await cartWith([{ variantId: variant.id, quantity: 1 }]);
+ await startCheckout(checkout());
+ const order = await testDb.order.findFirstOrThrow();
+
+ await expect(
+ testDb.$executeRaw`UPDATE orders SET terms_accepted_at = NULL WHERE id = ${order.id}::uuid`,
+ ).rejects.toThrow(/orders_terms_acceptance_check/);
+ await expect(
+ testDb.$executeRaw`UPDATE orders SET terms_version = NULL WHERE id = ${order.id}::uuid`,
+ ).rejects.toThrow(/orders_terms_acceptance_check/);
+ // Both null, as on an order from before the feature, is allowed.
+ await testDb.$executeRaw`
+ UPDATE orders SET terms_version = NULL, terms_accepted_at = NULL WHERE id = ${order.id}::uuid`;
+ });
+});
+
describe("refusals create nothing", () => {
it("refuses an invalid email with a field error", async () => {
const { variant } = await createSimpleProduct("a", 5);
diff --git a/tests/e2e/account/checkout.spec.ts b/tests/e2e/account/checkout.spec.ts
index 0409ff9..9307549 100644
--- a/tests/e2e/account/checkout.spec.ts
+++ b/tests/e2e/account/checkout.spec.ts
@@ -9,6 +9,7 @@ import {
payButton,
postPaidEvent,
seedPendingOrder,
+ termsBox,
} from "../checkout/support";
import { signInAsCustomer } from "./support";
@@ -83,6 +84,7 @@ test("a signed in Pay goes to Stripe with the account on the order and the addre
await page.getByLabel("Address line 1").fill("ul. Marszałkowska 1");
await page.getByLabel("Postal code").fill("00950");
await page.getByLabel("City").fill("Warsaw");
+ await termsBox(page).check();
await payButton(page).click();
await page.waitForURL(/^https:\/\/checkout\.stripe\.com\//, { timeout: 30_000 });
diff --git a/tests/e2e/checkout/support.ts b/tests/e2e/checkout/support.ts
index 140aa75..6c53c4b 100644
--- a/tests/e2e/checkout/support.ts
+++ b/tests/e2e/checkout/support.ts
@@ -51,6 +51,12 @@ export async function fillCheckout(page: Page, email = "e2e.customer@example.com
await page.getByLabel("Address line 1").fill(testAddress.line1);
await page.getByLabel("Postal code").fill(testAddress.postalCode);
await page.getByLabel("City").fill(testAddress.city);
+ await termsBox(page).check();
+}
+
+// spec 0017, AC-8: the box every Pay needs ticked.
+export function termsBox(page: Page) {
+ return page.getByRole("checkbox", { name: /^I accept the terms of sale/ });
}
// The Pay button whatever the delivery fee: the store_settings row is shared by every spec file
diff --git a/tests/e2e/legal/legal-pages.spec.ts b/tests/e2e/legal/legal-pages.spec.ts
new file mode 100644
index 0000000..f3824e8
--- /dev/null
+++ b/tests/e2e/legal/legal-pages.spec.ts
@@ -0,0 +1,269 @@
+import { randomUUID } from "node:crypto";
+
+import { expect, type Page, type Request, test } from "@playwright/test";
+
+import { expectNoA11yViolations } from "../a11y";
+import { createTestUser, signInFully, withDb } from "../admin/support";
+import { seedProduct } from "../catalog/support";
+import {
+ addToCart,
+ cartIdOf,
+ fillCheckout,
+ hasRealStripeKey,
+ payButton,
+ seedPendingOrder,
+ termsBox,
+} from "../checkout/support";
+
+// spec 0017: the five legal pages, the footer links, the checkout terms box and its record on
+// the order, the sign up notice (AC-1 to AC-3, AC-6, AC-8 to AC-13).
+
+const pages = [
+ { path: "/terms", title: "Terms of sale", link: "Terms of sale" },
+ { path: "/privacy", title: "Privacy policy", link: "Privacy policy" },
+ { path: "/returns", title: "Returns", link: "Returns" },
+ { path: "/shipping", title: "Shipping", link: "Shipping" },
+ { path: "/cookies", title: "Cookie policy", link: "Cookie policy" },
+] as const;
+
+const siteUrl = "http://localhost:3000";
+
+function footer(page: Page) {
+ return page.getByRole("navigation", { name: "Footer" });
+}
+
+for (const legal of pages) {
+ test(`${legal.path} shows its title, version date and text, and can be indexed`, async ({
+ page,
+ }) => {
+ const response = await page.goto(legal.path);
+
+ expect(response?.status()).toBe(200);
+ expect(response?.headers()["x-robots-tag"]).toBeUndefined();
+ await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1);
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText(legal.title);
+ await expect(page.getByText(/^Last updated \w+ \d{1,2}, \d{4}$/)).toBeVisible();
+ await expect(page).toHaveTitle(new RegExp(`^${legal.title} · `));
+ await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
+ "href",
+ `${siteUrl}${legal.path}`,
+ );
+ await expect(page.locator('meta[name="description"]')).toHaveAttribute("content", /\w/);
+ await expect(page.locator('meta[name="robots"]')).toHaveCount(0);
+ // No placeholder ever reaches the page.
+ await expect(page.locator("main")).not.toContainText("{{");
+ await expectNoA11yViolations(page);
+ });
+}
+
+test("every store footer links the legal pages in order, then Cookie settings", async ({
+ page,
+}) => {
+ for (const path of ["/", "/cart", "/no-such-page-here"]) {
+ await page.goto(path);
+ const links = footer(page).getByRole("listitem");
+ await expect(links).toHaveText([...pages.map((legal) => legal.link), "Cookie settings"]);
+ }
+
+ await page.goto("/");
+ await footer(page).getByRole("link", { name: "Returns" }).click();
+ await expect(page).toHaveURL("/returns");
+ await expect(page.getByRole("heading", { level: 1, name: "Returns" })).toBeVisible();
+});
+
+test("the returns page ends with the model withdrawal form", async ({ page }) => {
+ await page.goto("/returns");
+
+ const heading = page.getByRole("heading", { level: 2, name: "Model withdrawal form" });
+ await expect(heading).toBeVisible();
+ await expect(page.locator("main blockquote")).toContainText("I/We (*) hereby give notice");
+ await expect(page.getByText(/You can print it, or copy it into an email to/)).toBeVisible();
+});
+
+test("printing a legal page prints only its text", async ({ page }) => {
+ await page.goto("/terms");
+ await page.emulateMedia({ media: "print" });
+
+ await expect(page.getByRole("banner")).toBeHidden();
+ await expect(page.getByRole("contentinfo")).toBeHidden();
+ await expect(page.getByRole("heading", { level: 1, name: "Terms of sale" })).toBeVisible();
+});
+
+test("the admin panel shows no legal footer and no consent banner", async ({ page }) => {
+ const admin = await createTestUser({ enrolled: true });
+ await signInFully(page, admin);
+
+ await page.goto("/admin");
+ await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
+ await expect(footer(page)).toHaveCount(0);
+ await expect(page.getByRole("link", { name: "Terms of sale" })).toHaveCount(0);
+ await expect(page.getByRole("region", { name: "Cookie consent" })).toHaveCount(0);
+});
+
+test("sign up says the terms and privacy policy apply, with links that open a new tab", async ({
+ page,
+}) => {
+ await page.goto("/account/sign-up");
+
+ await expect(
+ page.getByText("By creating an account you accept the terms of sale and confirm"),
+ ).toBeVisible();
+ const terms = page.getByRole("link", { name: "terms of sale, opens in a new tab" });
+ await expect(terms).toHaveAttribute("href", "/terms");
+ await expect(terms).toHaveAttribute("target", "_blank");
+ await expect(
+ page.getByRole("link", { name: "privacy policy, opens in a new tab" }),
+ ).toHaveAttribute("href", "/privacy");
+});
+
+// The server action Pay calls: a POST to /checkout carrying the Next-Action header.
+function isPay(request: Request): boolean {
+ return request.method() === "POST" && request.headers()["next-action"] !== undefined;
+}
+
+test.describe("the checkout terms box", () => {
+ test.beforeEach(async ({ page }) => {
+ const socks = await seedProduct({ stock: 5, priceCents: 1250 });
+ await addToCart(page, socks);
+ await page.goto("/checkout");
+ });
+
+ test("is unticked, links the texts in a new tab, and Pay without it sends nothing", async ({
+ page,
+ }) => {
+ await expect(termsBox(page)).not.toBeChecked();
+ for (const name of [
+ "terms of sale, opens in a new tab",
+ "privacy policy, opens in a new tab",
+ "returns, opens in a new tab",
+ "shipping, opens in a new tab",
+ ]) {
+ await expect(page.getByRole("link", { name })).toHaveAttribute("target", "_blank");
+ }
+
+ await fillCheckout(page);
+ await termsBox(page).uncheck();
+ const sent: Request[] = [];
+ page.on("request", (request) => {
+ if (isPay(request)) sent.push(request);
+ });
+ await expect(payButton(page)).toBeEnabled();
+ await payButton(page).click();
+
+ await expect(page.getByText("Tick the box to accept the terms of sale.")).toBeVisible();
+ await expect(termsBox(page)).toBeFocused();
+ await expect(termsBox(page)).toHaveAttribute("aria-invalid", "true");
+ await expect(payButton(page)).toBeEnabled();
+ expect(sent).toEqual([]);
+ await expectNoA11yViolations(page);
+ });
+
+ test("asks again when the terms changed since the page loaded, keeping every field", async ({
+ page,
+ }) => {
+ // Unique per run: the local database keeps orders from earlier runs.
+ const email = `terms.changed.${randomUUID()}@example.com`;
+ await fillCheckout(page, email);
+ // Stand in for terms that changed after this page loaded: the first Pay carries an old
+ // version, as a page loaded before the change would.
+ let first = true;
+ await page.route("**/checkout", async (route) => {
+ const request = route.request();
+ if (!isPay(request) || !first) return route.continue();
+ first = false;
+ const body = request.postData() ?? "";
+ expect(body).toMatch(/"termsVersion":"\d{4}-\d{2}-\d{2}"/);
+ await route.continue({
+ postData: body.replace(/"termsVersion":"[^"]*"/, '"termsVersion":"2020-01-01"'),
+ });
+ });
+
+ await payButton(page).click();
+
+ const alert = page.getByRole("alert").filter({ hasText: "Our terms of sale changed on" });
+ await expect(alert).toContainText(/changed on \w+ \d{1,2}, \d{4}\. Please read them/);
+ await expect(alert.getByRole("link", { name: "them, opens in a new tab" })).toHaveAttribute(
+ "href",
+ "/terms",
+ );
+ await expect(termsBox(page)).not.toBeChecked();
+ await expect(termsBox(page)).toBeFocused();
+ await expect(page.getByLabel("Email")).toHaveValue(email);
+ await expect(page.getByLabel("City")).toHaveValue("Warsaw");
+ expect(
+ await withDb((db) => db.query("SELECT 1 FROM orders WHERE email = $1", [email])).then(
+ (r) => r.rowCount,
+ ),
+ ).toBe(0);
+ await expectNoA11yViolations(page);
+
+ // The next Pay with the box ticked sends the current version.
+ await termsBox(page).check();
+ const retry = page.waitForRequest(isPay);
+ await payButton(page).click();
+ expect((await retry).postData()).not.toContain('"termsVersion":"2020-01-01"');
+ await expect(alert).toHaveCount(0);
+ });
+});
+
+test("a ticked Pay records the terms version on the order, shown to the admin", async ({
+ page,
+ browser,
+}) => {
+ test.skip(!hasRealStripeKey, "needs a real Stripe test key (STRIPE_SECRET_KEY)");
+ const socks = await seedProduct({ stock: 5, priceCents: 1250 });
+ await addToCart(page, socks);
+ await page.goto("/checkout");
+ const email = `terms.accepted.${randomUUID()}@example.com`;
+ await fillCheckout(page, email);
+ await payButton(page).click();
+ await page.waitForURL(/^https:\/\/checkout\.stripe\.com\//, { timeout: 30_000 });
+
+ const row = await withDb((db) =>
+ db.query<{ number: number; version: string; accepted: boolean }>(
+ `SELECT number, terms_version::text AS version, terms_accepted_at IS NOT NULL AS accepted
+ FROM orders WHERE email = $1`,
+ [email],
+ ),
+ );
+ expect(row.rows[0]).toMatchObject({
+ version: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
+ accepted: true,
+ });
+
+ const admin = await createTestUser({ enrolled: true });
+ const adminPage = await (await browser.newContext()).newPage();
+ await signInFully(adminPage, admin);
+ await adminPage.goto(`/admin/orders/${row.rows[0]!.number}`);
+ await expect(adminPage.getByText(/^version \w+ \d{1,2}, \d{4}, on /)).toBeVisible();
+});
+
+test("an order from before the feature reads 'not recorded' on the admin page", async ({
+ page,
+ browser,
+}) => {
+ const socks = await seedProduct({ stock: 5, priceCents: 1250 });
+ await addToCart(page, socks);
+ const order = await seedPendingOrder(await cartIdOf(page), socks, {
+ quantity: 1,
+ priceCents: 1250,
+ });
+
+ const admin = await createTestUser({ enrolled: true });
+ const adminPage = await (await browser.newContext()).newPage();
+ await signInFully(adminPage, admin);
+ await adminPage.goto(`/admin/orders/${order.number}`);
+ const details = adminPage.locator("dl").filter({ hasText: "Terms accepted" });
+ await expect(details.getByText("not recorded")).toBeVisible();
+
+ await withDb((db) =>
+ db.query(
+ `UPDATE orders SET terms_version = '2026-10-20', terms_accepted_at = '2026-10-20T21:30:00Z'
+ WHERE id = $1`,
+ [order.id],
+ ),
+ );
+ await adminPage.reload();
+ // The version is a calendar day; the acceptance time is in the store's time zone.
+ await expect(details.getByText(/^version October 20, 2026, on Oct 2[01], 2026/)).toBeVisible();
+});
From 4646174c7f062b2b906917dc1c1cf62158d8f56e Mon Sep 17 00:00:00 2001
From: kris1027
Date: Tue, 20 Oct 2026 19:38:05 +0200
Subject: [PATCH 04/27] feat(consent): consent cookie, registry, banner and
settings dialog
Co-Authored-By: Claude Opus 5.5
---
app/(store)/cookies/page.tsx | 16 +
app/(store)/layout.tsx | 42 +-
src/components/layout/store-shell.test.ts | 36 +-
src/components/layout/store-shell.tsx | 27 +-
.../style-guide/components/consent-demo.tsx | 60 +++
.../components/style-guide-page.tsx | 367 +++++++++---------
src/lib/cart/cookie-name.ts | 3 +
src/lib/cart/cookie.test.ts | 4 +-
src/lib/cart/cookie.ts | 3 +-
src/lib/consent/consent-banner.tsx | 71 ++++
src/lib/consent/consent-dialog.tsx | 181 +++++++++
src/lib/consent/consent-gate.tsx | 17 +
src/lib/consent/consent-provider.tsx | 208 ++++++++++
src/lib/consent/consent.test.ts | 178 +++++++++
src/lib/consent/consent.ts | 172 ++++++++
src/lib/consent/cookie-settings-control.tsx | 40 ++
src/lib/consent/registry.test.ts | 95 +++++
src/lib/consent/registry.ts | 50 +++
tests/e2e/legal/consent.spec.ts | 260 +++++++++++++
19 files changed, 1627 insertions(+), 203 deletions(-)
create mode 100644 app/(store)/cookies/page.tsx
create mode 100644 src/features/style-guide/components/consent-demo.tsx
create mode 100644 src/lib/cart/cookie-name.ts
create mode 100644 src/lib/consent/consent-banner.tsx
create mode 100644 src/lib/consent/consent-dialog.tsx
create mode 100644 src/lib/consent/consent-gate.tsx
create mode 100644 src/lib/consent/consent-provider.tsx
create mode 100644 src/lib/consent/consent.test.ts
create mode 100644 src/lib/consent/consent.ts
create mode 100644 src/lib/consent/cookie-settings-control.tsx
create mode 100644 src/lib/consent/registry.test.ts
create mode 100644 src/lib/consent/registry.ts
create mode 100644 tests/e2e/legal/consent.spec.ts
diff --git a/app/(store)/cookies/page.tsx b/app/(store)/cookies/page.tsx
new file mode 100644
index 0000000..9442b6b
--- /dev/null
+++ b/app/(store)/cookies/page.tsx
@@ -0,0 +1,16 @@
+import { CookieTable } from "@/features/legal/components/cookie-table";
+import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page";
+import { cookieRegistry } from "@/lib/consent/registry";
+import { CookieSettingsButton } from "@/lib/consent/cookie-settings-control";
+
+export const metadata = legalMetadata("cookies");
+
+// spec 0017, AC-16 and AC-17: the policy, the registry table, and the settings button.
+export default function CookiesPage() {
+ return (
+
+
+
+
+ );
+}
diff --git a/app/(store)/layout.tsx b/app/(store)/layout.tsx
index 3c10c80..30ed794 100644
--- a/app/(store)/layout.tsx
+++ b/app/(store)/layout.tsx
@@ -6,27 +6,35 @@ import { CartLink, CartLinkView } from "@/features/cart/components/cart-link";
import { HeaderSearch } from "@/features/catalog/listing/components/search-box";
import { categoryPath } from "@/features/categories/paths";
import { getNavCategories } from "@/features/categories/queries";
+import { ConsentBanner } from "@/lib/consent/consent-banner";
+import { ConsentProvider } from "@/lib/consent/consent-provider";
+import { cookieRegistry } from "@/lib/consent/registry";
+import { cookiePolicyVersion } from "@/lib/legal/documents";
// The nav is a cached read tagged `categories`, so the shell stays static (spec 0013, AC-3).
+// Consent is read in the browser only (spec 0017, AC-22), which keeps it static too.
export default async function StoreLayout({ children }: LayoutProps<"/">) {
const categories = await getNavCategories();
return (
- ({
- href: categoryPath(category.slug),
- label: category.name,
- }))}
- actions={
- <>
-
-
- }>
-
-
- >
- }
- >
- {children}
-
+
+ }
+ navItems={categories.map((category) => ({
+ href: categoryPath(category.slug),
+ label: category.name,
+ }))}
+ actions={
+ <>
+
+
+ }>
+
+
+ >
+ }
+ >
+ {children}
+
+
);
}
diff --git a/src/components/layout/store-shell.test.ts b/src/components/layout/store-shell.test.ts
index 18a2bfe..5c3394d 100644
--- a/src/components/layout/store-shell.test.ts
+++ b/src/components/layout/store-shell.test.ts
@@ -61,7 +61,24 @@ describe("StoreShell", () => {
describe("footer links", () => {
it("renders no footer nav when there are no links", () => {
- expect(render()).not.toContain('aria-label="Footer"');
+ expect(render({ footerLinks: [] })).not.toContain('aria-label="Footer"');
+ });
+
+ // spec 0017, AC-3 and AC-16: the legal pages by default, in order, then Cookie settings,
+ // a plain link to /cookies with no ConsentProvider above (and in the server HTML).
+ it("lists the legal pages by default, then Cookie settings", () => {
+ const links = [...footer(render()).matchAll(/]*href="([^"]+)"[^>]*>([^<]+) [match[1], match[2]])
+ .filter(([href]) => !href?.startsWith("mailto:") && href !== "/");
+
+ expect(links).toEqual([
+ ["/terms", "Terms of sale"],
+ ["/privacy", "Privacy policy"],
+ ["/returns", "Returns"],
+ ["/shipping", "Shipping"],
+ ["/cookies", "Cookie policy"],
+ ["/cookies", "Cookie settings"],
+ ]);
});
it("renders each link from the footerLinks prop in a labelled nav", () => {
@@ -83,6 +100,23 @@ describe("StoreShell", () => {
expect(render({ navItems: [] })).not.toContain("Open menu");
});
+ // spec 0017, AC-19: the consent bar comes right after the skip link in the DOM.
+ it("places the banner right after the skip link", () => {
+ const html = render({ banner: createElement("section", { "aria-label": "Cookie consent" }) });
+
+ expect(html).toMatch(
+ /Skip to content<\/a><\/section> {
+ const html = render();
+
+ expect(html).toMatch(/ {
const html = render({ actions: createElement("button", { type: "button" }, "Cart") });
diff --git a/src/components/layout/store-shell.tsx b/src/components/layout/store-shell.tsx
index 4a86351..c7b86a0 100644
--- a/src/components/layout/store-shell.tsx
+++ b/src/components/layout/store-shell.tsx
@@ -2,7 +2,9 @@ import Link from "next/link";
import { Suspense } from "react";
import { Wordmark } from "@/components/wordmark";
+import { legalFooterLinks } from "@/features/legal/components/footer-links";
import { brand } from "@/lib/brand/brand";
+import { CookieSettingsControl } from "@/lib/consent/cookie-settings-control";
import { cn } from "@/lib/utils";
import { storeContainer } from "./container";
@@ -11,11 +13,17 @@ import type { NavItem } from "./nav";
import { StoreDesktopNav, StoreMobileNav } from "./store-nav-menu";
import { storeNavItems } from "./store-nav";
+const footerLinkClass =
+ "inline-flex min-h-6 cursor-pointer items-center text-sm text-muted-foreground hover:text-foreground";
+
type StoreShellProps = {
// Header actions (search, account, cart), filled by features 13, 12 and 6.
readonly actions?: React.ReactNode;
- // Footer links (legal pages, feature 16).
+ // Footer links; the legal pages by default (spec 0017, AC-3), demo links in the style guide.
readonly footerLinks?: readonly NavItem[];
+ // The cookie consent bar, right after the skip link (spec 0017, AC-19). Only the store layout
+ // and the style guide demo pass one.
+ readonly banner?: React.ReactNode;
// Defaults to the real list; the style guide passes demo links.
readonly navItems?: readonly NavItem[];
readonly children: React.ReactNode;
@@ -23,7 +31,8 @@ type StoreShellProps = {
export function StoreShell({
actions,
- footerLinks = [],
+ footerLinks = legalFooterLinks,
+ banner,
navItems = storeNavItems,
children,
}: StoreShellProps) {
@@ -35,7 +44,9 @@ export function StoreShell({
>
Skip to content
-
+ {banner}
+ {/* Printing a legal page prints only its text (AC-3). */}
+
{/* The navs read the pathname, which a route with unknown params (a product page)
@@ -56,7 +67,7 @@ export function StoreShell({
{children}
-