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( + "# Title\n\nraw\n\n![x](https://e.x/i.png)", + ); + expect(html).not.toMatch(/| { + 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 }) => ( +
    + {children} +
    + ), + hr: () =>
    , + a: ({ href, children }) => { + if (!href) return {children}; + if (href.startsWith("/")) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, +}; + +export function LegalMarkdown({ markdown }: { readonly markdown: string }) { + return ( +
    + + {markdown} + +
    + ); +} 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. + + ) + } + /> + + + ); +} diff --git a/src/features/style-guide/components/style-guide-page.tsx b/src/features/style-guide/components/style-guide-page.tsx index 1cc1449..f07cb67 100644 --- a/src/features/style-guide/components/style-guide-page.tsx +++ b/src/features/style-guide/components/style-guide-page.tsx @@ -17,10 +17,13 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { ConsentBanner } from "@/lib/consent/consent-banner"; +import { ConsentProvider } from "@/lib/consent/consent-provider"; import { cn } from "@/lib/utils"; import { loadTokenReport } from "../tokens"; import { ComponentShowcase } from "./component-showcase"; +import { ConsentDemo, demoPolicyVersion, demoRegistry } from "./consent-demo"; import { ShowcaseSection } from "./showcase-section"; const demoNav = [ @@ -65,187 +68,205 @@ export function StyleGuidePage() { const { colors, pairs } = loadTokenReport(); return ( - } footerLinks={demoFooterLinks}> -
    -
    - Not shown in production -

    Style guide

    -

    - Every token, type style and component in one place. New pages compose these; they never - invent a look. The rules behind them live in docs/design.md. -

    -
    + + } + footerLinks={demoFooterLinks} + banner={} + > +
    +
    + Not shown in production +

    Style guide

    +

    + Every token, type style and component in one place. New pages compose these; they + never invent a look. The rules behind them live in docs/design.md. +

    +
    + + +
      + {colors.map(([name, value]) => ( +
    • +
    • + ))} +
    +
    + + + + WCAG AA contrast for every token pair in use + + + Pair + Kind + Ratio + Needs + + + + {pairs.map((pair) => ( + + {/* Wraps instead of scrolling sideways on phones. */} + {pair.label} + + {pair.kind} + + + {pair.ratio.toFixed(2)}:1 + + + {pair.min}:1 {pair.ratio >= pair.min ? "✓" : "✗"} + + + ))} + +
    +
    - -
      - {colors.map(([name, value]) => ( -
    • + +
      + {typeScale.map((style) => (
    • - ))} -
    -
    - - - - WCAG AA contrast for every token pair in use - - - Pair - Kind - Ratio - Needs - - - - {pairs.map((pair) => ( - - {/* Wraps instead of scrolling sideways on phones. */} - {pair.label} - - {pair.kind} - - - {pair.ratio.toFixed(2)}:1 - - - {pair.min}:1 {pair.ratio >= pair.min ? "✓" : "✗"} - - ))} - -
    -
    - - -
    - {typeScale.map((style) => ( -
    -
    {style.name}
    -
    Quiet clothes for loud days
    +
    +
    + + +
    +
    +

    Spacing

    +
      + {spacing.map((step) => ( +
    • + {step} +
    • + ))} +
    - ))} - - - - -
    -
    -

    Spacing

    -
      - {spacing.map((step) => ( -
    • - {step} -
    • - ))} -
    -
    -
    -

    Radius

    -
    - {["rounded-sm", "rounded-md", "rounded-lg", "rounded-xl"].map((radius) => ( -
    - - ))} +
    +

    Radius

    +
    + {["rounded-sm", "rounded-md", "rounded-lg", "rounded-xl"].map((radius) => ( +
    + + ))} +
    -
    -
    -

    Product image (aspect-product, placeholder)

    -
    -
    - +
    +

    Product image (aspect-product, placeholder)

    +
    +
    + +
    +

    Linen shirt

    +
    -

    Linen shirt

    -
    -
    - - - -
    -
    Store currency
    -
    - -
    -
    JPY (no minor unit)
    -
    - -
    -
    Negative (refund)
    -
    - -
    -
    pl-PL, PLN
    -
    - - - -
    -
    -
    - - - -
    -
    -

    - Components -

    -

    - shadcn/ui on Base UI, in src/components/ui. Each one in the states it has. -

    -
    - -
    -
    - + + + +
    +
    Store currency
    +
    + +
    +
    JPY (no minor unit)
    +
    + +
    +
    Negative (refund)
    +
    + +
    +
    pl-PL, PLN
    +
    + + + +
    +
    +
    + + + +
    +
    +

    + Components +

    +

    + shadcn/ui on Base UI, in src/components/ui. Each one in the states it has. +

    +
    + +
    + + + + + + +
    + + ); } diff --git a/src/lib/cart/cookie-name.ts b/src/lib/cart/cookie-name.ts new file mode 100644 index 0000000..218cd27 --- /dev/null +++ b/src/lib/cart/cookie-name.ts @@ -0,0 +1,3 @@ +// Pure, so the client side consent registry can name the cart cookie without importing the +// server only cookie code (spec 0017, Build details 10). +export const CART_COOKIE = "bestore_cart"; diff --git a/src/lib/cart/cookie.test.ts b/src/lib/cart/cookie.test.ts index c47678a..f67df04 100644 --- a/src/lib/cart/cookie.test.ts +++ b/src/lib/cart/cookie.test.ts @@ -26,8 +26,8 @@ vi.mock("next/headers", () => ({ }), })); -const { CART_COOKIE, CART_TTL_MS, cartExpiry, readCartId, setCartCookie } = - await import("./cookie"); +const { CART_TTL_MS, cartExpiry, readCartId, setCartCookie } = await import("./cookie"); +const { CART_COOKIE } = await import("./cookie-name"); beforeEach(() => { vi.clearAllMocks(); diff --git a/src/lib/cart/cookie.ts b/src/lib/cart/cookie.ts index 0520bba..78209b6 100644 --- a/src/lib/cart/cookie.ts +++ b/src/lib/cart/cookie.ts @@ -5,10 +5,9 @@ import { cookies } from "next/headers"; import { env } from "@/lib/env"; import { logger } from "@/lib/logger"; +import { CART_COOKIE } from "./cookie-name"; import { signCartId, verifyCartCookie } from "./signature"; -export const CART_COOKIE = "bestore_cart"; - // spec 0002: a cart lives 30 days from its last write, and so does its cookie. export const CART_TTL_MS = 30 * 24 * 60 * 60 * 1000; diff --git a/src/lib/consent/consent-banner.tsx b/src/lib/consent/consent-banner.tsx new file mode 100644 index 0000000..9d8310d --- /dev/null +++ b/src/lib/consent/consent-banner.tsx @@ -0,0 +1,71 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef } from "react"; + +import { Button } from "@/components/ui/button"; +import { legalPaths } from "@/lib/legal/paths"; + +import { acceptAllChoices, refusedAll } from "./consent"; +import { useConsentState } from "./consent-provider"; + +// spec 0017, AC-18 and AC-19: a bar at the bottom of the viewport that blocks nothing and takes +// no focus. StoreShell places it right after the skip link, so keyboard and screen reader users +// reach it early. It renders nothing in the server HTML, so a returning visitor never sees it +// flash. +export function ConsentBanner() { + const state = useConsentState(); + const ref = useRef(null); + const showing = state?.showBanner === true; + + // WCAG 2.4.11: while the bar shows, the page scrolls a focused element clear of it. + useEffect(() => { + const banner = ref.current; + if (!showing || banner === null) return; + const root = document.documentElement; + const observer = new ResizeObserver(() => { + root.style.scrollPaddingBottom = `${banner.offsetHeight}px`; + }); + observer.observe(banner); + return () => { + observer.disconnect(); + root.style.removeProperty("scroll-padding-bottom"); + }; + }, [showing]); + + if (state === null || !showing) return null; + + return ( +
    +
    +

    + We would like to use optional cookies to understand how the store is used. They stay off + unless you say yes. Read the{" "} + + cookie policy + + . +

    +
    + + + +
    +
    +
    + ); +} diff --git a/src/lib/consent/consent-dialog.tsx b/src/lib/consent/consent-dialog.tsx new file mode 100644 index 0000000..d045431 --- /dev/null +++ b/src/lib/consent/consent-dialog.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Field, FieldContent, FieldDescription, FieldLabel, FieldSet } from "@/components/ui/field"; + +import { + acceptAllChoices, + categoryUsed, + type Choices, + type CookieRegistry, + type OptionalCategory, + refusedAll, +} from "./consent"; + +export const categoryCopy = { + necessary: { + label: "Necessary", + purpose: "Keep the store working: your cart, your sign in and this choice.", + }, + analytics: { + label: "Analytics", + purpose: "Help us see how the store is used, so we can improve it.", + }, + marketing: { + label: "Marketing", + purpose: "Help show you relevant offers on other sites.", + }, +} as const; + +// spec 0017, AC-16: the three categories and three buttons. Refusing is as easy as accepting: +// Accept all and Reject all sit side by side in the same style (Key invariants). +export function ConsentDialog({ + open, + onOpenChange, + registry, + granted, + onSave, + finalFocus, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly registry: CookieRegistry; + readonly granted: Choices; + readonly onSave: (next: Choices) => void; + // Focus returns here on close; null lets the dialog pick. + readonly finalFocus: HTMLElement | null; +}) { + return ( + + finalFocus} + > + {/* Keyed by open, so each opening starts from the saved choice, not the last edits. */} + {open ? ( + + ) : ( + + Cookie settings + + )} + + + ); +} + +function ConsentForm({ + registry, + granted, + onSave, +}: { + readonly registry: CookieRegistry; + readonly granted: Choices; + readonly onSave: (next: Choices) => void; +}) { + const [choices, setChoices] = useState(() => ({ + analytics: granted.analytics && categoryUsed(registry, "analytics"), + marketing: granted.marketing && categoryUsed(registry, "marketing"), + })); + + return ( + <> + + Cookie settings + + Choose which optional cookies we may use. Necessary cookies are always on. You can change + this at any time from the footer. See the cookie policy for every + cookie we use. + + +
    + Cookie categories + {/* No data-disabled on the fields: it fades the label text below AA contrast. */} + + + + + {categoryCopy.necessary.label} + Always on + + + {categoryCopy.necessary.purpose} + + + + {(["analytics", "marketing"] as const).map((category) => ( + setChoices((current) => ({ ...current, [category]: checked }))} + /> + ))} +
    + + +
    + + +
    +
    + + ); +} + +function CategoryField({ + category, + used, + checked, + onChange, +}: { + readonly category: OptionalCategory; + readonly used: boolean; + readonly checked: boolean; + readonly onChange: (checked: boolean) => void; +}) { + const id = `consent-${category}`; + return ( + + onChange(value === true)} + aria-describedby={`${id}-hint`} + /> + + + {categoryCopy[category].label} + {used ? null : ( + Not used right now + )} + + {categoryCopy[category].purpose} + + + ); +} diff --git a/src/lib/consent/consent-gate.tsx b/src/lib/consent/consent-gate.tsx new file mode 100644 index 0000000..81f021c --- /dev/null +++ b/src/lib/consent/consent-gate.tsx @@ -0,0 +1,17 @@ +"use client"; + +import type { OptionalCategory } from "./consent"; +import { useConsent } from "./consent-provider"; + +// spec 0017, AC-22: renders its children only once mounted and the category is granted, so no +// tracking script reaches the page before consent. Feature 18 wraps its scripts in it. +export function ConsentGate({ + category, + children, +}: { + readonly category: OptionalCategory; + readonly children: React.ReactNode; +}) { + const consent = useConsent(); + return consent.ready && consent[category] ? children : null; +} diff --git a/src/lib/consent/consent-provider.tsx b/src/lib/consent/consent-provider.tsx new file mode 100644 index 0000000..c9b595d --- /dev/null +++ b/src/lib/consent/consent-provider.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { createContext, useContext, useMemo, useState, useSyncExternalStore } from "react"; + +import { + type Choices, + CONSENT_COOKIE, + type ConsentChoice, + consentCookieString, + cookieNames, + cookiesToDelete, + type CookieRegistry, + expiredCookieString, + grantedChoices, + parseConsent, + readCookie, + revokedCategories, + shouldShowBanner, +} from "./consent"; +import { ConsentDialog } from "./consent-dialog"; + +// spec 0017, AC-15 to AC-22: the browser side of consent. The server never reads the choice, so +// the store shell stays static: everything here starts "not ready" in the server HTML and on the +// first client render, then reads document.cookie once React has hydrated (Build details 9). +// +// The context holds a store that never changes, and each consumer (banner, dialog, settings +// control, gates) subscribes on its own. A context value that changed on hydration would reach +// Suspense boundaries still streaming below the provider, and React would throw away their +// server HTML and render them again on the client. + +type ConsentStore = { + readonly policyVersion: string; + readonly registry: CookieRegistry; + readonly subscribe: (listener: () => void) => () => void; + readonly save: (next: Choices) => void; + readonly openSettings: () => void; +}; + +const ConsentContext = createContext(null); + +const noSubscription = () => () => {}; + +// Global Privacy Control is not in the DOM typings yet. +type GpcNavigator = Navigator & { readonly globalPrivacyControl?: boolean }; + +// The saved choice as it stands, or null. The 12 month age is checked against when this page +// loaded (performance.timeOrigin, epoch milliseconds), never Date.now() in render, which Cache +// Components refuses; a tab left open across the mark asks on its next load. +function currentChoice(cookieString: string, policyVersion: string): ConsentChoice | null { + return parseConsent( + readCookie(cookieString, CONSENT_COOKIE), + policyVersion, + performance.timeOrigin, + ); +} + +function createConsentStore( + policyVersion: string, + registry: CookieRegistry, + onOpen: (opener: HTMLElement | null) => void, +): ConsentStore { + const listeners = new Set<() => void>(); + return { + policyVersion, + registry, + openSettings() { + onOpen(document.activeElement instanceof HTMLElement ? document.activeElement : null); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + save(next) { + const previous = grantedChoices(currentChoice(document.cookie, policyVersion)); + // AC-20: cookies of a category turned off go first, on the same host only scope they were + // set with, then the new choice, then a reload so a script already running stops. + for (const name of cookiesToDelete(previous, next, registry, cookieNames(document.cookie))) { + document.cookie = expiredCookieString(name); + } + document.cookie = consentCookieString( + { v: policyVersion, at: Date.now(), ...next }, + location.protocol === "https:", + ); + if (revokedCategories(previous, next).length > 0) { + location.reload(); + return; + } + for (const listener of listeners) listener(); + }, + }; +} + +export type ConsentState = { + readonly ready: boolean; + readonly granted: Choices; + readonly choice: ConsentChoice | null; + readonly registry: CookieRegistry; + readonly showBanner: boolean; + readonly openSettings: () => void; + readonly save: (next: Choices) => void; +}; + +// For the banner, the dialog and the settings control. Null with no provider above, which the +// footer control turns into a plain link to /cookies. +export function useConsentState(): ConsentState | null { + const store = useContext(ConsentContext); + // null on the server and until hydration; after it, the cookie string as it is now. + const cookieString = useSyncExternalStore( + store?.subscribe ?? noSubscription, + () => (store === null ? null : document.cookie), + () => null, + ); + const gpc = useSyncExternalStore( + noSubscription, + () => (navigator as GpcNavigator).globalPrivacyControl === true, + () => false, + ); + return useMemo(() => { + if (store === null) return null; + const ready = cookieString !== null; + const choice = ready ? currentChoice(cookieString, store.policyVersion) : null; + return { + ready, + granted: grantedChoices(choice), + choice, + registry: store.registry, + showBanner: ready && shouldShowBanner({ registry: store.registry, choice, gpc }), + openSettings: store.openSettings, + save: store.save, + }; + }, [store, cookieString, gpc]); +} + +export type Consent = { + readonly ready: boolean; + readonly analytics: boolean; + readonly marketing: boolean; + readonly openSettings: () => void; +}; + +const notReady: Consent = { + ready: false, + analytics: false, + marketing: false, + openSettings: () => {}, +}; + +// AC-22, for feature 18: whether analytics or marketing may run. Never true before mount. +export function useConsent(): Consent { + const state = useConsentState(); + if (state === null || !state.ready) return notReady; + return { ready: true, ...state.granted, openSettings: state.openSettings }; +} + +export function ConsentProvider({ + policyVersion, + registry, + children, +}: { + // The /cookies front matter `updated`; a choice made against another version asks again. + readonly policyVersion: string; + readonly registry: CookieRegistry; + readonly children: React.ReactNode; +}) { + const [settingsOpen, setSettingsOpen] = useState(false); + // Where focus goes back when the dialog closes: whatever opened it. + const [opener, setOpener] = useState(null); + // Made once; the props never change for a mounted layout. + const [store] = useState(() => + createConsentStore(policyVersion, registry, (element) => { + setOpener(element); + setSettingsOpen(true); + }), + ); + + return ( + + {children} + + + ); +} + +function SettingsDialog({ + open, + onOpenChange, + opener, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly opener: HTMLElement | null; +}) { + const state = useConsentState(); + if (state === null || !state.ready) return null; + return ( + { + onOpenChange(false); + state.save(next); + }} + finalFocus={opener} + /> + ); +} diff --git a/src/lib/consent/consent.test.ts b/src/lib/consent/consent.test.ts new file mode 100644 index 0000000..f42e6b8 --- /dev/null +++ b/src/lib/consent/consent.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; + +import { + acceptAllChoices, + categoryUsed, + CONSENT_COOKIE, + CONSENT_MAX_AGE_SECONDS, + type ConsentChoice, + consentCookieString, + cookieNames, + cookiesToDelete, + type CookieRegistry, + entryMatches, + expiredCookieString, + grantedChoices, + parseConsent, + readCookie, + refusedAll, + revokedCategories, + serializeConsent, + shouldShowBanner, +} from "./consent"; + +// spec 0017, AC-15 to AC-21. + +const version = "2026-10-20"; +const now = Date.UTC(2026, 9, 20, 12); +const day = 24 * 60 * 60 * 1000; + +const choice: ConsentChoice = { v: version, at: now - day, analytics: true, marketing: false }; + +const entry = (overrides: Partial): CookieRegistry[number] => ({ + name: "x", + match: "x", + category: "necessary", + purpose: "p", + lifetime: "l", + setBy: "s", + ...overrides, +}); + +const registry: CookieRegistry = [ + entry({ name: "bestore_cart", match: "bestore_cart" }), + entry({ name: "_ga", match: "_ga*", category: "analytics" }), + entry({ name: "_fbp", match: "_fbp", category: "marketing" }), + entry({ name: "Stripe", match: null }), +]; + +describe("parseConsent", () => { + it("reads back what serializeConsent wrote", () => { + expect(parseConsent(serializeConsent(choice), version, now)).toEqual(choice); + }); + + it("writes URL encoded JSON with the four keys only", () => { + const extra = { ...choice, email: "a@b.c" } as ConsentChoice; + expect(JSON.parse(decodeURIComponent(serializeConsent(extra)))).toEqual({ + v: version, + at: choice.at, + analytics: true, + marketing: false, + }); + }); + + it.each([ + ["a missing cookie", undefined], + ["an empty value", ""], + ["text that is not JSON", "hello"], + ["broken URL encoding", "%E0%A4%A"], + ["JSON of the wrong shape", encodeURIComponent(JSON.stringify({ v: version, at: now }))], + [ + "a string for a flag", + serializeConsent({ ...choice, analytics: "yes" as unknown as boolean }), + ], + ["another policy version", serializeConsent({ ...choice, v: "2026-01-01" })], + ["a choice older than 12 months", serializeConsent({ ...choice, at: now - 366 * day })], + ])("reads %s as no choice yet", (_, raw) => { + expect(parseConsent(raw, version, now)).toBeNull(); + }); + + it("still counts a choice made just under 12 months ago", () => { + const old = { ...choice, at: now - 364 * day }; + expect(parseConsent(serializeConsent(old), version, now)).toEqual(old); + }); +}); + +describe("consentCookieString", () => { + it("is host only, on /, Lax, 12 months, and Secure only on https", () => { + const http = consentCookieString(choice, false); + expect(http).toBe( + `${CONSENT_COOKIE}=${serializeConsent(choice)}; Path=/; Max-Age=${CONSENT_MAX_AGE_SECONDS}; SameSite=Lax`, + ); + expect(http).not.toMatch(/domain|httponly/i); + expect(consentCookieString(choice, true)).toMatch(/; Secure$/); + expect(CONSENT_MAX_AGE_SECONDS).toBe(365 * 24 * 60 * 60); + }); + + it("deletes a cookie on the same scope", () => { + expect(expiredCookieString("_ga")).toBe("_ga=; Path=/; Max-Age=0; SameSite=Lax"); + }); +}); + +describe("reading document.cookie", () => { + const jar = "a=1; bestore_consent=%7B%7D; sb-ref-auth-token.0=x=y"; + + it("finds one value, keeping an = inside it", () => { + expect(readCookie(jar, "bestore_consent")).toBe("%7B%7D"); + expect(readCookie(jar, "sb-ref-auth-token.0")).toBe("x=y"); + expect(readCookie(jar, "missing")).toBeUndefined(); + }); + + it("lists the names", () => { + expect(cookieNames(jar)).toEqual(["a", "bestore_consent", "sb-ref-auth-token.0"]); + expect(cookieNames("")).toEqual([]); + }); +}); + +describe("entryMatches", () => { + it("matches a name or a * pattern, never a cookie on another domain", () => { + const supabase = entry({ match: "sb-*-auth-token*" }); + expect(entryMatches(supabase, "sb-abc-auth-token")).toBe(true); + expect(entryMatches(supabase, "sb-abc-auth-token.1")).toBe(true); + expect(entryMatches(supabase, "xsb-abc-auth-token")).toBe(false); + expect(entryMatches(entry({ match: "a.b" }), "aXb")).toBe(false); + expect(entryMatches(entry({ match: null }), "anything")).toBe(false); + }); +}); + +describe("withdrawal (AC-20)", () => { + const on = { analytics: true, marketing: true }; + + it("deletes only present cookies of the categories turned off", () => { + expect( + cookiesToDelete(on, { analytics: false, marketing: true }, registry, [ + "bestore_cart", + "_ga", + "_ga_XYZ", + "_fbp", + "other", + ]), + ).toEqual(["_ga", "_ga_XYZ"]); + }); + + it("deletes nothing when nothing is turned off", () => { + expect(cookiesToDelete(refusedAll, on, registry, ["_ga", "_fbp"])).toEqual([]); + expect(revokedCategories(refusedAll, on)).toEqual([]); + }); + + it("names the categories turned off, which trigger the reload", () => { + expect(revokedCategories(on, refusedAll)).toEqual(["analytics", "marketing"]); + }); +}); + +describe("shouldShowBanner (AC-18, AC-21)", () => { + const necessaryOnly: CookieRegistry = [entry({})]; + + it("shows only with an optional cookie, no choice and no GPC", () => { + expect(shouldShowBanner({ registry, choice: null, gpc: false })).toBe(true); + expect(shouldShowBanner({ registry: necessaryOnly, choice: null, gpc: false })).toBe(false); + expect(shouldShowBanner({ registry, choice, gpc: false })).toBe(false); + expect(shouldShowBanner({ registry, choice: null, gpc: true })).toBe(false); + }); +}); + +describe("what is granted", () => { + it("is nothing until a choice is made, GPC or not", () => { + expect(grantedChoices(null)).toEqual(refusedAll); + expect(grantedChoices(choice)).toEqual({ analytics: true, marketing: false }); + }); + + it("accept all turns on only the categories the registry uses", () => { + expect(acceptAllChoices(registry)).toEqual({ analytics: true, marketing: true }); + expect(acceptAllChoices([entry({ category: "analytics" })])).toEqual({ + analytics: true, + marketing: false, + }); + expect(categoryUsed([entry({})], "marketing")).toBe(false); + }); +}); diff --git a/src/lib/consent/consent.ts b/src/lib/consent/consent.ts new file mode 100644 index 0000000..7011416 --- /dev/null +++ b/src/lib/consent/consent.ts @@ -0,0 +1,172 @@ +import { z } from "zod"; + +// Pure. The consent choice (spec 0017, AC-15 to AC-21): the cookie's value, when a saved choice +// still counts, which cookies a withdrawal deletes and when the banner shows. Browser code in +// this folder calls these with document.cookie and Date.now(); nothing here touches either. + +export const CONSENT_COOKIE = "bestore_consent"; + +export const consentCategories = ["necessary", "analytics", "marketing"] as const; +export type ConsentCategory = (typeof consentCategories)[number]; + +export const optionalCategories = ["analytics", "marketing"] as const; +export type OptionalCategory = (typeof optionalCategories)[number]; + +// 12 months, in the cookie's Max-Age seconds and in the age check's milliseconds. +export const CONSENT_MAX_AGE_SECONDS = 365 * 24 * 60 * 60; +const consentMaxAgeMs = CONSENT_MAX_AGE_SECONDS * 1000; + +export type CookieEntry = { + // As the cookie table shows it, e.g. `sb--auth-token`. + readonly name: string; + // The names it covers in document.cookie, `*` matching any run of characters; null for a cookie + // set on another domain (Stripe's page), which this site can neither see nor delete. + readonly match: string | null; + readonly category: ConsentCategory; + readonly purpose: string; + readonly lifetime: string; + readonly setBy: string; +}; + +export type CookieRegistry = readonly CookieEntry[]; + +export type Choices = Readonly>; + +export type ConsentChoice = Choices & { + // The cookie policy's `updated` date the choice was made against. + readonly v: string; + // Epoch milliseconds of the choice. + readonly at: number; +}; + +const choiceSchema = z.object({ + v: z.string().min(1), + at: z.number().int().nonnegative(), + analytics: z.boolean(), + marketing: z.boolean(), +}); + +export const refusedAll: Choices = { analytics: false, marketing: false }; + +// A missing cookie, a value that is not our JSON, a choice made against another policy version, +// or one older than 12 months all read as "no choice yet" (AC-15). +export function parseConsent( + raw: string | undefined, + policyVersion: string, + nowMs: number, +): ConsentChoice | null { + if (raw === undefined || raw === "") return null; + let value: unknown; + try { + value = JSON.parse(decodeURIComponent(raw)); + } catch { + return null; + } + const parsed = choiceSchema.safeParse(value); + if (!parsed.success) return null; + const choice = parsed.data; + if (choice.v !== policyVersion || nowMs - choice.at > consentMaxAgeMs) return null; + return choice; +} + +// Only the four keys, in a fixed order, so the cookie never carries anything else. +export function serializeConsent(choice: ConsentChoice): string { + const { v, at, analytics, marketing } = choice; + return encodeURIComponent(JSON.stringify({ v, at, analytics, marketing })); +} + +// The document.cookie assignment for a choice: no Domain (host only), so a withdrawal can delete +// on the same scope (AC-20). +export function consentCookieString(choice: ConsentChoice, secure: boolean): string { + return [ + `${CONSENT_COOKIE}=${serializeConsent(choice)}`, + "Path=/", + `Max-Age=${CONSENT_MAX_AGE_SECONDS}`, + "SameSite=Lax", + ...(secure ? ["Secure"] : []), + ].join("; "); +} + +export function expiredCookieString(name: string): string { + return `${name}=; Path=/; Max-Age=0; SameSite=Lax`; +} + +// The value of one cookie in a document.cookie string. +export function readCookie(cookieString: string, name: string): string | undefined { + for (const part of cookieString.split(";")) { + const index = part.indexOf("="); + if (index < 0) continue; + if (part.slice(0, index).trim() === name) return part.slice(index + 1).trim(); + } + return undefined; +} + +export function cookieNames(cookieString: string): readonly string[] { + return cookieString + .split(";") + .map((part) => part.split("=")[0]?.trim() ?? "") + .filter((name) => name !== ""); +} + +function globToRegExp(glob: string): RegExp { + const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*"); + return new RegExp(`^${escaped}$`); +} + +export function entryMatches(entry: CookieEntry, name: string): boolean { + return entry.match !== null && globToRegExp(entry.match).test(name); +} + +// Whether the registry lists any cookie of this category: a category with none is "Not used +// right now" in the dialog (AC-16), and with no optional category at all the banner never +// shows (AC-18). +export function categoryUsed(registry: CookieRegistry, category: ConsentCategory): boolean { + return registry.some((entry) => entry.category === category); +} + +// What a customer's choice amounts to: refused until they say yes, whatever GPC says (AC-21). +export function grantedChoices(choice: ConsentChoice | null): Choices { + return choice === null + ? refusedAll + : { analytics: choice.analytics, marketing: choice.marketing }; +} + +// The categories a save turns from on to off: a reload follows, so a running script stops. +export function revokedCategories(previous: Choices, next: Choices): readonly OptionalCategory[] { + return optionalCategories.filter((category) => previous[category] && !next[category]); +} + +// AC-20: every cookie present now that the registry files under a category turned off. +export function cookiesToDelete( + previous: Choices, + next: Choices, + registry: CookieRegistry, + presentNames: readonly string[], +): readonly string[] { + const revoked = revokedCategories(previous, next); + const entries = registry.filter((entry) => + (revoked as readonly ConsentCategory[]).includes(entry.category), + ); + return presentNames.filter((name) => entries.some((entry) => entryMatches(entry, name))); +} + +// AC-18 and AC-21: an optional cookie exists, no valid choice was made, and the browser does not +// send Global Privacy Control. +export function shouldShowBanner(input: { + readonly registry: CookieRegistry; + readonly choice: ConsentChoice | null; + readonly gpc: boolean; +}): boolean { + const optionalUsed = optionalCategories.some((category) => + categoryUsed(input.registry, category), + ); + return optionalUsed && input.choice === null && !input.gpc; +} + +// Accept all turns on only what the registry uses; an unused category stays off (AC-16). +export function acceptAllChoices(registry: CookieRegistry): Choices { + return { + analytics: categoryUsed(registry, "analytics"), + marketing: categoryUsed(registry, "marketing"), + }; +} diff --git a/src/lib/consent/cookie-settings-control.tsx b/src/lib/consent/cookie-settings-control.tsx new file mode 100644 index 0000000..83b9e91 --- /dev/null +++ b/src/lib/consent/cookie-settings-control.tsx @@ -0,0 +1,40 @@ +"use client"; + +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { legalPaths } from "@/lib/legal/paths"; + +import { useConsentState } from "./consent-provider"; + +const label = "Cookie settings"; + +// spec 0017, AC-16: in the server HTML (and with no ConsentProvider above, as on the 404 page) +// a plain link to /cookies; after hydration a button that opens the settings dialog. +export function CookieSettingsControl({ className }: { readonly className?: string }) { + const state = useConsentState(); + if (state === null || !state.ready) { + return ( + + {label} + + ); + } + return ( + + ); +} + +// The button /cookies shows under its table; nothing before hydration, since it only opens the +// dialog. +export function CookieSettingsButton() { + const state = useConsentState(); + if (state === null || !state.ready) return null; + return ( + + ); +} diff --git a/src/lib/consent/registry.test.ts b/src/lib/consent/registry.test.ts new file mode 100644 index 0000000..9eb083c --- /dev/null +++ b/src/lib/consent/registry.test.ts @@ -0,0 +1,95 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { entryMatches } from "./consent"; +import { cookieRegistry } from "./registry"; + +// spec 0017, Build details 10: every cookie the code sets is in the registry, so the cookie table +// and a withdrawal can never miss one. Cookies are set in three ways: `cookies().set(NAME, ...)` +// on the server, `document.cookie = \`${NAME}=...\`` in the browser, and the Supabase auth loops +// (`cookiesToSet`), whose names Supabase picks and the `sb-*-auth-token*` entry covers. + +const root = process.cwd(); + +// The style guide demo sets its own demo cookie, listed in its own demo registry. +const skipped = ["src/generated/", "src/features/style-guide/"]; + +function sourceFiles(dir: string): string[] { + return readdirSync(join(root, dir), { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) + .map((entry) => relative(root, join(entry.parentPath, entry.name))) + .filter((file) => !/\.test\.tsx?$/.test(file)) + .filter((file) => !skipped.some((prefix) => file.startsWith(prefix))); +} + +const files = [...sourceFiles("app"), ...sourceFiles("src"), "proxy.ts"]; +const sources = new Map(files.map((file) => [file, readFileSync(join(root, file), "utf8")])); +const allSource = [...sources.values()].join("\n"); + +// `const NAME = "value"` anywhere in the tree. +function constantValue(name: string): string | undefined { + return new RegExp(`const ${name}\\s*=\\s*"([^"]+)"`).exec(allSource)?.[1]; +} + +type Write = { readonly file: string; readonly name: string | undefined; readonly code: string }; + +function cookieWrites(): readonly Write[] { + const writes: Write[] = []; + for (const [file, text] of sources) { + for (const match of text.matchAll(/cookies\(\)\)\.set\(\s*([A-Z_][A-Z0-9_]*|"[^"]+")/g)) { + const arg = match[1] ?? ""; + const name = arg.startsWith('"') ? arg.slice(1, -1) : constantValue(arg); + writes.push({ file, name, code: match[0] }); + } + for (const match of text.matchAll(/document\.cookie\s*=\s*([^;\n]+)/g)) { + const rhs = match[1] ?? ""; + const template = /^`(?:\$\{([A-Z_][A-Z0-9_]*)\}|([\w-]+))=/.exec(rhs); + let name: string | undefined; + if (template) name = template[2] ?? constantValue(template[1] ?? ""); + // The consent cookie's own writer, and the withdrawal's delete (it only removes cookies). + else if (rhs.startsWith("consentCookieString(")) name = constantValue("CONSENT_COOKIE"); + else if (rhs.startsWith("expiredCookieString(")) continue; + writes.push({ file, name, code: match[0] }); + } + } + return writes; +} + +describe("the cookie registry", () => { + const writes = cookieWrites(); + + it("finds the cookies the store sets", () => { + const names = writes.map((write) => write.name); + expect(names).toEqual( + expect.arrayContaining(["bestore_cart", "bestore_consent", "sidebar_state"]), + ); + }); + + it.each(writes.map((write) => [write.file, write.code, write.name] as const))( + "%s sets a listed cookie (%s)", + (_file, _code, name) => { + expect(name, "the cookie name could not be read; name it with a const").toBeDefined(); + expect(cookieRegistry.some((entry) => entryMatches(entry, name ?? ""))).toBe(true); + }, + ); + + it("covers the Supabase auth cookies, the only ones set by name loops", () => { + const loops = files.filter((file) => /cookiesToSet/.test(sources.get(file) ?? "")); + expect(loops.length).toBeGreaterThan(0); + for (const name of ["sb-abcd-auth-token", "sb-abcd-auth-token.0", "sb-abcd-auth-token.1"]) { + expect(cookieRegistry.some((entry) => entryMatches(entry, name))).toBe(true); + } + }); + + // AC-17: it ships with necessary cookies only, so the banner never shows today (AC-18). + it("lists necessary cookies only", () => { + expect(cookieRegistry.every((entry) => entry.category === "necessary")).toBe(true); + }); + + it("names Stripe's cookies, set on Stripe's domain, which this site cannot delete", () => { + const stripe = cookieRegistry.find((entry) => entry.name.includes("Stripe")); + expect(stripe?.match).toBeNull(); + }); +}); diff --git a/src/lib/consent/registry.ts b/src/lib/consent/registry.ts new file mode 100644 index 0000000..8f0af42 --- /dev/null +++ b/src/lib/consent/registry.ts @@ -0,0 +1,50 @@ +import { CART_COOKIE } from "@/lib/cart/cookie-name"; + +import { CONSENT_COOKIE, type CookieRegistry } from "./consent"; + +// spec 0017, AC-17: every cookie the store sets. registry.test.ts fails when code sets a cookie +// this list does not name. An analytics or marketing entry makes the banner appear, and adding +// one means changing the /cookies `updated` date, which asks every visitor again. +export const cookieRegistry: CookieRegistry = [ + { + name: CART_COOKIE, + match: CART_COOKIE, + category: "necessary", + purpose: "Keeps your cart between visits. It holds a signed cart id, nothing else.", + lifetime: "30 days after the cart last changed", + setBy: "This store", + }, + { + name: CONSENT_COOKIE, + match: CONSENT_COOKIE, + category: "necessary", + purpose: "Remembers your cookie choice, so we do not ask again on every page.", + lifetime: "12 months", + setBy: "This store", + }, + { + name: "sb--auth-token (and its numbered parts .0, .1, …)", + match: "sb-*-auth-token*", + category: "necessary", + purpose: "Keeps you signed in to your account. Only set when you sign in.", + lifetime: "Until you sign out, at most 400 days", + setBy: "This store (through Supabase, our sign in provider)", + }, + { + name: "sidebar_state", + match: "sidebar_state", + category: "necessary", + purpose: "Remembers whether the admin panel's sidebar is open. Only for store staff.", + lifetime: "7 days", + setBy: "This store", + }, + { + name: "Stripe's own cookies", + match: null, + category: "necessary", + purpose: + "Used by Stripe on its secure payment page to process your card payment and prevent fraud.", + lifetime: "Set by Stripe, see Stripe's cookie policy", + setBy: "Stripe, on its own domain (not ours)", + }, +]; diff --git a/tests/e2e/legal/consent.spec.ts b/tests/e2e/legal/consent.spec.ts new file mode 100644 index 0000000..c4a771f --- /dev/null +++ b/tests/e2e/legal/consent.spec.ts @@ -0,0 +1,260 @@ +import { type BrowserContext, expect, type Page, test } from "@playwright/test"; + +import { expectNoA11yViolations } from "../a11y"; +import { seedProduct } from "../catalog/support"; +import { addToCart } from "../checkout/support"; +import { blockAppScripts } from "../no-js"; + +// spec 0017: cookie consent. The real registry has no optional cookie, so the store never shows +// the banner; the style guide demo registry has one analytics cookie and runs the banner, the +// dialog and the withdrawal end to end (AC-15 to AC-24). + +const demoCookie = "demo_analytics"; + +function banner(page: Page) { + return page.getByRole("region", { name: "Cookie consent" }); +} + +function settingsDialog(page: Page) { + return page.getByRole("dialog", { name: "Cookie settings" }); +} + +function demoState(page: Page) { + return page.getByTestId("consent-demo-analytics"); +} + +async function cookieNamed(context: BrowserContext, name: string) { + return (await context.cookies()).find((cookie) => cookie.name === name); +} + +async function savedChoice(context: BrowserContext) { + const cookie = await cookieNamed(context, "bestore_consent"); + return cookie === undefined ? undefined : JSON.parse(decodeURIComponent(cookie.value)); +} + +async function openDemo(page: Page) { + await page.goto("/style-guide"); + // Ready once hydrated: the demo's state line leaves its waiting text. + await expect(demoState(page)).not.toHaveText("Waiting for the page to load"); +} + +test.describe("the store", () => { + test("never shows the banner while only necessary cookies are registered", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("contentinfo").getByRole("button", { name: "Cookie settings" }), + ).toBeVisible(); + await expect(banner(page)).toHaveCount(0); + }); + + test("Cookie settings is a link in the server HTML and a dialog button after hydration", async ({ + page, + request, + }) => { + const html = await (await request.get("/")).text(); + expect(html).toMatch(/]*href="\/cookies"[^>]*>Cookie settings<\/a>/); + expect(html).not.toContain("Cookie consent"); + + await page.goto("/"); + await page.getByRole("contentinfo").getByRole("button", { name: "Cookie settings" }).click(); + const dialog = settingsDialog(page); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("checkbox", { name: /Necessary/ })).toBeChecked(); + await expect(dialog.getByRole("checkbox", { name: /Necessary/ })).toBeDisabled(); + for (const name of [/Analytics/, /Marketing/]) { + const box = dialog.getByRole("checkbox", { name }); + await expect(box).not.toBeChecked(); + await expect(box).toBeDisabled(); + } + await expect(dialog.getByText("Not used right now")).toHaveCount(2); + await expectNoA11yViolations(page); + + await dialog.getByRole("button", { name: "Reject all" }).click(); + await expect(dialog).toBeHidden(); + // Focus goes back to what opened it. + await expect( + page.getByRole("contentinfo").getByRole("button", { name: "Cookie settings" }), + ).toBeFocused(); + expect(await savedChoice(page.context())).toMatchObject({ analytics: false, marketing: false }); + }); + + test("without the app's JavaScript, Cookie settings goes to the cookie policy", async ({ + page, + }) => { + await blockAppScripts(page); + await page.goto("/"); + await page.getByRole("contentinfo").getByRole("link", { name: "Cookie settings" }).click(); + await expect(page).toHaveURL("/cookies"); + await expect(page.getByRole("table", { name: "Every cookie the store sets" })).toBeVisible(); + }); + + test("/cookies lists every registered cookie and opens the settings from its own button", async ({ + page, + }) => { + await page.goto("/cookies"); + const table = page.getByRole("table", { name: "Every cookie the store sets" }); + for (const name of [ + "bestore_cart", + "bestore_consent", + /sb--auth-token/, + "Stripe's own cookies", + ]) { + await expect(table.getByRole("cell", { name })).toBeVisible(); + } + await page.getByRole("main").getByRole("button", { name: "Cookie settings" }).click(); + await expect(settingsDialog(page)).toBeVisible(); + }); + + // AC-23: on a first visit nothing outside the necessary entries is stored, with scripts on and + // off; only scripts could write web storage, so it is checked with them on. + for (const scripts of ["on", "off"] as const) { + test(`a first visit stores only necessary cookies (JavaScript ${scripts})`, async ({ + page, + }) => { + if (scripts === "off") await blockAppScripts(page); + const socks = await seedProduct({ stock: 5, priceCents: 1250 }); + const necessary = /^(bestore_cart|bestore_consent|sb-.+-auth-token.*)$/; + + for (const path of ["/", `/products/${socks.slug}`, "/cart", "/checkout"]) { + await page.goto(path); + await page.waitForLoadState("networkidle"); + const names = (await page.context().cookies()).map((cookie) => cookie.name); + expect(names.filter((name) => !necessary.test(name))).toEqual([]); + if (scripts === "on") { + expect(await page.evaluate(() => [localStorage.length, sessionStorage.length])).toEqual([ + 0, 0, + ]); + } + } + }); + } + + test("with a cart, checkout still stores only necessary cookies", async ({ page }) => { + const socks = await seedProduct({ stock: 5, priceCents: 1250 }); + await addToCart(page, socks); + await page.goto("/checkout"); + await page.waitForLoadState("networkidle"); + + expect((await page.context().cookies()).map((cookie) => cookie.name)).toEqual(["bestore_cart"]); + expect(await page.evaluate(() => [localStorage.length, sessionStorage.length])).toEqual([0, 0]); + }); +}); + +test.describe("the style guide demo", () => { + test("shows the banner after the skip link, with three equal buttons, and Reject all sticks", async ({ + page, + }) => { + await openDemo(page); + const bar = banner(page); + await expect(bar).toBeVisible(); + await expect(bar.getByRole("link", { name: "cookie policy" })).toHaveAttribute( + "href", + "/cookies", + ); + const buttons = bar.getByRole("button"); + await expect(buttons).toHaveText(["Accept all", "Reject all", "Choose cookies"]); + const classes = await buttons.evaluateAll((all) => all.map((button) => button.className)); + expect(new Set(classes).size).toBe(1); + // The bar takes no focus, and comes right after the skip link in tab order. + await expect(bar.locator(":focus")).toHaveCount(0); + await page.keyboard.press("Tab"); + await expect(page.getByRole("link", { name: "Skip to content" })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(bar.getByRole("link", { name: "cookie policy" })).toBeFocused(); + // The page scrolls a focused element clear of the bar (WCAG 2.4.11). + const height = await bar.evaluate((element) => element.getBoundingClientRect().height); + const padding = await page.evaluate(() => document.documentElement.style.scrollPaddingBottom); + expect(Number.parseFloat(padding)).toBeCloseTo(height, 0); + await expectNoA11yViolations(page); + + await bar.getByRole("button", { name: "Reject all" }).click(); + await expect(bar).toHaveCount(0); + expect(await savedChoice(page.context())).toMatchObject({ + v: "style-guide-demo", + analytics: false, + marketing: false, + }); + expect(await page.evaluate(() => document.documentElement.style.scrollPaddingBottom)).toBe(""); + expect(await cookieNamed(page.context(), demoCookie)).toBeUndefined(); + + await page.reload(); + await expect(demoState(page)).toHaveText("Off"); + await expect(banner(page)).toHaveCount(0); + }); + + test("Accept all sets the demo cookie; turning analytics off deletes it and reloads", async ({ + page, + }) => { + await openDemo(page); + await banner(page).getByRole("button", { name: "Accept all" }).click(); + await expect(demoState(page)).toHaveText("On"); + await expect.poll(() => cookieNamed(page.context(), demoCookie)).toBeDefined(); + expect(await savedChoice(page.context())).toMatchObject({ analytics: true }); + + await page.getByRole("button", { name: "Open cookie settings" }).click(); + const dialog = settingsDialog(page); + await expect(dialog.getByRole("checkbox", { name: /Analytics/ })).toBeChecked(); + await expectNoA11yViolations(page); + await dialog.getByRole("checkbox", { name: /Analytics/ }).uncheck(); + const reloaded = page.waitForEvent("load"); + await dialog.getByRole("button", { name: "Save choices" }).click(); + await reloaded; + + await expect(demoState(page)).toHaveText("Off"); + expect(await cookieNamed(page.context(), demoCookie)).toBeUndefined(); + expect(await savedChoice(page.context())).toMatchObject({ analytics: false }); + }); + + test("Choose cookies opens the dialog, where a choice is saved", async ({ page }) => { + await openDemo(page); + await banner(page).getByRole("button", { name: "Choose cookies" }).click(); + const dialog = settingsDialog(page); + await expect(dialog.getByRole("checkbox", { name: /Analytics/ })).not.toBeChecked(); + await expect(dialog.getByRole("checkbox", { name: /Marketing/ })).toBeDisabled(); + await dialog.getByRole("checkbox", { name: /Analytics/ }).check(); + await dialog.getByRole("button", { name: "Save choices" }).click(); + + await expect(dialog).toBeHidden(); + await expect(banner(page)).toHaveCount(0); + await expect(demoState(page)).toHaveText("On"); + }); + + test("Global Privacy Control hides the banner and refuses until the visitor opts in", async ({ + page, + }) => { + await page.addInitScript(() => { + Object.defineProperty(Navigator.prototype, "globalPrivacyControl", { get: () => true }); + }); + await openDemo(page); + + await expect(demoState(page)).toHaveText("Off"); + await expect(banner(page)).toHaveCount(0); + expect(await savedChoice(page.context())).toBeUndefined(); + + await page.getByRole("button", { name: "Open cookie settings" }).click(); + await settingsDialog(page).getByRole("button", { name: "Accept all" }).click(); + await expect(demoState(page)).toHaveText("On"); + await page.reload(); + await expect(demoState(page)).toHaveText("On"); + }); + + test("an old or broken choice counts as none, so the banner asks again", async ({ page }) => { + await page.context().addCookies([ + { + name: "bestore_consent", + value: encodeURIComponent( + JSON.stringify({ + v: "an-older-policy", + at: Date.now(), + analytics: true, + marketing: false, + }), + ), + url: "http://localhost:3000", + }, + ]); + await openDemo(page); + await expect(banner(page)).toBeVisible(); + await expect(demoState(page)).toHaveText("Off"); + }); +}); From b4fd72040f3b2f0915f73f1c2a343673765c82b9 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Tue, 20 Oct 2026 19:44:43 +0200 Subject: [PATCH 05/27] docs(specs): verify steps for spec 0017 Co-Authored-By: Claude Opus 5.5 --- .../0017-legal-pages-cookie-consent/verify.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/specs/0017-legal-pages-cookie-consent/verify.md 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..e63ff1f --- /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 +- [ ] 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 +- [ ] 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 +- [ ] 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 +- [ ] Open `/admin` signed in → no legal footer links, no consent banner → AC-3, AC-18 +- [ ] Print preview a legal page → header, footer and banner hidden, only the text prints → AC-3 +- [ ] With `brand.legal` fields null, the pages show `[[TO FILL: seller.name]]` etc. outside production → AC-4 +- [ ] `/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 +- [ ] Each draft starts with a DRAFT HTML comment (not rendered) and covers the content outline sections → AC-7 +- [ ] `/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 +- [ ] `/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 <date>. 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 +- [ ] Pay ticked → the new order row has `terms_version` = the terms `updated` date and `terms_accepted_at` ≈ now → AC-11 +- [ ] `/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 +- [ ] `/admin/orders/<n>` for that order → "Terms accepted: version October 20, 2026, on <date time in STORE_TIMEZONE>"; for an older order → "not recorded" → AC-13 +- [ ] 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 +- [ ] 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 +- [ ] `/cookies` table lists `bestore_cart`, `bestore_consent`, `sb-<project ref>-auth-token`, `sidebar_state`, Stripe's own cookies; its "Cookie settings" button opens the dialog → AC-17 +- [ ] On the store, no banner ever shows with today's registry → AC-18 +- [ ] `/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 +- [ ] `/style-guide`: Accept all → `demo_analytics` cookie appears; Open cookie settings, untick Analytics, Save → page reloads, `demo_analytics` gone → AC-20 +- [ ] `/style-guide` with GPC (`navigator.globalPrivacyControl = true`) → no banner, demo Off, nothing written; opting in from settings turns it On and persists → AC-21 +- [ ] Feature 18 API: `useConsent()` returns `{ ready, analytics, marketing, openSettings }`, `ConsentGate` renders nothing before mount → AC-22 +- [ ] First visit to `/`, a product, `/cart`, `/checkout` with JS on and off → only necessary cookies; web storage empty with JS on → AC-23 + +## Commands +- [ ] `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 +- [ ] `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 +- [ ] `pnpm test:db tests/db/purge-expired-orders.db.test.ts` → both terms columns survive the purge → AC-11 +- [ ] `VERCEL_ENV=production pnpm build` → fails while any `[[TO FILL` is left → AC-4 +- [ ] `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 +- [ ] 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 +- [ ] Change `cookies.md` `updated` → a saved demo choice no longer counts, the banner asks again → consent cookie `v` +- [ ] Set a consent cookie with `at` older than 12 months → reads as no choice → consent cookie `at` +- [ ] Demo registry with an analytics entry → banner shows; real registry → never → banner visibility, optional cookies +- [ ] GPC on → banner hidden → banner visibility, GPC +- [ ] 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 From 03300f6008f9945fad99877814f6a84ff4e2cba5 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:01:49 +0200 Subject: [PATCH 06/27] test(legal): value sourcing for versions, canonical, locale and email links Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../legal/components/legal-page.test.ts | 88 +++++++++++++++++++ src/lib/dates.test.ts | 12 +++ src/lib/email/render.test.ts | 27 ++++++ src/lib/legal/content.test.ts | 15 ++++ 4 files changed, 142 insertions(+) create mode 100644 src/features/legal/components/legal-page.test.ts diff --git a/src/features/legal/components/legal-page.test.ts b/src/features/legal/components/legal-page.test.ts new file mode 100644 index 0000000..a4491eb --- /dev/null +++ b/src/features/legal/components/legal-page.test.ts @@ -0,0 +1,88 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { calendarDate, formatCalendarDate } from "@/lib/dates"; +import cookiesText from "@/lib/legal/content/cookies.md"; +import termsText from "@/lib/legal/content/terms.md"; +import { parseFrontMatter } from "@/lib/legal/front-matter"; +import { legalSlugs } from "@/lib/legal/paths"; + +vi.mock("server-only", () => ({})); +// Another origin and another locale than the defaults, so the test proves both follow the env. +vi.mock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SITE_URL: "https://staging.other.example", + STORE_LOCALE: "pl-PL", + STORE_COUNTRY: "PL", + STORE_CURRENCY: "PLN", + VERCEL_ENV: "development", + }, +})); +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => + createElement("a", { href, ...props }, children), +})); + +const { LegalPage, legalMetadata } = await import("./legal-page"); +const { cookiePolicyVersion, currentTermsVersion } = await import("@/lib/legal/documents"); + +// spec 0017, Value sourcing: front matter. The checkout's version and the consent cookie's `v` +// come from the files' own `updated` date, so editing it moves them with no other change. +describe("the text versions", () => { + it("take the terms version from terms.md front matter", () => { + expect(currentTermsVersion).toBe(parseFrontMatter(termsText, "terms").meta.updated); + }); + + it("take the cookie policy version from cookies.md front matter", () => { + expect(cookiePolicyVersion).toBe(parseFrontMatter(cookiesText, "cookies").meta.updated); + }); +}); + +// spec 0017, AC-2 and Value sourcing: canonical. +describe("legalMetadata", () => { + it.each(legalSlugs)("points the %s canonical URL at NEXT_PUBLIC_SITE_URL", (slug) => { + expect(legalMetadata(slug).alternates?.canonical).toBe(`https://staging.other.example/${slug}`); + }); + + it("uses the front matter title and description", () => { + const { meta } = parseFrontMatter(termsText, "terms"); + + expect(legalMetadata("terms")).toMatchObject({ + title: meta.title, + description: meta.description, + }); + }); +}); + +// spec 0017, AC-1 and Value sourcing: calendar dates. +describe("LegalPage", () => { + const html = renderToStaticMarkup(createElement(LegalPage, { slug: "terms" })); + const updated = parseFrontMatter(termsText, "terms").meta.updated; + + it("shows the version date in STORE_LOCALE, on the file's own day", () => { + const polish = formatCalendarDate(calendarDate(updated), "pl-PL"); + + expect(polish).not.toBe(formatCalendarDate(calendarDate(updated), "en")); + expect(html).toContain(`Last updated <time dateTime="${updated}">${polish}</time>`); + }); + + it("has the title as its only h1 and no unfilled placeholder", () => { + expect(html.match(/<h1/g)).toHaveLength(1); + expect(html).toContain(">Terms of sale</h1>"); + expect(html).not.toContain("{{"); + }); + + it("fills the live values a page passes in", () => { + const shipping = renderToStaticMarkup( + createElement(LegalPage, { + slug: "shipping", + values: { deliveryFee: "9,99 zł", freeDeliveryRule: "Free over 50 zł." }, + }), + ); + + expect(shipping).toContain("9,99 zł"); + expect(shipping).toContain("Free over 50 zł."); + expect(shipping).not.toContain("{{"); + }); +}); diff --git a/src/lib/dates.test.ts b/src/lib/dates.test.ts index 9f1cae8..4012ce8 100644 --- a/src/lib/dates.test.ts +++ b/src/lib/dates.test.ts @@ -36,6 +36,18 @@ describe("formatCalendarDate", () => { expect(formatCalendarDate(calendarDate("2026-01-01"), "pl-PL")).toBe("1 stycznia 2026"); }); + // Value sourcing, admin version and time: accepted at 00:30 Warsaw time the next day, the + // version still reads as the file's date while the time follows the store zone. + it("keeps the version day apart from a late night acceptance time", () => { + const version = calendarDate("2026-10-20"); + const acceptedAt = new Date("2026-10-20T22:30:00Z"); + + expect(formatCalendarDate(version, "pl-PL")).toBe("20 października 2026"); + expect(formatDateTime(acceptedAt, { locale: "en", timeZone: "Europe/Warsaw" })).toBe( + "Oct 21, 2026, 12:30 AM", + ); + }); + 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"); }); diff --git a/src/lib/email/render.test.ts b/src/lib/email/render.test.ts index 876de53..c9cddb1 100644 --- a/src/lib/email/render.test.ts +++ b/src/lib/email/render.test.ts @@ -192,6 +192,33 @@ describe("the confirmation", () => { expect(email.html).toContain('href="https://shop.example/terms"'); }); + // Value sourcing, email links: NEXT_PUBLIC_SITE_URL decides the origin. + it("builds the legal links from the site URL it is given", () => { + const props = confirmationProps( + { + number: 1, + customerName: null, + currency: "EUR", + address: null, + paidAt: new Date("2026-10-13T10:00:00Z"), + subtotalCents: 1000, + discountCents: 0, + discountCode: null, + shippingCents: 0, + totalCents: 1000, + termsVersion: null, + lines: [], + }, + { ...format, siteUrl: "https://staging.other.example" }, + ); + + expect(props.legalLinks).toEqual({ + terms: "https://staging.other.example/terms", + returns: "https://staging.other.example/returns", + privacy: "https://staging.other.example/privacy", + }); + }); + it("formats the version in the store locale, on the same day", () => { const props = confirmationProps( { diff --git a/src/lib/legal/content.test.ts b/src/lib/legal/content.test.ts index 549dcce..61e8206 100644 --- a/src/lib/legal/content.test.ts +++ b/src/lib/legal/content.test.ts @@ -71,6 +71,21 @@ describe("the drafts together", () => { ).toThrow("still has blanks to fill before launch"); }); + // Value sourcing, seller placeholders: one brand.legal field filled clears its marker + // everywhere, and only its own. + it("fill one seller field on every page that names it", () => { + const docs = loadLegalDocuments(raw, { + seller: { ...seller, name: "Kowalski Shop sp. z o.o." }, + store: { country: "Poland", currency: "EUR" }, + production: false, + }); + const bodies = legalSlugs.map((slug) => docs[slug].body); + + expect(bodies.some((body) => body.includes("Kowalski Shop sp. z o.o."))).toBe(true); + for (const body of bodies) expect(body).not.toContain("[[TO FILL: seller.name]]"); + expect(bodies.some((body) => body.includes("[[TO FILL: seller.address]]"))).toBe(true); + }); + it("put the shipping placeholders on /shipping only", () => { for (const slug of legalSlugs) { const names = placeholderNames(raw[slug]); From 824cbc72a117ed336b0b737049c42f4637f11f3f Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:01:53 +0200 Subject: [PATCH 07/27] docs(scope): legal pages & cookie consent verified, tested and done Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/scope/scope.md | 8 +-- .../0017-legal-pages-cookie-consent/index.md | 2 +- .../0017-legal-pages-cookie-consent/verify.md | 66 +++++++++---------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/docs/scope/scope.md b/docs/scope/scope.md index a3664da..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 | in-progress | +| 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,7 +311,7 @@ spec [0016](../specs/0016-native-controls-get-forms.md) · code in [src/componen ## Launch readiness -### 16. Legal pages & cookie consent · in-progress +### 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. 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/) @@ -321,8 +321,8 @@ spec [0017](../specs/0017-legal-pages-cookie-consent/index.md) · code in [src/l - [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` +- [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 index 49161d5..66042b9 100644 --- a/docs/specs/0017-legal-pages-cookie-consent/index.md +++ b/docs/specs/0017-legal-pages-cookie-consent/index.md @@ -1,7 +1,7 @@ # 0017. Legal pages and cookie consent **Date**: 2026-10-20 -**Status**: In Progress +**Status**: Accepted ## Summary diff --git a/docs/specs/0017-legal-pages-cookie-consent/verify.md b/docs/specs/0017-legal-pages-cookie-consent/verify.md index e63ff1f..a2de5ef 100644 --- a/docs/specs/0017-legal-pages-cookie-consent/verify.md +++ b/docs/specs/0017-legal-pages-cookie-consent/verify.md @@ -2,48 +2,48 @@ _Steps derived from spec 0017 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._ ## UI / manual -- [ ] 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 -- [ ] View source of each legal page → `<title>` 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 -- [ ] 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 -- [ ] Open `/admin` signed in → no legal footer links, no consent banner → AC-3, AC-18 -- [ ] Print preview a legal page → header, footer and banner hidden, only the text prints → AC-3 -- [ ] With `brand.legal` fields null, the pages show `[[TO FILL: seller.name]]` etc. outside production → AC-4 -- [ ] `/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 -- [ ] Each draft starts with a DRAFT HTML comment (not rendered) and covers the content outline sections → AC-7 -- [ ] `/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 -- [ ] `/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 <date>. 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 -- [ ] Pay ticked → the new order row has `terms_version` = the terms `updated` date and `terms_accepted_at` ≈ now → AC-11 -- [ ] `/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 -- [ ] `/admin/orders/<n>` for that order → "Terms accepted: version October 20, 2026, on <date time in STORE_TIMEZONE>"; for an older order → "not recorded" → AC-13 -- [ ] 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 -- [ ] 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 -- [ ] `/cookies` table lists `bestore_cart`, `bestore_consent`, `sb-<project ref>-auth-token`, `sidebar_state`, Stripe's own cookies; its "Cookie settings" button opens the dialog → AC-17 -- [ ] On the store, no banner ever shows with today's registry → AC-18 -- [ ] `/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 -- [ ] `/style-guide`: Accept all → `demo_analytics` cookie appears; Open cookie settings, untick Analytics, Save → page reloads, `demo_analytics` gone → AC-20 -- [ ] `/style-guide` with GPC (`navigator.globalPrivacyControl = true`) → no banner, demo Off, nothing written; opting in from settings turns it On and persists → AC-21 -- [ ] Feature 18 API: `useConsent()` returns `{ ready, analytics, marketing, openSettings }`, `ConsentGate` renders nothing before mount → AC-22 -- [ ] First visit to `/`, a product, `/cart`, `/checkout` with JS on and off → only necessary cookies; web storage empty with JS on → AC-23 +- [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 → `<title>` 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 <date>. 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/<n>` for that order → "Terms accepted: version October 20, 2026, on <date time in STORE_TIMEZONE>"; 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-<project ref>-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 -- [ ] `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 -- [ ] `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 -- [ ] `pnpm test:db tests/db/purge-expired-orders.db.test.ts` → both terms columns survive the purge → AC-11 -- [ ] `VERCEL_ENV=production pnpm build` → fails while any `[[TO FILL` is left → AC-4 -- [ ] `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 +- [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 -- [ ] 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 +- [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 -- [ ] Change `cookies.md` `updated` → a saved demo choice no longer counts, the banner asks again → consent cookie `v` -- [ ] Set a consent cookie with `at` older than 12 months → reads as no choice → consent cookie `at` -- [ ] Demo registry with an analytics entry → banner shows; real registry → never → banner visibility, optional cookies -- [ ] GPC on → banner hidden → banner visibility, GPC -- [ ] Withdraw analytics with `demo_analytics` set → only that cookie deleted → withdrawal cookie names +- [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 From 127c351933f9b50b39b4105de5e39a4c98acaf91 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:04:01 +0200 Subject: [PATCH 08/27] chore(sync): reconcile docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- AGENTS.md | 4 ++++ src/features/cart/AGENTS.md | 2 +- src/features/checkout/AGENTS.md | 2 ++ src/features/legal/AGENTS.md | 25 +++++++++++++++++++++++++ src/features/legal/CLAUDE.md | 6 ++++++ src/lib/consent/AGENTS.md | 25 +++++++++++++++++++++++++ src/lib/consent/CLAUDE.md | 6 ++++++ src/lib/legal/AGENTS.md | 25 +++++++++++++++++++++++++ src/lib/legal/CLAUDE.md | 6 ++++++ 9 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/features/legal/AGENTS.md create mode 100644 src/features/legal/CLAUDE.md create mode 100644 src/lib/consent/AGENTS.md create mode 100644 src/lib/consent/CLAUDE.md create mode 100644 src/lib/legal/AGENTS.md create mode 100644 src/lib/legal/CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index badc42a..1209a22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,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). @@ -105,5 +106,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/src/features/cart/AGENTS.md b/src/features/cart/AGENTS.md index 7f03574..4057055 100644 --- a/src/features/cart/AGENTS.md +++ b/src/features/cart/AGENTS.md @@ -8,7 +8,7 @@ The guest cart: add to cart, `/cart` with quantity and remove, the header count, - `actions.ts`: `addToCart`, `setCartItemQuantity`, `removeCartItem`. - `expired-carts.ts`: the logic behind `GET /api/cron/expired-carts` (scheduled in `vercel.json`). -- `src/lib/cart/signature.ts`, `cookie.ts`: the `bestore_cart` cookie, `<cartId>.<HMAC SHA 256>` signed with `CART_COOKIE_SECRET`. +- `src/lib/cart/signature.ts`, `cookie.ts`: the `bestore_cart` cookie, `<cartId>.<HMAC SHA 256>` signed with `CART_COOKIE_SECRET`. The name `CART_COOKIE` lives in the pure `cookie-name.ts`, so the client side consent registry can import it. - `src/lib/cart/merge.ts`: `mergeCarts` and `lockAccountCart`, the guest cart joining a customer's cart at sign in and checkout (spec 0012, AC-5). The caller holds the customer lock. - `src/lib/cart/load-cart.ts`: loads the cart with live variant data; `cart-lines.ts`: pure line flags, totals and `canCheckout`. - `src/lib/cart/owned.ts`: `readOwnedCartId` and `loadOwnedCart`, the cookie cart only when this visitor may use it (spec 0012, AC-7). diff --git a/src/features/checkout/AGENTS.md b/src/features/checkout/AGENTS.md index 2ac2b90..bb2fb35 100644 --- a/src/features/checkout/AGENTS.md +++ b/src/features/checkout/AGENTS.md @@ -25,6 +25,7 @@ Everything from `/checkout` to the order confirmation. The customer types their - Expected failures are `StartCheckoutError` codes. A validation refusal returns a `fields` map with the same messages the form shows, and the form focuses the first invalid field. Every field missing or of the wrong type still gets its own message. - A signed in checkout (spec 0012, AC-15) uses the account email, whatever was typed, first merges a guest cookie cart into the account cart (`cart_changed` with `merged` when the account cart already held lines), and takes the customer lock before the cart lock. Saving the address goes through `src/lib/customer/addresses.ts`. - A promo code (spec 0014) is rechecked on Pay under `lockCode`, taken after the customer and cart locks, and redeemed in the order's transaction. The discount reaches Stripe as a one off coupon `order_<orderId>` with our exact amount, created after the order commits; if it fails the order is expired, which frees the use. The rule lives in `src/lib/discounts/`. +- Terms acceptance (spec 0017): `acceptTerms` must be `true`, and the `termsVersion` the page passed the form must equal `currentTermsVersion` (`src/lib/legal/documents.ts`), checked before any database call; a mismatch answers `terms_changed`. The order stores `terms_version` and `terms_accepted_at`, both or neither (a CHECK in the migration SQL). - Every schema transform must be safe to run twice: `handleSubmit` sends the parsed output and the server parses it again. - The address travels as one `ShipTo` value, saved with `deliveryColumns` and sent to Stripe as `payment_intent_data.shipping`. Empty optional keys are left out, never sent as `""`. - `orders.email` can be null on an order purged under spec 0008. `checkoutPrefill` never reaches one (the purge clears `cart_id`) and returns null with `order.email_missing` if it ever sees a null email; `getCompletion` throws on a paid order without one. @@ -51,5 +52,6 @@ Everything from `/checkout` to the order confirmation. The customer types their - [0007 Shipping address and flat rate](../../../docs/specs/0007-shipping-address-flat-rate/index.md) - [0008 Purge PII from expired orders](../../../docs/specs/0008-purge-expired-order-pii/index.md) - [0014 Discounts and promo codes](../../../docs/specs/0014-discounts-promo-codes/index.md) +- [0017 Legal pages and cookie consent](../../../docs/specs/0017-legal-pages-cookie-consent/index.md) _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/src/features/legal/AGENTS.md b/src/features/legal/AGENTS.md new file mode 100644 index 0000000..052137e --- /dev/null +++ b/src/features/legal/AGENTS.md @@ -0,0 +1,25 @@ +# Legal pages + +## Overview + +The storefront pages `/terms`, `/privacy`, `/returns`, `/shipping` and `/cookies`, and the legal links in the store footer. The texts and their versions live in `src/lib/legal/`; the consent banner and dialog live in `src/lib/consent/`. Governing spec: [0017 Legal pages and cookie consent](../../../docs/specs/0017-legal-pages-cookie-consent/index.md). + +## Files + +- `app/(store)/{terms,privacy,returns,shipping,cookies}/page.tsx`: thin routes, `legalMetadata(slug)` then `<LegalPage slug=... />`. +- `components/legal-page.tsx`: `server-only`. `legalMetadata` (title, description, canonical from `NEXT_PUBLIC_SITE_URL`) and `LegalPage` (the `h1`, the "Last updated" date, the body). +- `components/legal-markdown.tsx`: its own `react-markdown` renderer, not the product description one. Only `h2`, `h3`, `p`, lists, `strong`, `em`, `a`, `blockquote`, `hr`; raw HTML skipped; links only to `/...`, `#...`, `https:` and `mailto:`. +- `components/cookie-table.tsx`: the registry as the table on `/cookies`. +- `components/footer-links.ts`: `legalFooterLinks`, which `StoreShell` uses by default, so the 404 page has them too. +- `shipping-values.ts`: pure. The live delivery fee and free delivery rule `/shipping` fills in. + +## Conventions + +- The pages read no request data, so they prerender with no `dynamic` or `revalidate` export. `/shipping` adds only the cached `getShippingSettings()` read, which an admin save expires. +- The admin panel never renders `StoreShell`, which keeps legal links and the banner out of it. + +## Tests + +`tests/e2e/legal/legal-pages.spec.ts` (pages, footer, checkout links, axe) and `tests/e2e/legal/consent.spec.ts` (banner, dialog, withdrawal, GPC, first visit cookies and storage). + +_Drafted by /sync from the introducing change, worth a quick human pass._ diff --git a/src/features/legal/CLAUDE.md b/src/features/legal/CLAUDE.md new file mode 100644 index 0000000..3e8f00c --- /dev/null +++ b/src/features/legal/CLAUDE.md @@ -0,0 +1,6 @@ +# CLAUDE.md + +This project's context for all AI tools lives in [AGENTS.md](./AGENTS.md). +Claude Code loads it via the import below: + +@AGENTS.md diff --git a/src/lib/consent/AGENTS.md b/src/lib/consent/AGENTS.md new file mode 100644 index 0000000..9c07249 --- /dev/null +++ b/src/lib/consent/AGENTS.md @@ -0,0 +1,25 @@ +# Cookie consent + +## Overview + +A small first party consent system: the `bestore_consent` cookie written by the browser, the cookie registry, a provider, a gate, the banner and the settings dialog. Mounted only in `app/(store)/layout.tsx`, so the admin panel never shows the banner. Governing spec: [0017 Legal pages and cookie consent](../../../docs/specs/0017-legal-pages-cookie-consent/index.md). + +## Files + +- `consent.ts`: pure. The cookie schema, `parseConsent`, `serializeConsent`, `cookiesToDelete`, `shouldShowBanner`, `acceptAllChoices`. +- `registry.ts`: `cookieRegistry`, every cookie the store sets, shown as the table on `/cookies`. +- `consent-provider.tsx`: `ConsentProvider`, `useConsent` (for feature 18: `{ ready, analytics, marketing, openSettings }`) and `useConsentState` (banner, dialog, settings control). +- `consent-gate.tsx`: `ConsentGate category=...`, renders its children only once mounted and granted. +- `consent-banner.tsx`, `consent-dialog.tsx`, `cookie-settings-control.tsx`: the UI. The control is a link to `/cookies` before hydration or with no provider above, and a button that opens the dialog after. + +## Conventions + +- Every cookie or web storage key the code sets goes in `registry.ts`. `registry.test.ts` greps `src/` and `app/` for cookie writes and fails on an unlisted name. +- An `analytics` or `marketing` entry makes the banner appear by itself. Adding one means bumping the `updated` date of `src/lib/legal/content/cookies.md`, which asks everyone again. +- Tracking code runs only inside `ConsentGate` or behind `useConsent()`; neither is ever true before mount. +- The server never reads the consent cookie, so the store shell and legal pages stay static. Browser reads go through `useSyncExternalStore` with a "not ready" server snapshot; never read `Date.now()` in render (Cache Components refuses it), the age check uses `performance.timeOrigin`. +- Accept all and Reject all always sit side by side with the same style, in the banner and the dialog. Global Privacy Control hides the banner and keeps every optional category off. +- Turning a category off deletes its registry matched cookies and reloads the page, so a running script stops. +- The real registry has no optional cookie today, so the banner never shows in the store. The style guide's Consent demo (`src/features/style-guide/components/consent-demo.tsx`) brings its own registry and policy version; `tests/e2e/legal/consent.spec.ts` drives it. + +_Drafted by /sync from the introducing change, worth a quick human pass._ diff --git a/src/lib/consent/CLAUDE.md b/src/lib/consent/CLAUDE.md new file mode 100644 index 0000000..3e8f00c --- /dev/null +++ b/src/lib/consent/CLAUDE.md @@ -0,0 +1,6 @@ +# CLAUDE.md + +This project's context for all AI tools lives in [AGENTS.md](./AGENTS.md). +Claude Code loads it via the import below: + +@AGENTS.md diff --git a/src/lib/legal/AGENTS.md b/src/lib/legal/AGENTS.md new file mode 100644 index 0000000..53d4af5 --- /dev/null +++ b/src/lib/legal/AGENTS.md @@ -0,0 +1,25 @@ +# Legal texts + +## Overview + +The five legal texts (terms, privacy, returns, shipping, cookies) as Markdown in the repo, bundled into the build as strings and parsed once at module load. Shared code, so checkout, account, orders and emails can read a version or link a page without importing `src/features/legal/`. Governing spec: [0017 Legal pages and cookie consent](../../../docs/specs/0017-legal-pages-cookie-consent/index.md). + +## Files + +- `content/{terms,privacy,returns,shipping,cookies}.md`: the texts. Front matter `title`, `description`, `updated` (`YYYY-MM-DD`, the text's version). `markdown.d.ts` types the `*.md` default export as `string`. +- `documents.ts`: `server-only`. `legalDocuments`, `currentTermsVersion` (what checkout accepts and stores on the order) and `cookiePolicyVersion` (what the consent cookie records). +- `load.ts`: pure `loadLegalDocuments`, the raw files to documents: front matter, seller and store placeholders, the draft comment stripped, the production guard. +- `front-matter.ts`, `placeholders.ts`: pure. `key: value` front matter checked with Zod (no YAML parser); `{{name}}` filling and `[[TO FILL: ...]]` markers. +- `paths.ts`: pure, no server import. `legalSlugs`, `legalPaths`, `legalLinkLabels` (the footer order), for client components and the email builder. + +## Conventions + +- The texts are never read with `fs`. `next.config.ts` runs `scripts/raw-text-loader.cjs` on `src/lib/legal/content/*.md` only, and `vitest.config.mts` has the same transform; a new legal file must live in `content/` to import. +- Changing a text's meaning means changing its `updated` date. A new terms date makes open checkouts answer `terms_changed`; a new cookies date asks every visitor for consent again. +- No Markdown `#` heading in a text (the title is the page `h1`) and no unknown `{{placeholder}}`: both fail `content.test.ts` or the build. +- Seller details come from `brand.legal` in `src/lib/brand/brand.ts`. A `null` field renders `[[TO FILL: seller.<field>]]`, and with `VERCEL_ENV=production` any marker throws at load, failing the build and `startCheckout`. +- `/shipping` keeps `{{deliveryFee}}` and `{{freeDeliveryRule}}` unfilled here; the page fills them per render from the cached shipping settings. +- Format a version with `formatCalendarDate` (UTC), never through `STORE_TIMEZONE`. +- The drafts are not legal advice: a lawyer reviews them before launch (spec 0017, Follow-up). + +_Drafted by /sync from the introducing change, worth a quick human pass._ diff --git a/src/lib/legal/CLAUDE.md b/src/lib/legal/CLAUDE.md new file mode 100644 index 0000000..3e8f00c --- /dev/null +++ b/src/lib/legal/CLAUDE.md @@ -0,0 +1,6 @@ +# CLAUDE.md + +This project's context for all AI tools lives in [AGENTS.md](./AGENTS.md). +Claude Code loads it via the import below: + +@AGENTS.md From 2376195b1d279a8dd896b0955e6b2d3aaba725d8 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:11:11 +0200 Subject: [PATCH 09/27] refactor(legal): move footer links to src/lib/legal The shared store shell imported a feature module. The links only map over src/lib/legal/paths.ts, so they belong beside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/specs/0017-legal-pages-cookie-consent/index.md | 4 ++-- src/components/layout/store-shell.tsx | 2 +- src/features/legal/AGENTS.md | 1 - src/lib/legal/AGENTS.md | 1 + .../legal/components => lib/legal}/footer-links.ts | 6 ++++-- 5 files changed, 8 insertions(+), 6 deletions(-) rename src/{features/legal/components => lib/legal}/footer-links.ts (63%) diff --git a/docs/specs/0017-legal-pages-cookie-consent/index.md b/docs/specs/0017-legal-pages-cookie-consent/index.md index 66042b9..d77d512 100644 --- a/docs/specs/0017-legal-pages-cookie-consent/index.md +++ b/docs/specs/0017-legal-pages-cookie-consent/index.md @@ -81,7 +81,7 @@ Reasoning and options: see [rationale.md](rationale.md). | `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). | +| `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 `<LegalPage slug=… />`. | | `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). | @@ -93,7 +93,7 @@ Reasoning and options: see [rationale.md](rationale.md). **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. +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. diff --git a/src/components/layout/store-shell.tsx b/src/components/layout/store-shell.tsx index c7b86a0..400e254 100644 --- a/src/components/layout/store-shell.tsx +++ b/src/components/layout/store-shell.tsx @@ -2,9 +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 { legalFooterLinks } from "@/lib/legal/footer-links"; import { cn } from "@/lib/utils"; import { storeContainer } from "./container"; diff --git a/src/features/legal/AGENTS.md b/src/features/legal/AGENTS.md index 052137e..c1e1278 100644 --- a/src/features/legal/AGENTS.md +++ b/src/features/legal/AGENTS.md @@ -10,7 +10,6 @@ The storefront pages `/terms`, `/privacy`, `/returns`, `/shipping` and `/cookies - `components/legal-page.tsx`: `server-only`. `legalMetadata` (title, description, canonical from `NEXT_PUBLIC_SITE_URL`) and `LegalPage` (the `h1`, the "Last updated" date, the body). - `components/legal-markdown.tsx`: its own `react-markdown` renderer, not the product description one. Only `h2`, `h3`, `p`, lists, `strong`, `em`, `a`, `blockquote`, `hr`; raw HTML skipped; links only to `/...`, `#...`, `https:` and `mailto:`. - `components/cookie-table.tsx`: the registry as the table on `/cookies`. -- `components/footer-links.ts`: `legalFooterLinks`, which `StoreShell` uses by default, so the 404 page has them too. - `shipping-values.ts`: pure. The live delivery fee and free delivery rule `/shipping` fills in. ## Conventions diff --git a/src/lib/legal/AGENTS.md b/src/lib/legal/AGENTS.md index 53d4af5..70ac3c6 100644 --- a/src/lib/legal/AGENTS.md +++ b/src/lib/legal/AGENTS.md @@ -11,6 +11,7 @@ The five legal texts (terms, privacy, returns, shipping, cookies) as Markdown in - `load.ts`: pure `loadLegalDocuments`, the raw files to documents: front matter, seller and store placeholders, the draft comment stripped, the production guard. - `front-matter.ts`, `placeholders.ts`: pure. `key: value` front matter checked with Zod (no YAML parser); `{{name}}` filling and `[[TO FILL: ...]]` markers. - `paths.ts`: pure, no server import. `legalSlugs`, `legalPaths`, `legalLinkLabels` (the footer order), for client components and the email builder. +- `footer-links.ts`: `legalFooterLinks`, which `StoreShell` uses by default, so the 404 page has them too. ## Conventions diff --git a/src/features/legal/components/footer-links.ts b/src/lib/legal/footer-links.ts similarity index 63% rename from src/features/legal/components/footer-links.ts rename to src/lib/legal/footer-links.ts index 10c73d3..45fe0da 100644 --- a/src/features/legal/components/footer-links.ts +++ b/src/lib/legal/footer-links.ts @@ -1,8 +1,10 @@ import type { NavItem } from "@/components/layout/nav"; -import { legalLinkLabels, legalPaths, legalSlugs } from "@/lib/legal/paths"; + +import { legalLinkLabels, legalPaths, legalSlugs } from "./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. +// by default, so app/not-found.tsx gets them too. Shared code, so the shell never imports a +// feature. export const legalFooterLinks: readonly NavItem[] = legalSlugs.map((slug) => ({ href: legalPaths[slug], label: legalLinkLabels[slug], From 88b36a0f4b24b8cb69fcdba731d6cd68c83717c4 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:11:26 +0200 Subject: [PATCH 10/27] refactor(legal): make the renderer's allowed elements readonly Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/features/legal/components/legal-markdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/legal/components/legal-markdown.tsx b/src/features/legal/components/legal-markdown.tsx index 818e7dc..8ab2feb 100644 --- a/src/features/legal/components/legal-markdown.tsx +++ b/src/features/legal/components/legal-markdown.tsx @@ -6,7 +6,7 @@ import Markdown, { type Components } from "react-markdown"; // 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 = [ +export const allowedElements: readonly string[] = [ "h2", "h3", "p", From db796be0569823f6f005721a06a0b0e0444c8357 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:11:37 +0200 Subject: [PATCH 11/27] docs(legal): correct the loader and footer order comments Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/legal/AGENTS.md | 2 +- src/lib/legal/content/markdown.d.ts | 4 ++-- src/lib/legal/paths.ts | 3 ++- vitest.config.mts | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/lib/legal/AGENTS.md b/src/lib/legal/AGENTS.md index 70ac3c6..f42c60e 100644 --- a/src/lib/legal/AGENTS.md +++ b/src/lib/legal/AGENTS.md @@ -10,7 +10,7 @@ The five legal texts (terms, privacy, returns, shipping, cookies) as Markdown in - `documents.ts`: `server-only`. `legalDocuments`, `currentTermsVersion` (what checkout accepts and stores on the order) and `cookiePolicyVersion` (what the consent cookie records). - `load.ts`: pure `loadLegalDocuments`, the raw files to documents: front matter, seller and store placeholders, the draft comment stripped, the production guard. - `front-matter.ts`, `placeholders.ts`: pure. `key: value` front matter checked with Zod (no YAML parser); `{{name}}` filling and `[[TO FILL: ...]]` markers. -- `paths.ts`: pure, no server import. `legalSlugs`, `legalPaths`, `legalLinkLabels` (the footer order), for client components and the email builder. +- `paths.ts`: pure, no server import. `legalSlugs` (the footer order), `legalPaths`, `legalLinkLabels`, for client components and the email builder. - `footer-links.ts`: `legalFooterLinks`, which `StoreShell` uses by default, so the 404 page has them too. ## Conventions diff --git a/src/lib/legal/content/markdown.d.ts b/src/lib/legal/content/markdown.d.ts index 4a302ff..236eed9 100644 --- a/src/lib/legal/content/markdown.d.ts +++ b/src/lib/legal/content/markdown.d.ts @@ -1,5 +1,5 @@ -// The legal texts are imported as raw strings (the `raw` Turbopack rule in next.config.ts and -// the matching plugin in vitest.config.mts). +// The legal texts are imported as raw strings (the Turbopack rule running +// scripts/raw-text-loader.cjs 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/paths.ts b/src/lib/legal/paths.ts index ae8556a..61e9fb9 100644 --- a/src/lib/legal/paths.ts +++ b/src/lib/legal/paths.ts @@ -1,6 +1,7 @@ // Pure, no server import: client components and the email builder link to the legal pages // without pulling in their texts (spec 0017, Module layout). +// The footer's order (AC-3). export const legalSlugs = ["terms", "privacy", "returns", "shipping", "cookies"] as const; export type LegalSlug = (typeof legalSlugs)[number]; @@ -13,7 +14,7 @@ export const legalPaths: Readonly<Record<LegalSlug, `/${LegalSlug}`>> = { cookies: "/cookies", }; -// The footer's order (AC-3). +// What the footer and other links call each page. export const legalLinkLabels: Readonly<Record<LegalSlug, string>> = { terms: "Terms of sale", privacy: "Privacy policy", diff --git a/vitest.config.mts b/vitest.config.mts index e5c30bf..fe7a01e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,6 +1,6 @@ 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. +// The same as the raw text loader rule in next.config.ts: a legal text imports as its string. const legalContent = /\/src\/lib\/legal\/content\/[^/]+\.md$/; function rawLegalMarkdown(): Plugin { From 49d32a2b88ea6ffc9bdf3415108ed4eb1a926717 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:11:55 +0200 Subject: [PATCH 12/27] fix(consent): link the dialog to the cookie policy through legalPaths The banner and the settings control already did; the dialog hard-coded /cookies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/consent/consent-dialog.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/consent/consent-dialog.tsx b/src/lib/consent/consent-dialog.tsx index d045431..271dcae 100644 --- a/src/lib/consent/consent-dialog.tsx +++ b/src/lib/consent/consent-dialog.tsx @@ -13,6 +13,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Field, FieldContent, FieldDescription, FieldLabel, FieldSet } from "@/components/ui/field"; +import { legalPaths } from "@/lib/legal/paths"; import { acceptAllChoices, @@ -95,8 +96,8 @@ function ConsentForm({ <DialogTitle className="text-lg">Cookie settings</DialogTitle> <DialogDescription> Choose which optional cookies we may use. Necessary cookies are always on. You can change - this at any time from the footer. See the <a href="/cookies">cookie policy</a> for every - cookie we use. + this at any time from the footer. See the <a href={legalPaths.cookies}>cookie policy</a>{" "} + for every cookie we use. </DialogDescription> </DialogHeader> <FieldSet className="gap-4"> From c6bf6e976b9855af245652d7262d6119394f5d2b Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:12:56 +0200 Subject: [PATCH 13/27] refactor(dates): share one YYYY-MM-DD schema for legal and checkout front-matter.ts and the checkout schema each wrote the same calendar day refine. calendarDayTextSchema in src/lib/dates.ts serves both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/features/checkout/schemas.test.ts | 12 ++++++------ src/features/checkout/schemas.ts | 8 ++------ src/lib/dates.test.ts | 11 +++++++++++ src/lib/dates.ts | 8 ++++++++ src/lib/legal/front-matter.ts | 6 ++---- 5 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/features/checkout/schemas.test.ts b/src/features/checkout/schemas.test.ts index e4e9eeb..e62202b 100644 --- a/src/features/checkout/schemas.test.ts +++ b/src/features/checkout/schemas.test.ts @@ -4,7 +4,7 @@ import { checkoutFieldErrors, checkoutSchema, sessionIdSchema, - termsVersionSchema, + startCheckoutInputSchema, } from "./schemas"; // spec 0006, AC-1, AC-2 and AC-11; spec 0007, AC-2. @@ -91,13 +91,13 @@ describe("acceptTerms", () => { }); }); -describe("termsVersionSchema", () => { - it("accepts a real YYYY-MM-DD date", () => { - expect(termsVersionSchema.safeParse("2026-10-20").success).toBe(true); +describe("startCheckoutInputSchema", () => { + it("accepts a real YYYY-MM-DD terms version", () => { + expect(startCheckoutInputSchema.safeParse({ termsVersion: "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); + it.each(["2026-02-31", "20.10.2026", "", 20261020, undefined])("refuses %j", (termsVersion) => { + expect(startCheckoutInputSchema.safeParse({ termsVersion }).success).toBe(false); }); }); diff --git a/src/features/checkout/schemas.ts b/src/features/checkout/schemas.ts index 4e1cc9d..ba344fc 100644 --- a/src/features/checkout/schemas.ts +++ b/src/features/checkout/schemas.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { parseCalendarDay } from "@/lib/dates"; +import { calendarDayTextSchema } from "@/lib/dates"; import { normalizeEmail } from "@/lib/discounts/schema"; import { shippingAddressSchema, type StoreCountry } from "@/lib/shipping/address"; @@ -37,11 +37,7 @@ export function checkoutSchema(country: StoreCountry) { // 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 const startCheckoutInputSchema = z.object({ termsVersion: calendarDayTextSchema }); export type CheckoutValues = z.input<ReturnType<typeof checkoutSchema>>; export type CheckoutInput = z.output<ReturnType<typeof checkoutSchema>>; diff --git a/src/lib/dates.test.ts b/src/lib/dates.test.ts index 4012ce8..1ce64bc 100644 --- a/src/lib/dates.test.ts +++ b/src/lib/dates.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { addDays, calendarDate, + calendarDayTextSchema, formatCalendarDate, formatDate, formatDateTime, @@ -158,3 +159,13 @@ describe("addDays", () => { expect(addDays({ y: 2026, m: 3, d: 1 }, -1)).toEqual({ y: 2026, m: 2, d: 28 }); }); }); + +describe("calendarDayTextSchema", () => { + it("accepts a real YYYY-MM-DD date", () => { + expect(calendarDayTextSchema.safeParse("2028-02-29").success).toBe(true); + }); + + it.each(["2026-02-31", "20.10.2026", "", 20261020, undefined])("refuses %j", (value) => { + expect(calendarDayTextSchema.safeParse(value).success).toBe(false); + }); +}); diff --git a/src/lib/dates.ts b/src/lib/dates.ts index f907334..025d6b8 100644 --- a/src/lib/dates.ts +++ b/src/lib/dates.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export type DateFormat = { readonly locale: string; readonly timeZone: string; @@ -40,6 +42,12 @@ export function parseCalendarDay(text: string): { y: number; m: number; d: numbe : null; } +// The same check as a Zod string, for any YYYY-MM-DD text read from outside (a legal text's +// `updated`, the terms version a checkout sends). +export const calendarDayTextSchema = z + .string() + .refine((value) => parseCalendarDay(value) !== null, { error: "Not a real YYYY-MM-DD date" }); + export type CalendarDay = { readonly y: number; readonly m: number; readonly d: number }; // The calendar day an instant falls on in a time zone (spec 0015, AC-2), so "today" is the diff --git a/src/lib/legal/front-matter.ts b/src/lib/legal/front-matter.ts index 4df8165..692a747 100644 --- a/src/lib/legal/front-matter.ts +++ b/src/lib/legal/front-matter.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { parseCalendarDay } from "@/lib/dates"; +import { calendarDayTextSchema } 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). @@ -8,9 +8,7 @@ import { parseCalendarDay } from "@/lib/dates"; 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", - }), + updated: calendarDayTextSchema, }); export type FrontMatter = z.output<typeof frontMatterSchema>; From 68677a18a136790d47ff68e0c4de05c14f095c20 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:13:22 +0200 Subject: [PATCH 14/27] refactor(ui): share the inline link class New tab links, legal Markdown links and the checkout sign in hint each spelled out the same class. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/components/inline-link.ts | 4 ++++ src/components/new-tab-link.tsx | 4 +++- src/features/checkout/components/checkout-summary.tsx | 6 ++---- src/features/legal/components/legal-markdown.tsx | 8 ++++---- 4 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 src/components/inline-link.ts 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/new-tab-link.tsx b/src/components/new-tab-link.tsx index 6ea7d40..dc0c3d0 100644 --- a/src/components/new-tab-link.tsx +++ b/src/components/new-tab-link.tsx @@ -1,3 +1,5 @@ +import { inlineLinkClass } from "./inline-link"; + // 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({ @@ -13,7 +15,7 @@ export function NewTabLink({ target="_blank" rel="noopener" aria-label={`${children}, opens in a new tab`} - className="text-foreground underline underline-offset-4 hover:text-foreground/80" + className={inlineLinkClass} > {children} </a> diff --git a/src/features/checkout/components/checkout-summary.tsx b/src/features/checkout/components/checkout-summary.tsx index 22b22c9..f9c72e7 100644 --- a/src/features/checkout/components/checkout-summary.tsx +++ b/src/features/checkout/components/checkout-summary.tsx @@ -2,6 +2,7 @@ import { InfoIcon } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; +import { inlineLinkClass } from "@/components/inline-link"; import { Price } from "@/components/price"; import { ProductImage } from "@/components/product-image"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -167,10 +168,7 @@ export async function CheckoutSummary({ customer === null ? ( <p className="text-sm text-muted-foreground"> Have an account?{" "} - <Link - href={signInToCheckout} - className="text-foreground underline underline-offset-4 hover:text-foreground/80" - > + <Link href={signInToCheckout} className={inlineLinkClass}> Sign in </Link> </p> diff --git a/src/features/legal/components/legal-markdown.tsx b/src/features/legal/components/legal-markdown.tsx index 8ab2feb..e733b46 100644 --- a/src/features/legal/components/legal-markdown.tsx +++ b/src/features/legal/components/legal-markdown.tsx @@ -1,6 +1,8 @@ import Link from "next/link"; import Markdown, { type Components } from "react-markdown"; +import { inlineLinkClass } from "@/components/inline-link"; + // 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 @@ -27,8 +29,6 @@ export function legalUrl(url: string): string { return /^(https:|mailto:)/i.test(trimmed) ? trimmed : ""; } -const linkClass = "text-foreground underline underline-offset-4 hover:text-foreground/80"; - const components: Components = { h2: ({ children }) => ( <h2 className="mt-6 font-heading text-2xl text-foreground md:text-3xl">{children}</h2> @@ -49,7 +49,7 @@ const components: Components = { if (!href) return <span>{children}</span>; if (href.startsWith("/")) { return ( - <Link href={href} className={linkClass}> + <Link href={href} className={inlineLinkClass}> {children} </Link> ); @@ -57,7 +57,7 @@ const components: Components = { return ( <a href={href} - className={linkClass} + className={inlineLinkClass} {...(href.startsWith("https:") ? { rel: "noopener noreferrer" } : {})} > {children} From 2ed34255f87d9e41c3fc5f774523f8482a25c02e Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:13:46 +0200 Subject: [PATCH 15/27] refactor(consent): one set of category labels for the dialog and cookie table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../legal/components/cookie-table.tsx | 10 ++----- src/lib/consent/consent-dialog.tsx | 29 +++++++------------ src/lib/consent/consent.ts | 7 +++++ 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/src/features/legal/components/cookie-table.tsx b/src/features/legal/components/cookie-table.tsx index dbc4847..a92be65 100644 --- a/src/features/legal/components/cookie-table.tsx +++ b/src/features/legal/components/cookie-table.tsx @@ -7,13 +7,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { type ConsentCategory, type CookieRegistry } from "@/lib/consent/consent"; - -const categoryLabels: Readonly<Record<ConsentCategory, string>> = { - necessary: "Necessary", - analytics: "Analytics", - marketing: "Marketing", -}; +import { consentCategoryLabels, type CookieRegistry } from "@/lib/consent/consent"; // 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. @@ -47,7 +41,7 @@ export function CookieTable({ registry }: { readonly registry: CookieRegistry }) {entry.name} </TableCell> <TableCell className="align-top whitespace-normal"> - {categoryLabels[entry.category]} + {consentCategoryLabels[entry.category]} </TableCell> <TableCell className="align-top whitespace-normal">{entry.purpose}</TableCell> <TableCell className="align-top whitespace-normal">{entry.lifetime}</TableCell> diff --git a/src/lib/consent/consent-dialog.tsx b/src/lib/consent/consent-dialog.tsx index 271dcae..973a775 100644 --- a/src/lib/consent/consent-dialog.tsx +++ b/src/lib/consent/consent-dialog.tsx @@ -19,25 +19,18 @@ import { acceptAllChoices, categoryUsed, type Choices, + type ConsentCategory, + consentCategoryLabels, type CookieRegistry, type OptionalCategory, refusedAll, } from "./consent"; -export const categoryCopy = { - necessary: { - label: "Necessary", - purpose: "Keep the store working: your cart, your sign in and this choice.", - }, - analytics: { - label: "Analytics", - purpose: "Help us see how the store is used, so we can improve it.", - }, - marketing: { - label: "Marketing", - purpose: "Help show you relevant offers on other sites.", - }, -} as const; +const categoryPurposes: Readonly<Record<ConsentCategory, string>> = { + necessary: "Keep the store working: your cart, your sign in and this choice.", + analytics: "Help us see how the store is used, so we can improve it.", + marketing: "Help show you relevant offers on other sites.", +}; // spec 0017, AC-16: the three categories and three buttons. Refusing is as easy as accepting: // Accept all and Reject all sit side by side in the same style (Key invariants). @@ -112,11 +105,11 @@ function ConsentForm({ /> <FieldContent> <FieldLabel htmlFor="consent-necessary"> - {categoryCopy.necessary.label} + {consentCategoryLabels.necessary} <span className="font-normal text-muted-foreground">Always on</span> </FieldLabel> <FieldDescription id="consent-necessary-hint"> - {categoryCopy.necessary.purpose} + {categoryPurposes.necessary} </FieldDescription> </FieldContent> </Field> @@ -170,12 +163,12 @@ function CategoryField({ /> <FieldContent> <FieldLabel htmlFor={id}> - {categoryCopy[category].label} + {consentCategoryLabels[category]} {used ? null : ( <span className="font-normal text-muted-foreground">Not used right now</span> )} </FieldLabel> - <FieldDescription id={`${id}-hint`}>{categoryCopy[category].purpose}</FieldDescription> + <FieldDescription id={`${id}-hint`}>{categoryPurposes[category]}</FieldDescription> </FieldContent> </Field> ); diff --git a/src/lib/consent/consent.ts b/src/lib/consent/consent.ts index 7011416..f110dc3 100644 --- a/src/lib/consent/consent.ts +++ b/src/lib/consent/consent.ts @@ -9,6 +9,13 @@ export const CONSENT_COOKIE = "bestore_consent"; export const consentCategories = ["necessary", "analytics", "marketing"] as const; export type ConsentCategory = (typeof consentCategories)[number]; +// What the dialog and the /cookies table call each category. +export const consentCategoryLabels: Readonly<Record<ConsentCategory, string>> = { + necessary: "Necessary", + analytics: "Analytics", + marketing: "Marketing", +}; + export const optionalCategories = ["analytics", "marketing"] as const; export type OptionalCategory = (typeof optionalCategories)[number]; From 03eea7a952394f5acb9702832d8e17ce35e8875e Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:14:08 +0200 Subject: [PATCH 16/27] refactor(email): build the confirmation's legal links through one helper Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/email/props.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/lib/email/props.ts b/src/lib/email/props.ts index ef23089..4dbfc55 100644 --- a/src/lib/email/props.ts +++ b/src/lib/email/props.ts @@ -4,7 +4,7 @@ import type { OrderRefundedProps } from "../../../emails/order-refunded"; import type { OrderShippedProps } from "../../../emails/order-shipped"; import { formatCalendarDate, formatDate } from "@/lib/dates"; import { discountRowLabel } from "@/lib/discounts/rule"; -import { legalPaths } from "@/lib/legal/paths"; +import { legalPaths, type LegalSlug } from "@/lib/legal/paths"; import { formatMoney } from "@/lib/money"; import type { DeliveryAddress } from "@/lib/shipping/address"; @@ -106,13 +106,18 @@ export function confirmationProps( 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(), + terms: legalUrl("terms", format.siteUrl), + returns: legalUrl("returns", format.siteUrl), + privacy: legalUrl("privacy", format.siteUrl), }, }; } +// An email is read away from the site, so its links are absolute. +function legalUrl(slug: LegalSlug, siteUrl: string): string { + return new URL(legalPaths[slug], siteUrl).toString(); +} + export type ShippedSource = OrderBasics & { readonly carrier: string | null; readonly trackingNumber: string | null; From 651bc1816d0982d805a99293a22053bc55761c2c Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:14:46 +0200 Subject: [PATCH 17/27] refactor(legal): share the TermsAcceptance type between checkout and admin orders Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/features/checkout/actions/start-checkout.ts | 5 +---- src/features/orders/admin-queries.ts | 6 +++--- src/lib/legal/AGENTS.md | 1 + src/lib/legal/acceptance.ts | 4 ++++ 4 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 src/lib/legal/acceptance.ts diff --git a/src/features/checkout/actions/start-checkout.ts b/src/features/checkout/actions/start-checkout.ts index 3e4a05b..a868bbd 100644 --- a/src/features/checkout/actions/start-checkout.ts +++ b/src/features/checkout/actions/start-checkout.ts @@ -13,6 +13,7 @@ import { redeem } from "@/lib/discounts/codes"; import type { DiscountRefused } from "@/lib/discounts/rule"; import { calendarDate } from "@/lib/dates"; import { env } from "@/lib/env"; +import type { TermsAcceptance } from "@/lib/legal/acceptance"; import { currentTermsVersion } from "@/lib/legal/documents"; import { logOrderExpired } from "@/lib/orders/log"; import { minimumChargeCents } from "@/lib/orders/minimum-charge"; @@ -289,10 +290,6 @@ async function checkoutCartId(customer: CustomerIdentity | null): Promise<Result return cartId === null ? fail({ code: "cart_changed" }) : { ok: true, data: cartId }; } -// spec 0017, AC-11: written on the order in the transaction that creates it, never changed. A -// replaced or restarted checkout makes a new order with its own fresh values. -type TermsAcceptance = { readonly version: Date; readonly acceptedAt: Date }; - type CreatedOrder = { readonly id: string; readonly number: number; diff --git a/src/features/orders/admin-queries.ts b/src/features/orders/admin-queries.ts index 8b105e3..9815f69 100644 --- a/src/features/orders/admin-queries.ts +++ b/src/features/orders/admin-queries.ts @@ -14,6 +14,7 @@ import { addDays, parseCalendarDay, zonedDayStart } from "@/lib/dates"; import { db } from "@/lib/db"; import { escapeLike } from "@/lib/like"; import { env } from "@/lib/env"; +import type { TermsAcceptance } from "@/lib/legal/acceptance"; import { logEmailMissing } from "@/lib/orders/log"; import { type DeliveryAddress, @@ -246,9 +247,8 @@ 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; + // spec 0017, AC-13: null on orders from before the feature. + readonly terms: TermsAcceptance | null; readonly carrier: string | null; readonly trackingNumber: string | null; readonly phone: string | null; diff --git a/src/lib/legal/AGENTS.md b/src/lib/legal/AGENTS.md index f42c60e..7a0244d 100644 --- a/src/lib/legal/AGENTS.md +++ b/src/lib/legal/AGENTS.md @@ -10,6 +10,7 @@ The five legal texts (terms, privacy, returns, shipping, cookies) as Markdown in - `documents.ts`: `server-only`. `legalDocuments`, `currentTermsVersion` (what checkout accepts and stores on the order) and `cookiePolicyVersion` (what the consent cookie records). - `load.ts`: pure `loadLegalDocuments`, the raw files to documents: front matter, seller and store placeholders, the draft comment stripped, the production guard. - `front-matter.ts`, `placeholders.ts`: pure. `key: value` front matter checked with Zod (no YAML parser); `{{name}}` filling and `[[TO FILL: ...]]` markers. +- `acceptance.ts`: the `TermsAcceptance` type an order records (checkout writes it, admin orders read it). - `paths.ts`: pure, no server import. `legalSlugs` (the footer order), `legalPaths`, `legalLinkLabels`, for client components and the email builder. - `footer-links.ts`: `legalFooterLinks`, which `StoreShell` uses by default, so the 404 page has them too. diff --git a/src/lib/legal/acceptance.ts b/src/lib/legal/acceptance.ts new file mode 100644 index 0000000..a8a4b81 --- /dev/null +++ b/src/lib/legal/acceptance.ts @@ -0,0 +1,4 @@ +// spec 0017, AC-11 and AC-13: what an order records about the terms accepted at Pay. The version +// is the terms `updated` day at UTC midnight (a Postgres `date`); acceptedAt is the server's time +// at Pay. Written with the order and never changed; both or neither (a CHECK in the migration). +export type TermsAcceptance = { readonly version: Date; readonly acceptedAt: Date }; From 5a19276af153ca7acd60109b49b2474c94d22bb4 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:15:17 +0200 Subject: [PATCH 18/27] refactor(consent): derive per category choices from optionalCategories acceptAllChoices, grantedChoices, refusedAll and the dialog listed analytics and marketing by hand, so a new category needed an edit in each. choicesFrom builds them from the one list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/lib/consent/consent-dialog.tsx | 11 ++++++----- src/lib/consent/consent.test.ts | 15 +++++++++++++++ src/lib/consent/consent.ts | 18 ++++++++++-------- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/lib/consent/consent-dialog.tsx b/src/lib/consent/consent-dialog.tsx index 973a775..56f1c04 100644 --- a/src/lib/consent/consent-dialog.tsx +++ b/src/lib/consent/consent-dialog.tsx @@ -19,10 +19,12 @@ import { acceptAllChoices, categoryUsed, type Choices, + choicesFrom, type ConsentCategory, consentCategoryLabels, type CookieRegistry, type OptionalCategory, + optionalCategories, refusedAll, } from "./consent"; @@ -78,10 +80,9 @@ function ConsentForm({ readonly granted: Choices; readonly onSave: (next: Choices) => void; }) { - const [choices, setChoices] = useState<Choices>(() => ({ - analytics: granted.analytics && categoryUsed(registry, "analytics"), - marketing: granted.marketing && categoryUsed(registry, "marketing"), - })); + const [choices, setChoices] = useState<Choices>(() => + choicesFrom((category) => granted[category] && categoryUsed(registry, category)), + ); return ( <> @@ -113,7 +114,7 @@ function ConsentForm({ </FieldDescription> </FieldContent> </Field> - {(["analytics", "marketing"] as const).map((category) => ( + {optionalCategories.map((category) => ( <CategoryField key={category} category={category} diff --git a/src/lib/consent/consent.test.ts b/src/lib/consent/consent.test.ts index f42e6b8..49e6ad8 100644 --- a/src/lib/consent/consent.test.ts +++ b/src/lib/consent/consent.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { acceptAllChoices, categoryUsed, + choicesFrom, CONSENT_COOKIE, CONSENT_MAX_AGE_SECONDS, type ConsentChoice, @@ -13,6 +14,7 @@ import { entryMatches, expiredCookieString, grantedChoices, + optionalCategories, parseConsent, readCookie, refusedAll, @@ -176,3 +178,16 @@ describe("what is granted", () => { expect(categoryUsed([entry({})], "marketing")).toBe(false); }); }); + +describe("choicesFrom", () => { + it("answers once for every optional category and nothing else", () => { + const asked: string[] = []; + const choices = choicesFrom((category) => { + asked.push(category); + return category === "analytics"; + }); + + expect(asked).toEqual([...optionalCategories]); + expect(choices).toEqual({ analytics: true, marketing: false }); + }); +}); diff --git a/src/lib/consent/consent.ts b/src/lib/consent/consent.ts index f110dc3..3af2cbd 100644 --- a/src/lib/consent/consent.ts +++ b/src/lib/consent/consent.ts @@ -53,7 +53,14 @@ const choiceSchema = z.object({ marketing: z.boolean(), }); -export const refusedAll: Choices = { analytics: false, marketing: false }; +// One answer per optional category, so a new category needs no edit in the callers. +export function choicesFrom(pick: (category: OptionalCategory) => boolean): Choices { + return Object.fromEntries( + optionalCategories.map((category) => [category, pick(category)]), + ) as Record<OptionalCategory, boolean>; +} + +export const refusedAll: Choices = choicesFrom(() => false); // A missing cookie, a value that is not our JSON, a choice made against another policy version, // or one older than 12 months all read as "no choice yet" (AC-15). @@ -133,9 +140,7 @@ export function categoryUsed(registry: CookieRegistry, category: ConsentCategory // What a customer's choice amounts to: refused until they say yes, whatever GPC says (AC-21). export function grantedChoices(choice: ConsentChoice | null): Choices { - return choice === null - ? refusedAll - : { analytics: choice.analytics, marketing: choice.marketing }; + return choice === null ? refusedAll : choicesFrom((category) => choice[category]); } // The categories a save turns from on to off: a reload follows, so a running script stops. @@ -172,8 +177,5 @@ export function shouldShowBanner(input: { // Accept all turns on only what the registry uses; an unused category stays off (AC-16). export function acceptAllChoices(registry: CookieRegistry): Choices { - return { - analytics: categoryUsed(registry, "analytics"), - marketing: categoryUsed(registry, "marketing"), - }; + return choicesFrom((category) => categoryUsed(registry, category)); } From 1adb6ad848d4d72466250a84bda5bd89185260cc Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:16:18 +0200 Subject: [PATCH 19/27] refactor(dates): brand checked YYYY-MM-DD text as CalendarDayText Legal versions travelled as plain strings, and calendarDate took any string. Only text that passed calendarDayTextSchema now reaches it: a legal text's updated, the terms version checkout accepts and the current version a terms_changed refusal hands back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/features/checkout/actions/start-checkout.ts | 4 ++-- src/features/checkout/components/checkout-form.tsx | 6 +++--- src/features/checkout/components/checkout-payment.tsx | 3 ++- src/features/checkout/components/checkout-summary.tsx | 3 ++- src/lib/dates.test.ts | 10 +++++++--- src/lib/dates.ts | 10 +++++++--- src/lib/legal/load.ts | 3 ++- 7 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/features/checkout/actions/start-checkout.ts b/src/features/checkout/actions/start-checkout.ts index a868bbd..ec4b2a4 100644 --- a/src/features/checkout/actions/start-checkout.ts +++ b/src/features/checkout/actions/start-checkout.ts @@ -11,7 +11,7 @@ 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 { calendarDate, type CalendarDayText } from "@/lib/dates"; import { env } from "@/lib/env"; import type { TermsAcceptance } from "@/lib/legal/acceptance"; import { currentTermsVersion } from "@/lib/legal/documents"; @@ -60,7 +60,7 @@ export type StartCheckoutError = // spec 0014, AC-4: the code stopped applying since Apply (or never did); no order was made. | ({ 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 }; + | { readonly code: "terms_changed"; readonly currentVersion: CalendarDayText }; type Result<T> = ActionResult<T, StartCheckoutError>; diff --git a/src/features/checkout/components/checkout-form.tsx b/src/features/checkout/components/checkout-form.tsx index af3f8fe..eae3fe5 100644 --- a/src/features/checkout/components/checkout-form.tsx +++ b/src/features/checkout/components/checkout-form.tsx @@ -37,7 +37,7 @@ 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 { calendarDate, type CalendarDayText, formatCalendarDate } from "@/lib/dates"; import { legalPaths } from "@/lib/legal/paths"; import { formatMoney } from "@/lib/money"; import type { StoreCountry } from "@/lib/shipping/address"; @@ -115,7 +115,7 @@ export function CheckoutForm({ // 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; + readonly termsVersion: CalendarDayText; // 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. @@ -128,7 +128,7 @@ export function CheckoutForm({ const [notice, setNotice] = useState<string | null>(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<string | null>(null); + const [termsChangedOn, setTermsChangedOn] = useState<CalendarDayText | null>(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( diff --git a/src/features/checkout/components/checkout-payment.tsx b/src/features/checkout/components/checkout-payment.tsx index 64f401c..346e6ce 100644 --- a/src/features/checkout/components/checkout-payment.tsx +++ b/src/features/checkout/components/checkout-payment.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { type ReactNode, useRef, useState } from "react"; +import type { CalendarDayText } from "@/lib/dates"; import { DeliveryRow } from "@/components/delivery-row"; import { Price } from "@/components/price"; import { useStoreFormat } from "@/components/store-format-provider"; @@ -48,7 +49,7 @@ export function CheckoutPayment({ readonly prefill: CheckoutPrefill; readonly account: CheckoutAccount | null; // spec 0017, AC-9: the terms version the page shows. - readonly termsVersion: string; + readonly termsVersion: CalendarDayText; }) { 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 f9c72e7..5129e00 100644 --- a/src/features/checkout/components/checkout-summary.tsx +++ b/src/features/checkout/components/checkout-summary.tsx @@ -2,6 +2,7 @@ import { InfoIcon } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; +import type { CalendarDayText } from "@/lib/dates"; import { inlineLinkClass } from "@/components/inline-link"; import { Price } from "@/components/price"; import { ProductImage } from "@/components/product-image"; @@ -53,7 +54,7 @@ export async function CheckoutSummary({ }: { readonly searchParams: Promise<Record<string, string | string[] | undefined>>; // spec 0017, AC-9: the current terms version, which Pay sends back. - readonly termsVersion: string; + readonly termsVersion: CalendarDayText; }) { const [cart, params] = await Promise.all([loadOwnedCart(), searchParams]); if (!cart || !cart.canCheckout) redirect(cartPath); diff --git a/src/lib/dates.test.ts b/src/lib/dates.test.ts index 1ce64bc..0c765fc 100644 --- a/src/lib/dates.test.ts +++ b/src/lib/dates.test.ts @@ -33,14 +33,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"); + expect(formatCalendarDate(calendarDate(calendarDayTextSchema.parse("2026-10-20")), "en")).toBe( + "October 20, 2026", + ); + expect( + formatCalendarDate(calendarDate(calendarDayTextSchema.parse("2026-01-01")), "pl-PL"), + ).toBe("1 stycznia 2026"); }); // Value sourcing, admin version and time: accepted at 00:30 Warsaw time the next day, the // version still reads as the file's date while the time follows the store zone. it("keeps the version day apart from a late night acceptance time", () => { - const version = calendarDate("2026-10-20"); + const version = calendarDate(calendarDayTextSchema.parse("2026-10-20")); const acceptedAt = new Date("2026-10-20T22:30:00Z"); expect(formatCalendarDate(version, "pl-PL")).toBe("20 października 2026"); diff --git a/src/lib/dates.ts b/src/lib/dates.ts index 025d6b8..c5653dd 100644 --- a/src/lib/dates.ts +++ b/src/lib/dates.ts @@ -27,7 +27,7 @@ export function formatCalendarDate(date: Date, locale: string): string { } // A YYYY-MM-DD text as the UTC midnight formatCalendarDate and a Postgres `date` column expect. -export function calendarDate(ymd: string): Date { +export function calendarDate(ymd: CalendarDayText): Date { return new Date(`${ymd}T00:00:00Z`); } @@ -43,10 +43,14 @@ export function parseCalendarDay(text: string): { y: number; m: number; d: numbe } // The same check as a Zod string, for any YYYY-MM-DD text read from outside (a legal text's -// `updated`, the terms version a checkout sends). +// `updated`, the terms version a checkout sends). Branded, so only a checked text reaches +// calendarDate. export const calendarDayTextSchema = z .string() - .refine((value) => parseCalendarDay(value) !== null, { error: "Not a real YYYY-MM-DD date" }); + .refine((value) => parseCalendarDay(value) !== null, { error: "Not a real YYYY-MM-DD date" }) + .brand<"CalendarDayText">(); + +export type CalendarDayText = z.output<typeof calendarDayTextSchema>; export type CalendarDay = { readonly y: number; readonly m: number; readonly d: number }; diff --git a/src/lib/legal/load.ts b/src/lib/legal/load.ts index f5ec0c7..495fdb2 100644 --- a/src/lib/legal/load.ts +++ b/src/lib/legal/load.ts @@ -1,4 +1,5 @@ import type { SellerDetails } from "@/lib/brand/brand"; +import type { CalendarDayText } from "@/lib/dates"; import { parseFrontMatter } from "./front-matter"; import { legalPaths, type LegalSlug, legalSlugs } from "./paths"; @@ -13,7 +14,7 @@ export type LegalDocument = { readonly title: string; readonly description: string; // The front matter date, YYYY-MM-DD: the text's version. - readonly updated: string; + readonly updated: CalendarDayText; // Seller placeholders filled; `/shipping` still holds its live ones (AC-5). readonly body: string; }; From 37de060acca0cd90744cffb686f9f5ac3d64165e Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:17:12 +0200 Subject: [PATCH 20/27] refactor(checkout): drop the repeated acceptTerms check in startCheckout checkoutSchema's z.literal(true) already refuses anything else on the server, so the second check could never fire. ESLint now allows a key destructured only to keep it off the rest object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- eslint.config.mjs | 3 +++ src/features/checkout/actions/start-checkout.ts | 8 ++------ 2 files changed, 5 insertions(+), 6 deletions(-) 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/src/features/checkout/actions/start-checkout.ts b/src/features/checkout/actions/start-checkout.ts index ec4b2a4..e11fc7a 100644 --- a/src/features/checkout/actions/start-checkout.ts +++ b/src/features/checkout/actions/start-checkout.ts @@ -41,7 +41,6 @@ import { import { type CheckoutFieldErrors, checkoutFieldErrors, - checkoutMessages, checkoutSchema, startCheckoutInputSchema, } from "../schemas"; @@ -97,11 +96,8 @@ async function run(input: unknown): Promise<Result<{ readonly url: string }>> { } = 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 } }); - } + // reads as changed: the customer never saw the current text. acceptTerms is already `true`: + // checkoutSchema refused anything else above. const terms = startCheckoutInputSchema.safeParse(input); if (!terms.success || terms.data.termsVersion !== currentTermsVersion) { return fail({ code: "terms_changed", currentVersion: currentTermsVersion }); From 981eaef0c034cd56eaf7813eff01a5b488075a11 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:17:35 +0200 Subject: [PATCH 21/27] docs(specs): align spec 0017 with what was built AC-4 lists the store placeholders, AC-9 says a missing or malformed termsVersion answers terms_changed, AC-17 lists sidebar_state, the raw Markdown rule names the custom loader, and the CHECK test is an update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/specs/0017-legal-pages-cookie-consent/index.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/specs/0017-legal-pages-cookie-consent/index.md b/docs/specs/0017-legal-pages-cookie-consent/index.md index d77d512..a30c50c 100644 --- a/docs/specs/0017-legal-pages-cookie-consent/index.md +++ b/docs/specs/0017-legal-pages-cookie-consent/index.md @@ -23,7 +23,7 @@ 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 <date>" line, and its body from `src/lib/legal/content/<slug>.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-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 <threshold> 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. @@ -31,7 +31,7 @@ Legal pages 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-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 <date>. 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. @@ -42,7 +42,7 @@ 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":"<cookie policy updated>","at":<epoch ms>,"analytics":<bool>,"marketing":<bool>}`. 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-<ref>-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-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-<ref>-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. @@ -87,7 +87,7 @@ Reasoning and options: see [rationale.md](rationale.md). | `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`). +**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. @@ -97,8 +97,8 @@ Reasoning and options: see [rationale.md](rationale.md). 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 <JSON string>`. 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. +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 <JSON string>`, `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 <JSON string>`. 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 `<html>` 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. From 083e9e2163f1c0497e6ee286bd963fe4c573c651 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Tue, 20 Oct 2026 21:22:21 +0200 Subject: [PATCH 22/27] test(legal): prove the legal text guard fails a real next build Spec 0017 asked for the guard test to run next build; only the loader's unit tests covered it. A new Vitest build project copies the app under .next/, adds a fixture line to terms.md and builds it: a [[TO FILL]] marker with VERCEL_ENV=production and an unknown placeholder must each fail the build with the loader's message. CI runs it in the static job. The Turbopack rule's path condition now accepts a folder prefix, since the copy's Turbopack root is the repo, not the copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/ci.yml | 2 + AGENTS.md | 3 +- next.config.test.ts | 2 + next.config.ts | 6 +- package.json | 1 + src/lib/legal/AGENTS.md | 2 +- tests/build/legal-guard.build.test.ts | 95 +++++++++++++++++++++++++++ vitest.config.mts | 15 ++++- 8 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 tests/build/legal-guard.build.test.ts 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 1209a22..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) ``` @@ -70,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 diff --git a/next.config.test.ts b/next.config.test.ts index 940a099..8087603 100644 --- a/next.config.test.ts +++ b/next.config.test.ts @@ -17,6 +17,8 @@ describe("legal text bundling", () => { 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); }); diff --git a/next.config.ts b/next.config.ts index 5bb2827..323160c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,8 +2,10 @@ 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$/; +// A RegExp matches against the path relative to the Turbopack root, which is the folder holding +// the lockfile: the app folder here, a parent one for the build guard's copy +// (tests/build/legal-guard.build.test.ts), so the prefix may be any folder. +const legalContent = /(?:^|\/)src\/lib\/legal\/content\/[^/]+\.md$/; const nextConfig: NextConfig = { // Static catalog shell, request data behind Suspense (spec 0005, Caching model). 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/src/lib/legal/AGENTS.md b/src/lib/legal/AGENTS.md index 7a0244d..5f72e2e 100644 --- a/src/lib/legal/AGENTS.md +++ b/src/lib/legal/AGENTS.md @@ -19,7 +19,7 @@ The five legal texts (terms, privacy, returns, shipping, cookies) as Markdown in - The texts are never read with `fs`. `next.config.ts` runs `scripts/raw-text-loader.cjs` on `src/lib/legal/content/*.md` only, and `vitest.config.mts` has the same transform; a new legal file must live in `content/` to import. - Changing a text's meaning means changing its `updated` date. A new terms date makes open checkouts answer `terms_changed`; a new cookies date asks every visitor for consent again. - No Markdown `#` heading in a text (the title is the page `h1`) and no unknown `{{placeholder}}`: both fail `content.test.ts` or the build. -- Seller details come from `brand.legal` in `src/lib/brand/brand.ts`. A `null` field renders `[[TO FILL: seller.<field>]]`, and with `VERCEL_ENV=production` any marker throws at load, failing the build and `startCheckout`. +- Seller details come from `brand.legal` in `src/lib/brand/brand.ts`. A `null` field renders `[[TO FILL: seller.<field>]]`, and with `VERCEL_ENV=production` any marker throws at load, failing the build and `startCheckout`. `pnpm test:build` (`tests/build/legal-guard.build.test.ts`) proves it with a real `next build`. - `/shipping` keeps `{{deliveryFee}}` and `{{freeDeliveryRule}}` unfilled here; the page fills them per render from the cached shipping settings. - Format a version with `formatCalendarDate` (UTC), never through `STORE_TIMEZONE`. - The drafts are not legal advice: a lawyer reviews them before launch (spec 0017, Follow-up). diff --git a/tests/build/legal-guard.build.test.ts b/tests/build/legal-guard.build.test.ts new file mode 100644 index 0000000..b7c2112 --- /dev/null +++ b/tests/build/legal-guard.build.test.ts @@ -0,0 +1,95 @@ +import { spawnSync } from "node:child_process"; +import { appendFileSync, cpSync, mkdirSync, rmSync } from "node:fs"; +import path from "node:path"; + +import { afterAll, describe, expect, it } from "vitest"; + +import { validEnv } from "../valid-env"; + +// spec 0017, AC-4 and Build details 7: a blank left in a legal text, or an unknown placeholder, +// must fail `next build` itself, not only the loader's unit tests. Each case builds a copy of the +// app with one fixture line added to terms.md. The copy sits under .next/, which git, tsc, +// ESLint and Vitest all skip, and below the repo, so Turbopack finds the repo's lockfile and +// node_modules. The throw comes while Next collects page data, before any page reads the +// database, so no database is needed. + +const repo = path.resolve(import.meta.dirname, "../.."); +const workRoot = path.join(repo, ".next", "legal-guard"); + +// What `next build` reads; the test files are left out, as they import from tests/. +const appFiles = [ + "app", + "src", + "emails", + "public", + "scripts/raw-text-loader.cjs", + "proxy.ts", + "instrumentation.ts", + "next.config.ts", + "next-env.d.ts", + "tsconfig.json", + "postcss.config.mjs", + "package.json", +] as const; + +function appCopy(name: string, termsFixture: string): string { + const dir = path.join(workRoot, name); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(path.join(dir, "scripts"), { recursive: true }); + for (const file of appFiles) { + cpSync(path.join(repo, file), path.join(dir, file), { + recursive: true, + filter: (source) => !/\.test\.tsx?$/.test(source), + }); + } + appendFileSync(path.join(dir, "src/lib/legal/content/terms.md"), `\n${termsFixture}\n`); + return dir; +} + +function nextBuild(cwd: string, env: Readonly<Record<string, string>>) { + // Vitest sets NODE_ENV=test and EMAIL_TRANSPORT=log; a build runs as production, and a shell's + // VERCEL_ENV must not leak into the case that runs without it. + const { VERCEL_ENV: _vercelEnv, ...inherited } = process.env; + const result = spawnSync(path.join(repo, "node_modules/.bin/next"), ["build"], { + cwd, + env: { + ...inherited, + ...validEnv, + NODE_ENV: "production", + NEXT_TELEMETRY_DISABLED: "1", + ...env, + }, + encoding: "utf8", + timeout: 280_000, + }); + return { status: result.status, output: `${result.stdout}${result.stderr}` }; +} + +afterAll(() => rmSync(workRoot, { recursive: true, force: true })); + +describe("next build and the legal texts", () => { + it("fails a production build while a [[TO FILL]] marker is left", () => { + const dir = appCopy("marker", "[[TO FILL: build guard fixture]]"); + + const { status, output } = nextBuild(dir, { + VERCEL_ENV: "production", + // Production refuses any other transport (spec 0011, AC-16). + EMAIL_TRANSPORT: "resend", + RESEND_API_KEY: "re_buildguard", + }); + + expect(status).not.toBe(0); + expect(output).toMatch( + /terms\.md still has blanks to fill before launch: .*\[\[TO FILL: build guard fixture\]\]/, + ); + }); + + it("fails any build with an unknown placeholder", () => { + const dir = appCopy("placeholder", "{{build.guardFixture}}"); + + const { status, output } = nextBuild(dir, {}); + + expect(status).not.toBe(0); + expect(output).toContain("Unknown legal placeholder {{build.guardFixture}}"); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index fe7a01e..7127043 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -14,9 +14,10 @@ function rawLegalMarkdown(): Plugin { }; } -// Three projects: `unit` needs no database (`pnpm test`); `db` runs the *.db.test.ts suites +// Four 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`). +// the *.stack.test.ts suites against the local Supabase stack's auth schema (`pnpm test:stack`); +// `build` runs the *.build.test.ts suites, which call `next build` (`pnpm test:build`). export default defineConfig({ plugins: [rawLegalMarkdown()], resolve: { tsconfigPaths: true }, @@ -36,6 +37,7 @@ export default defineConfig({ "tests/e2e/**", "**/*.db.test.ts", "**/*.stack.test.ts", + "**/*.build.test.ts", ], unstubEnvs: true, }, @@ -60,6 +62,15 @@ export default defineConfig({ testTimeout: 20_000, }, }, + { + extends: true, + test: { + name: "build", + include: ["**/*.build.test.ts"], + exclude: ["node_modules/**", ".next/**"], + testTimeout: 300_000, + }, + }, ], }, }); From abf9d7a20fa1f8956e59e8a7f58872b5b03a05b7 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Wed, 21 Oct 2026 16:05:48 +0200 Subject: [PATCH 23/27] refactor(legal): run the one raw text loader and path pattern in Vitest The Vitest transform copied the loader's body and kept its own path pattern, which had already drifted from next.config.ts. Both now use scripts/raw-text-loader.cjs and src/lib/legal/content-path.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- next.config.ts | 6 +----- src/lib/legal/AGENTS.md | 2 +- src/lib/legal/content-path.ts | 6 ++++++ vitest.config.mts | 11 ++++++++--- 4 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 src/lib/legal/content-path.ts diff --git a/next.config.ts b/next.config.ts index 323160c..a64596c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,11 +1,7 @@ import type { NextConfig } from "next"; import { productImagesConfig } from "./src/lib/image-config"; - -// A RegExp matches against the path relative to the Turbopack root, which is the folder holding -// the lockfile: the app folder here, a parent one for the build guard's copy -// (tests/build/legal-guard.build.test.ts), so the prefix may be any folder. -const legalContent = /(?:^|\/)src\/lib\/legal\/content\/[^/]+\.md$/; +import { legalContent } from "./src/lib/legal/content-path"; const nextConfig: NextConfig = { // Static catalog shell, request data behind Suspense (spec 0005, Caching model). diff --git a/src/lib/legal/AGENTS.md b/src/lib/legal/AGENTS.md index 5f72e2e..1a71b5c 100644 --- a/src/lib/legal/AGENTS.md +++ b/src/lib/legal/AGENTS.md @@ -16,7 +16,7 @@ The five legal texts (terms, privacy, returns, shipping, cookies) as Markdown in ## Conventions -- The texts are never read with `fs`. `next.config.ts` runs `scripts/raw-text-loader.cjs` on `src/lib/legal/content/*.md` only, and `vitest.config.mts` has the same transform; a new legal file must live in `content/` to import. +- The texts are never read with `fs`. `next.config.ts` runs `scripts/raw-text-loader.cjs` on `src/lib/legal/content/*.md` only, and `vitest.config.mts` runs the same loader, both matching the one pattern in `content-path.ts`; a new legal file must live in `content/` to import. - Changing a text's meaning means changing its `updated` date. A new terms date makes open checkouts answer `terms_changed`; a new cookies date asks every visitor for consent again. - No Markdown `#` heading in a text (the title is the page `h1`) and no unknown `{{placeholder}}`: both fail `content.test.ts` or the build. - Seller details come from `brand.legal` in `src/lib/brand/brand.ts`. A `null` field renders `[[TO FILL: seller.<field>]]`, and with `VERCEL_ENV=production` any marker throws at load, failing the build and `startCheckout`. `pnpm test:build` (`tests/build/legal-guard.build.test.ts`) proves it with a real `next build`. diff --git a/src/lib/legal/content-path.ts b/src/lib/legal/content-path.ts new file mode 100644 index 0000000..76d3ea8 --- /dev/null +++ b/src/lib/legal/content-path.ts @@ -0,0 +1,6 @@ +// The legal texts that bundle as raw strings, shared by the Turbopack rule in next.config.ts and +// the Vitest transform, so the two never disagree. Turbopack matches the path relative to its +// root, the folder holding the lockfile: the app folder here, a parent one for the build guard's +// copy (tests/build/legal-guard.build.test.ts), so the prefix may be any folder. Vitest matches +// the absolute path. +export const legalContent = /(?:^|\/)src\/lib\/legal\/content\/[^/]+\.md$/; diff --git a/vitest.config.mts b/vitest.config.mts index 7127043..6d1c193 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,15 +1,20 @@ +import { createRequire } from "node:module"; + import { defineConfig, type Plugin } from "vitest/config"; -// The same as the raw text loader rule in next.config.ts: a legal text imports as its string. -const legalContent = /\/src\/lib\/legal\/content\/[^/]+\.md$/; +import { legalContent } from "./src/lib/legal/content-path"; + +const require = createRequire(import.meta.url); +const rawTextLoader = require("./scripts/raw-text-loader.cjs") as (source: string) => string; +// The raw text loader rule of next.config.ts, run by Vitest: a legal text imports as its string. 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 }; + return { code: rawTextLoader(code), map: null }; }, }; } From 29892745a9a08f977b26a481c8ce0570052ee4a8 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Wed, 21 Oct 2026 16:05:52 +0200 Subject: [PATCH 24/27] test(consent): wait for hydration instead of network idle A page that keeps fetching never reaches networkidle, so the first visit check timed out in CI. The footer's Cookie settings turning from a link into a button is a deterministic ready signal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/e2e/legal/consent.spec.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/e2e/legal/consent.spec.ts b/tests/e2e/legal/consent.spec.ts index c4a771f..40c9f4e 100644 --- a/tests/e2e/legal/consent.spec.ts +++ b/tests/e2e/legal/consent.spec.ts @@ -32,6 +32,17 @@ async function savedChoice(context: BrowserContext) { return cookie === undefined ? undefined : JSON.parse(decodeURIComponent(cookie.value)); } +// Hydrated once the footer's Cookie settings link has become a button; without scripts the link +// stays. A wait on "networkidle" is no ready signal: a page that keeps fetching never settles. +async function pageReady(page: Page, scripts: "on" | "off") { + const footer = page.getByRole("contentinfo"); + await expect( + scripts === "on" + ? footer.getByRole("button", { name: "Cookie settings" }) + : footer.getByRole("link", { name: "Cookie settings" }), + ).toBeVisible(); +} + async function openDemo(page: Page) { await page.goto("/style-guide"); // Ready once hydrated: the demo's state line leaves its waiting text. @@ -117,7 +128,7 @@ test.describe("the store", () => { for (const path of ["/", `/products/${socks.slug}`, "/cart", "/checkout"]) { await page.goto(path); - await page.waitForLoadState("networkidle"); + await pageReady(page, scripts); const names = (await page.context().cookies()).map((cookie) => cookie.name); expect(names.filter((name) => !necessary.test(name))).toEqual([]); if (scripts === "on") { @@ -133,7 +144,7 @@ test.describe("the store", () => { const socks = await seedProduct({ stock: 5, priceCents: 1250 }); await addToCart(page, socks); await page.goto("/checkout"); - await page.waitForLoadState("networkidle"); + await pageReady(page, "on"); expect((await page.context().cookies()).map((cookie) => cookie.name)).toEqual(["bestore_cart"]); expect(await page.evaluate(() => [localStorage.length, sessionStorage.length])).toEqual([0, 0]); From 75a2fd7c6b0125dcd7a81b3d93d1ca39e6edaa80 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Wed, 21 Oct 2026 16:05:56 +0200 Subject: [PATCH 25/27] test(dashboard): scope figure checks to their card A bare getByText also matched the hidden copy of a streamed segment, a strict mode violation on mobile that failed main as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/e2e/admin/dashboard.spec.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/e2e/admin/dashboard.spec.ts b/tests/e2e/admin/dashboard.spec.ts index 467eb56..41fa222 100644 --- a/tests/e2e/admin/dashboard.spec.ts +++ b/tests/e2e/admin/dashboard.spec.ts @@ -57,13 +57,18 @@ test("shows a period's figures, best sellers and chart table, and links to its o await page.goto(`/admin?from=${year}-03-01&to=${year}-03-31`); await expect(page.getByRole("heading", { level: 1, name: "Dashboard" })).toBeVisible(); - const orders = page.locator('[data-slot="card"]', { - has: page.getByRole("heading", { name: "Orders", level: 3 }), - }); + // Scoped through a card's heading: a bare getByText also matches the hidden copy of a + // streamed segment that React has not swapped in yet, a strict mode violation. + const card = (name: string) => + page.locator('[data-slot="card"]', { + has: page.getByRole("heading", { name, level: 3 }), + }); + const orders = card("Orders"); + const net = card("Net revenue"); await expect(orders.getByText("2", { exact: true })).toBeVisible(); - await expect(page.getByText("Gross €60.01")).toBeVisible(); - await expect(page.getByText("Refunds €0.00")).toBeVisible(); - await expect(page.getByText("€30.01", { exact: true })).toBeVisible(); + await expect(net.getByText("Gross €60.01")).toBeVisible(); + await expect(net.getByText("Refunds €0.00")).toBeVisible(); + await expect(card("Average order value").getByText("€30.01", { exact: true })).toBeVisible(); await expect(page.getByText("No earlier figure to compare").first()).toBeVisible(); await expect(page.getByRole("heading", { name: "Net revenue per day" })).toBeVisible(); await expect(page.getByRole("row", { name: /Dashboard e2e mug 3 €60\.01/ })).toBeVisible(); From ac58a64c5fde976ef6877bdbec940eec84ccce4d Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Wed, 21 Oct 2026 16:14:19 +0200 Subject: [PATCH 26/27] test(consent): wait for the empty cart checkout to land on /cart The streamed redirect navigated after hydration and destroyed the page the storage read ran in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/e2e/legal/consent.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/legal/consent.spec.ts b/tests/e2e/legal/consent.spec.ts index 40c9f4e..3bcfdfa 100644 --- a/tests/e2e/legal/consent.spec.ts +++ b/tests/e2e/legal/consent.spec.ts @@ -128,6 +128,9 @@ test.describe("the store", () => { for (const path of ["/", `/products/${socks.slug}`, "/cart", "/checkout"]) { await page.goto(path); + // An empty cart's /checkout sends the browser on to /cart once its stream redirects; the + // storage read must wait for that page, or the navigation destroys its context. + if (scripts === "on" && path === "/checkout") await expect(page).toHaveURL("/cart"); await pageReady(page, scripts); const names = (await page.context().cookies()).map((cookie) => cookie.name); expect(names.filter((name) => !necessary.test(name))).toEqual([]); From ae818f2afeb137192a06d867bae3e9f5492b56e3 Mon Sep 17 00:00:00 2001 From: kris1027 <kris1027.dev@gmail.com> Date: Wed, 21 Oct 2026 16:14:23 +0200 Subject: [PATCH 27/27] test(catalog): wait for the home grid before reading its order allTextContents does not wait, so it could read the grid before it streamed in and find neither product. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/e2e/catalog/categories-arrange.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/catalog/categories-arrange.spec.ts b/tests/e2e/catalog/categories-arrange.spec.ts index 93ee9e3..b29628d 100644 --- a/tests/e2e/catalog/categories-arrange.spec.ts +++ b/tests/e2e/catalog/categories-arrange.spec.ts @@ -124,10 +124,10 @@ test("products are arranged by keyboard and the home grid follows", async ({ const customer = await (await browser.newContext()).newPage(); await customer.goto("/"); - const cards = await customer - .getByRole("region", { name: "Shop all" }) - .getByRole("link") - .allTextContents(); + const shopAll = customer.getByRole("region", { name: "Shop all" }); + // allTextContents does not wait, and the grid streams in after the shell. + await expect(shopAll.getByRole("link").filter({ hasText: first.name })).toBeVisible(); + const cards = await shopAll.getByRole("link").allTextContents(); const card = (name: string) => cards.findIndex((text) => text.includes(name)); expect(card(first.name)).toBeGreaterThanOrEqual(0); expect(card(first.name)).toBeLessThan(card(second.name));