diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dfcb715..e408556 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,6 +32,8 @@ jobs:
- run: pnpm format:check
- run: pnpm typecheck
- run: pnpm test
+ # spec 0017, AC-4: a blank or unknown placeholder in a legal text fails `next build`.
+ - run: pnpm test:build
db:
runs-on: ubuntu-latest
diff --git a/AGENTS.md b/AGENTS.md
index badc42a..919b07c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -38,6 +38,7 @@ pnpm lint && pnpm typecheck
pnpm format # Prettier; format:check in CI
pnpm test # Vitest unit + integration (*.test.ts beside the source)
pnpm test:db # Vitest db project against TEST_DATABASE_URL (needs pnpm db:start)
+pnpm test:build # Vitest build project: next build on a copy of the app (tests/build/)
pnpm test:e2e # Playwright e2e (tests/e2e/*.spec.ts)
```
@@ -57,6 +58,7 @@ Stored in `docs/specs/`. Format: `docs/specs/NNNN-title/index.md`. Scope lives i
- **Money and time**: integer minor units (cents) in `Int` columns, never floats; timestamps in UTC `timestamptz`, shown in `STORE_TIMEZONE`.
- **Caching**: `cacheComponents` is on. Catalog reads are `'use cache'` functions tagged from `src/lib/cache-tags.ts`; whatever changes what they return must expire the tag (`updateTag` in a server action, `revalidateTag(tag, { expire: 0 })` in a route handler). Request data (cookies, the cart) sits behind Suspense.
- **Status codes**: under Cache Components a page's `notFound()` / `redirect()` answer 200 (the shell streams first). Where the real status matters, decide it in the proxy before rendering (the admin gate does this).
+- **Cookies and storage**: every new cookie or web storage key goes in the consent registry (`src/lib/consent/registry.ts`; its test catches unlisted cookies, not storage keys); anything not strictly necessary runs only behind `ConsentGate` / `useConsent()`. Rendering a page never sets a cookie.
- **Cron**: handlers under `app/api/cron/` refuse anything but `Authorization: Bearer ${CRON_SECRET}` (constant time compare); schedules live in `vercel.json`.
- **Env**: every variable the app reads is added to the Zod schema in `src/lib/env.ts` and to `.env.example`. A variable only a CLI reads (e.g. `SEND_EMAIL_HOOK_URI`, read by the Supabase CLI through `env()` in `supabase/config.toml`) goes in `.env.example` alone, marked as such.
- **UI**: WCAG AA baseline on storefront and admin (labels, keyboard use, visible focus, contrast).
@@ -69,7 +71,7 @@ Stored in `docs/specs/`. Format: `docs/specs/NNNN-title/index.md`. Scope lives i
Installed:
- ESLint (Next.js config, `@typescript-eslint/no-explicit-any: error`) + Prettier with `prettier-plugin-tailwindcss`; scripts `format`, `format:check`, `test`, `test:e2e`. Prettier skips `*.md` on purpose (skills and `next dev` own those files).
- Pre commit (simple-git-hooks + lint-staged): lint and format staged files, then `pnpm typecheck`. `SKIP_SIMPLE_GIT_HOOKS=1` bypasses it in an emergency.
-- CI: [.github/workflows/ci.yml](.github/workflows/ci.yml), on pushes to `main` and on every PR, as parallel jobs: `static` (lint, format check, typecheck, unit), `db` (`test:db`), `e2e` (4 Playwright shards, each with its own Supabase stack and build; shard 1 also runs `test:stack`), and a `check` gate that needs them all (the ruleset's required check, so it keeps that name).
+- CI: [.github/workflows/ci.yml](.github/workflows/ci.yml), on pushes to `main` and on every PR, as parallel jobs: `static` (lint, format check, typecheck, unit, build guard), `db` (`test:db`), `e2e` (4 Playwright shards, each with its own Supabase stack and build; shard 1 also runs `test:stack`), and a `check` gate that needs them all (the ruleset's required check, so it keeps that name).
## Git
@@ -105,5 +107,8 @@ MCP servers: Supabase (connected), Stripe (connected), Vercel (connected), Playw
- [src/features/settings/AGENTS.md](src/features/settings/AGENTS.md): the admin settings page, the shipping fee and free delivery threshold
- [src/features/account/AGENTS.md](src/features/account/AGENTS.md): customer accounts, sign up and sign in, the customer session and lock, order history, the address book, settings
- [src/lib/shipping/AGENTS.md](src/lib/shipping/AGENTS.md): the shared delivery fee rule, address schema and settings reads
+- [src/lib/legal/AGENTS.md](src/lib/legal/AGENTS.md): the five legal texts as bundled Markdown, their versions, placeholders and the production blank guard
+- [src/lib/consent/AGENTS.md](src/lib/consent/AGENTS.md): the consent cookie, cookie registry, `ConsentProvider` / `useConsent` / `ConsentGate`, banner and settings dialog
+- [src/features/legal/AGENTS.md](src/features/legal/AGENTS.md): the storefront legal pages, their Markdown renderer and the footer links
_Drafted by /audit from the repo, worth a quick human pass. Edit freely: once a line stops matching this draft, later runs treat it as curated and will flag rather than overwrite it._
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/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/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/docs/scope/scope.md b/docs/scope/scope.md
index bfac72e..c00fd9d 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 | done |
| 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 · done
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)
+- [x] Verify it: `/check verify legal pages & cookie consent`
+- [x] 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..a30c50c
--- /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**: Accepted
+
+## 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 store ones (`{{store.country}}`, `{{store.currency}}`) from `STORE_COUNTRY` (its display name in `STORE_LOCALE`) and `STORE_CURRENCY`; 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 }`; so does a missing or malformed one, since the customer never saw the current text (it is not a field they type, so `validation` would have nothing to point at). 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) a line for Stripe's own cookies on its hosted payment page (set on Stripe's domain, not ours) and `sidebar_state` (the admin panel's sidebar, staff only). `/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`, `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 in `next.config.ts` running `scripts/raw-text-loader.cjs` on `src/lib/legal/content/*.md`, 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. Turbopack's own `raw` module type was tried first and dropped: it exports nothing a JavaScript import can read.
+
+**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/lib/legal/footer-links.ts` (shared code, so the shell imports no feature) 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 update that clears one column of a created order, and the purge db test (`purge-expired`) asserts both columns survive a purge.
+6. **Raw Markdown.** `next.config.ts` gains a `turbopack.rules` entry running `scripts/raw-text-loader.cjs` (`export default `, `as: "*.js"`) on only `src/lib/legal/content/*.md` (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.
diff --git a/docs/specs/0017-legal-pages-cookie-consent/verify.md b/docs/specs/0017-legal-pages-cookie-consent/verify.md
new file mode 100644
index 0000000..a2de5ef
--- /dev/null
+++ b/docs/specs/0017-legal-pages-cookie-consent/verify.md
@@ -0,0 +1,49 @@
+# Verify: Legal pages & cookie consent · spec 0017 · updated 2026-10-20
+_Steps derived from spec 0017 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._
+
+## UI / manual
+- [x] Open `/terms`, `/privacy`, `/returns`, `/shipping`, `/cookies` → one `h1` with the front matter title, "Last updated October 20, 2026", the Markdown body, no `{{` text → AC-1
+- [x] View source of each legal page → `` is the front matter title, `meta description` set, `link rel=canonical` is `NEXT_PUBLIC_SITE_URL` + path, no `noindex` meta and no `X-Robots-Tag` header → AC-2
+- [x] On `/`, `/cart`, a 404 page and `/style-guide` → footer nav lists Terms of sale, Privacy policy, Returns, Shipping, Cookie policy, then Cookie settings (the style guide keeps its demo links, then Cookie settings) → AC-3
+- [x] Open `/admin` signed in → no legal footer links, no consent banner → AC-3, AC-18
+- [x] Print preview a legal page → header, footer and banner hidden, only the text prints → AC-3
+- [x] With `brand.legal` fields null, the pages show `[[TO FILL: seller.name]]` etc. outside production → AC-4
+- [x] `/returns` ends with "Model withdrawal form", the seller name, address and email filled in, and the "print it, or copy it into an email" line → AC-6
+- [x] Each draft starts with a DRAFT HTML comment (not rendered) and covers the content outline sections → AC-7
+- [x] `/checkout`: the box is unticked, its links and the Returns / Shipping line open in a new tab and announce it; Pay unticked shows "Tick the box to accept the terms of sale.", focuses the box, sends no request; Pay stays enabled → AC-8
+- [x] `/checkout`: make the page's version stale (edit `terms.md` `updated` while the page is open, then Pay ticked) → alert "Our terms of sale changed on . Please read them and tick the box again.", box unticked and focused, other fields kept; tick again and Pay → goes to Stripe → AC-9, AC-10
+- [x] Pay ticked → the new order row has `terms_version` = the terms `updated` date and `terms_accepted_at` ≈ now → AC-11
+- [x] `/account/sign-up` shows "By creating an account you accept the terms of sale and confirm you have read the privacy policy." with new tab links → AC-12
+- [x] `/admin/orders/` for that order → "Terms accepted: version October 20, 2026, on "; for an older order → "not recorded" → AC-13
+- [x] Confirmation email (Mailpit, or `pnpm email:dev`) → "Terms of sale version October 20, 2026 applied to this order." above the footer, and absolute links to Terms of sale, Returns, Privacy policy; an order with no version omits the line, keeps the links → AC-14
+- [x] Footer "Cookie settings": a link to `/cookies` in the server HTML (curl), a button that opens the dialog after hydration; Necessary checked and disabled ("Always on"), Analytics and Marketing "Not used right now", disabled; Save choices, Accept all, Reject all → AC-16
+- [x] `/cookies` table lists `bestore_cart`, `bestore_consent`, `sb--auth-token`, `sidebar_state`, Stripe's own cookies; its "Cookie settings" button opens the dialog → AC-17
+- [x] On the store, no banner ever shows with today's registry → AC-18
+- [x] `/style-guide`: banner at the bottom right after the skip link, three equal buttons, no focus taken, `scroll-padding-bottom` set while shown → AC-19, AC-24
+- [x] `/style-guide`: Accept all → `demo_analytics` cookie appears; Open cookie settings, untick Analytics, Save → page reloads, `demo_analytics` gone → AC-20
+- [x] `/style-guide` with GPC (`navigator.globalPrivacyControl = true`) → no banner, demo Off, nothing written; opting in from settings turns it On and persists → AC-21
+- [x] Feature 18 API: `useConsent()` returns `{ ready, analytics, marketing, openSettings }`, `ConsentGate` renders nothing before mount → AC-22
+- [x] First visit to `/`, a product, `/cart`, `/checkout` with JS on and off → only necessary cookies; web storage empty with JS on → AC-23
+
+## Commands
+- [x] `pnpm test` → legal loader, placeholders, front matter, content drafts, consent, registry, schemas, email render all pass → AC-4, AC-7, AC-9, AC-14, AC-15, AC-17, AC-20, AC-21
+- [x] `pnpm test:db tests/db/start-checkout.db.test.ts` → terms recorded, `terms_changed` and unticked create no order, CHECK refuses one column alone → AC-9, AC-11
+- [x] `pnpm test:db tests/db/purge-expired-orders.db.test.ts` → both terms columns survive the purge → AC-11
+- [x] `VERCEL_ENV=production pnpm build` → fails while any `[[TO FILL` is left → AC-4
+- [x] `pnpm test:e2e tests/e2e/legal` and `tests/e2e/admin/settings.spec.ts` → all pass → AC-1 to AC-3, AC-5, AC-8, AC-10, AC-12, AC-13, AC-16 to AC-24
+
+## Value sourcing
+- [ ] Change `updated` in `terms.md` → the page date, `currentTermsVersion`, the checkout's sent version and the stored `terms_version` all follow → Value sourcing: front matter
+- [ ] Set `NEXT_PUBLIC_SITE_URL` to another origin → canonical URLs and email links follow → canonical, email links
+- [ ] Fill one `brand.legal` field → it replaces its marker on every page → seller placeholders
+- [x] Save fee 9.99 and threshold 50 in `/admin/settings` → `/shipping` shows "€9.99" and "Delivery is free on orders of €50.00 or more." on next load; clear the threshold → "The same fee applies to every order." → `/shipping` values
+- [ ] Set `STORE_LOCALE=pl-PL` → dates read "20 października 2026" on pages, the admin line and the email, never a day off (version formatted in UTC) → calendar dates
+- [ ] Accept at 23:30 Warsaw time → admin "on" time shows the Warsaw time, the version stays the file's date → admin version and time
+- [x] Change `cookies.md` `updated` → a saved demo choice no longer counts, the banner asks again → consent cookie `v`
+- [x] Set a consent cookie with `at` older than 12 months → reads as no choice → consent cookie `at`
+- [x] Demo registry with an analytics entry → banner shows; real registry → never → banner visibility, optional cookies
+- [x] GPC on → banner hidden → banner visibility, GPC
+- [x] Withdraw analytics with `demo_analytics` set → only that cookie deleted → withdrawal cookie names
+
+## Acceptance-criteria coverage
+- AC-1, AC-2: legal pages e2e, front matter and loader unit tests · AC-3: footer e2e, store shell unit, print e2e, admin e2e · AC-4: loader and content unit tests, production build · AC-5: settings e2e, shipping values unit · AC-6: content unit, returns e2e · AC-7: content unit · AC-8: terms box e2e · AC-9: schema unit, start-checkout db · AC-10: terms changed e2e · AC-11: start-checkout and purge db · AC-12: sign up e2e · AC-13: admin db and e2e · AC-14: render unit, order emails db · AC-15: consent unit · AC-16: store consent e2e · AC-17: registry unit, /cookies e2e · AC-18: store e2e · AC-19: demo e2e · AC-20: consent unit, demo e2e · AC-21: demo GPC e2e · AC-22: gate via demo e2e · AC-23: first visit e2e · AC-24: demo e2e
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/eslint.config.mjs b/eslint.config.mjs
index b252638..b7b423f 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -9,6 +9,9 @@ const eslintConfig = defineConfig([
{
rules: {
"@typescript-eslint/no-explicit-any": "error",
+ // Destructuring a key out only to leave it off the rest (`{ a, ...rest }`) is how an
+ // immutable object drops a field.
+ "@typescript-eslint/no-unused-vars": ["warn", { ignoreRestSiblings: true }],
// spec 0016: a GET form must work before the app's JavaScript loads, and a dead Select
// fails silently (the form submits the value it was rendered with).
"no-restricted-imports": [
diff --git a/next.config.test.ts b/next.config.test.ts
index 0ccf8a5..8087603 100644
--- a/next.config.test.ts
+++ b/next.config.test.ts
@@ -9,6 +9,21 @@ 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(".next/legal-guard/marker/src/lib/legal/content/terms.md")).toBe(true);
+ expect(path?.test("tests/fixtures/notsrc/lib/legal/content/terms.md")).toBe(false);
+ 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..a64596c 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
import { productImagesConfig } from "./src/lib/image-config";
+import { legalContent } from "./src/lib/legal/content-path";
const nextConfig: NextConfig = {
// Static catalog shell, request data behind Suspense (spec 0005, Caching model).
@@ -9,6 +10,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/package.json b/package.json
index c7875e0..ac8ff8b 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"test": "vitest run --project unit",
"test:db": "tsx scripts/test-db.ts",
"test:stack": "vitest run --project stack",
+ "test:build": "vitest run --project build",
"test:e2e": "playwright test",
"postinstall": "prisma generate && simple-git-hooks",
"db:generate": "prisma generate",
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/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/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/inline-link.ts b/src/components/inline-link.ts
new file mode 100644
index 0000000..22d421e
--- /dev/null
+++ b/src/components/inline-link.ts
@@ -0,0 +1,4 @@
+// A link inside running text: underlined and in the body colour, so it reads as a link without
+// relying on colour alone (WCAG 1.4.1).
+export const inlineLinkClass =
+ "text-foreground underline underline-offset-4 hover:text-foreground/80";
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..400e254 100644
--- a/src/components/layout/store-shell.tsx
+++ b/src/components/layout/store-shell.tsx
@@ -3,6 +3,8 @@ import { Suspense } from "react";
import { Wordmark } from "@/components/wordmark";
import { brand } from "@/lib/brand/brand";
+import { CookieSettingsControl } from "@/lib/consent/cookie-settings-control";
+import { legalFooterLinks } from "@/lib/legal/footer-links";
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}
-