diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfcb715..e408556 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: - run: pnpm format:check - run: pnpm typecheck - run: pnpm test + # spec 0017, AC-4: a blank or unknown placeholder in a legal text fails `next build`. + - run: pnpm test:build db: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index badc42a..919b07c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,7 @@ pnpm lint && pnpm typecheck pnpm format # Prettier; format:check in CI pnpm test # Vitest unit + integration (*.test.ts beside the source) pnpm test:db # Vitest db project against TEST_DATABASE_URL (needs pnpm db:start) +pnpm test:build # Vitest build project: next build on a copy of the app (tests/build/) pnpm test:e2e # Playwright e2e (tests/e2e/*.spec.ts) ``` @@ -57,6 +58,7 @@ Stored in `docs/specs/`. Format: `docs/specs/NNNN-title/index.md`. Scope lives i - **Money and time**: integer minor units (cents) in `Int` columns, never floats; timestamps in UTC `timestamptz`, shown in `STORE_TIMEZONE`. - **Caching**: `cacheComponents` is on. Catalog reads are `'use cache'` functions tagged from `src/lib/cache-tags.ts`; whatever changes what they return must expire the tag (`updateTag` in a server action, `revalidateTag(tag, { expire: 0 })` in a route handler). Request data (cookies, the cart) sits behind Suspense. - **Status codes**: under Cache Components a page's `notFound()` / `redirect()` answer 200 (the shell streams first). Where the real status matters, decide it in the proxy before rendering (the admin gate does this). +- **Cookies and storage**: every new cookie or web storage key goes in the consent registry (`src/lib/consent/registry.ts`; its test catches unlisted cookies, not storage keys); anything not strictly necessary runs only behind `ConsentGate` / `useConsent()`. Rendering a page never sets a cookie. - **Cron**: handlers under `app/api/cron/` refuse anything but `Authorization: Bearer ${CRON_SECRET}` (constant time compare); schedules live in `vercel.json`. - **Env**: every variable the app reads is added to the Zod schema in `src/lib/env.ts` and to `.env.example`. A variable only a CLI reads (e.g. `SEND_EMAIL_HOOK_URI`, read by the Supabase CLI through `env()` in `supabase/config.toml`) goes in `.env.example` alone, marked as such. - **UI**: WCAG AA baseline on storefront and admin (labels, keyboard use, visible focus, contrast). @@ -69,7 +71,7 @@ Stored in `docs/specs/`. Format: `docs/specs/NNNN-title/index.md`. Scope lives i Installed: - ESLint (Next.js config, `@typescript-eslint/no-explicit-any: error`) + Prettier with `prettier-plugin-tailwindcss`; scripts `format`, `format:check`, `test`, `test:e2e`. Prettier skips `*.md` on purpose (skills and `next dev` own those files). - Pre commit (simple-git-hooks + lint-staged): lint and format staged files, then `pnpm typecheck`. `SKIP_SIMPLE_GIT_HOOKS=1` bypasses it in an emergency. -- CI: [.github/workflows/ci.yml](.github/workflows/ci.yml), on pushes to `main` and on every PR, as parallel jobs: `static` (lint, format check, typecheck, unit), `db` (`test:db`), `e2e` (4 Playwright shards, each with its own Supabase stack and build; shard 1 also runs `test:stack`), and a `check` gate that needs them all (the ruleset's required check, so it keeps that name). +- CI: [.github/workflows/ci.yml](.github/workflows/ci.yml), on pushes to `main` and on every PR, as parallel jobs: `static` (lint, format check, typecheck, unit, build guard), `db` (`test:db`), `e2e` (4 Playwright shards, each with its own Supabase stack and build; shard 1 also runs `test:stack`), and a `check` gate that needs them all (the ruleset's required check, so it keeps that name). ## Git @@ -105,5 +107,8 @@ MCP servers: Supabase (connected), Stripe (connected), Vercel (connected), Playw - [src/features/settings/AGENTS.md](src/features/settings/AGENTS.md): the admin settings page, the shipping fee and free delivery threshold - [src/features/account/AGENTS.md](src/features/account/AGENTS.md): customer accounts, sign up and sign in, the customer session and lock, order history, the address book, settings - [src/lib/shipping/AGENTS.md](src/lib/shipping/AGENTS.md): the shared delivery fee rule, address schema and settings reads +- [src/lib/legal/AGENTS.md](src/lib/legal/AGENTS.md): the five legal texts as bundled Markdown, their versions, placeholders and the production blank guard +- [src/lib/consent/AGENTS.md](src/lib/consent/AGENTS.md): the consent cookie, cookie registry, `ConsentProvider` / `useConsent` / `ConsentGate`, banner and settings dialog +- [src/features/legal/AGENTS.md](src/features/legal/AGENTS.md): the storefront legal pages, their Markdown renderer and the footer links _Drafted by /audit from the repo, worth a quick human pass. Edit freely: once a line stops matching this draft, later runs treat it as curated and will flag rather than overwrite it._ diff --git a/app/(store)/checkout/page.tsx b/app/(store)/checkout/page.tsx index 95ae432..04fe414 100644 --- a/app/(store)/checkout/page.tsx +++ b/app/(store)/checkout/page.tsx @@ -6,6 +6,7 @@ import { CheckoutSummary, CheckoutSummarySkeleton, } from "@/features/checkout/components/checkout-summary"; +import { currentTermsVersion } from "@/lib/legal/documents"; import { cn } from "@/lib/utils"; export const metadata: Metadata = { @@ -18,7 +19,7 @@ export default function CheckoutPage({ searchParams }: PageProps<"/checkout">) {

Checkout

}> - +
); diff --git a/app/(store)/cookies/page.tsx b/app/(store)/cookies/page.tsx new file mode 100644 index 0000000..9442b6b --- /dev/null +++ b/app/(store)/cookies/page.tsx @@ -0,0 +1,16 @@ +import { CookieTable } from "@/features/legal/components/cookie-table"; +import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page"; +import { cookieRegistry } from "@/lib/consent/registry"; +import { CookieSettingsButton } from "@/lib/consent/cookie-settings-control"; + +export const metadata = legalMetadata("cookies"); + +// spec 0017, AC-16 and AC-17: the policy, the registry table, and the settings button. +export default function CookiesPage() { + return ( + + + + + ); +} diff --git a/app/(store)/layout.tsx b/app/(store)/layout.tsx index 3c10c80..30ed794 100644 --- a/app/(store)/layout.tsx +++ b/app/(store)/layout.tsx @@ -6,27 +6,35 @@ import { CartLink, CartLinkView } from "@/features/cart/components/cart-link"; import { HeaderSearch } from "@/features/catalog/listing/components/search-box"; import { categoryPath } from "@/features/categories/paths"; import { getNavCategories } from "@/features/categories/queries"; +import { ConsentBanner } from "@/lib/consent/consent-banner"; +import { ConsentProvider } from "@/lib/consent/consent-provider"; +import { cookieRegistry } from "@/lib/consent/registry"; +import { cookiePolicyVersion } from "@/lib/legal/documents"; // The nav is a cached read tagged `categories`, so the shell stays static (spec 0013, AC-3). +// Consent is read in the browser only (spec 0017, AC-22), which keeps it static too. export default async function StoreLayout({ children }: LayoutProps<"/">) { const categories = await getNavCategories(); return ( - ({ - href: categoryPath(category.slug), - label: category.name, - }))} - actions={ - <> - - - }> - - - - } - > - {children} - + + } + navItems={categories.map((category) => ({ + href: categoryPath(category.slug), + label: category.name, + }))} + actions={ + <> + + + }> + + + + } + > + {children} + + ); } diff --git a/app/(store)/privacy/page.tsx b/app/(store)/privacy/page.tsx new file mode 100644 index 0000000..018e26d --- /dev/null +++ b/app/(store)/privacy/page.tsx @@ -0,0 +1,7 @@ +import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page"; + +export const metadata = legalMetadata("privacy"); + +export default function PrivacyPage() { + return ; +} diff --git a/app/(store)/returns/page.tsx b/app/(store)/returns/page.tsx new file mode 100644 index 0000000..4ecdd0d --- /dev/null +++ b/app/(store)/returns/page.tsx @@ -0,0 +1,7 @@ +import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page"; + +export const metadata = legalMetadata("returns"); + +export default function ReturnsPage() { + return ; +} diff --git a/app/(store)/shipping/page.tsx b/app/(store)/shipping/page.tsx new file mode 100644 index 0000000..ccb5ad4 --- /dev/null +++ b/app/(store)/shipping/page.tsx @@ -0,0 +1,20 @@ +import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page"; +import { shippingValues } from "@/features/legal/shipping-values"; +import { env } from "@/lib/env"; +import { getShippingSettings } from "@/lib/shipping/settings"; + +export const metadata = legalMetadata("shipping"); + +// spec 0017, AC-5: static plus the cached settings read, which an admin save expires. +export default async function ShippingPage() { + const settings = await getShippingSettings(); + return ( + + ); +} diff --git a/app/(store)/terms/page.tsx b/app/(store)/terms/page.tsx new file mode 100644 index 0000000..4d84df3 --- /dev/null +++ b/app/(store)/terms/page.tsx @@ -0,0 +1,7 @@ +import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page"; + +export const metadata = legalMetadata("terms"); + +export default function TermsPage() { + return ; +} diff --git a/docs/scope/scope.md b/docs/scope/scope.md index bfac72e..c00fd9d 100644 --- a/docs/scope/scope.md +++ b/docs/scope/scope.md @@ -30,7 +30,7 @@ _These are recommendations to keep your build orderly, not requirements. Skip an | 14 | Discounts & promo codes | Slice 8 | done | | 15 | Admin sales dashboard | Slice 9 | done | | 23 | Native controls in GET forms | Slice 9 follow up | done | -| 16 | Legal pages & cookie consent | Launch | planned | +| 16 | Legal pages & cookie consent | Launch | done | | 17 | SEO for storefront pages | Launch | planned | | 18 | Analytics & error tracking | Launch | planned | | 19 | Production deploy | Launch | planned | @@ -311,10 +311,18 @@ spec [0016](../specs/0016-native-controls-get-forms.md) · code in [src/componen ## Launch readiness -### 16. Legal pages & cookie consent · needs a decision +### 16. Legal pages & cookie consent · done Terms, privacy policy, returns and shipping policy, and a cookie banner that blocks non essential cookies until the customer agrees. **Done when:** the legal pages are linked from every page footer and from checkout; no analytics or marketing cookies are set before consent; the customer can change their choice later. -- [ ] Design it (spec): `/architect legal pages & cookie consent` +spec [0017](../specs/0017-legal-pages-cookie-consent/index.md) · code in [src/lib/legal/](../../src/lib/legal/), [src/lib/consent/](../../src/lib/consent/), [src/features/legal/](../../src/features/legal/) +- [x] Design it (spec): `/architect legal pages & cookie consent` +- [x] Build it: `/develop legal pages & cookie consent` + - [x] Thin thread: the terms page from Markdown, the footer link, the checkout checkbox recording the version on the order, the admin line and the confirmation email (AC-1, AC-2, AC-4, AC-7 to AC-11, AC-13, AC-14) + - [x] The other four pages with live shipping values, the model withdrawal form, print styles and the sign up notice (AC-1 to AC-3, AC-5 to AC-7, AC-12) + - [x] Consent core: the cookie, the registry, `ConsentProvider`, `useConsent` and `ConsentGate`, and the cookie table (AC-15, AC-17, AC-22) + - [x] Banner, settings dialog, withdrawal, GPC, the style guide demo and the first visit privacy e2e (AC-16, AC-18 to AC-21, AC-23, AC-24) +- [x] Verify it: `/check verify legal pages & cookie consent` +- [x] Test it: `/test legal pages & cookie consent` ### 17. SEO for storefront pages Make products findable: titles, descriptions, canonical links, sitemap, product rich results, and social share cards on every public page. diff --git a/docs/specs/0017-legal-pages-cookie-consent/index.md b/docs/specs/0017-legal-pages-cookie-consent/index.md new file mode 100644 index 0000000..a30c50c --- /dev/null +++ b/docs/specs/0017-legal-pages-cookie-consent/index.md @@ -0,0 +1,224 @@ +# 0017. Legal pages and cookie consent + +**Date**: 2026-10-20 +**Status**: Accepted + +## Summary + +The storefront gets five legal pages (terms, privacy, returns, shipping, cookies) written as Markdown files in the repo, linked from every store footer and from checkout. Checkout gains a required "I accept the terms" checkbox, and each new order records which terms version (the terms file's date) the customer accepted and when, shown to admins and in the order confirmation email. A small first party consent system (a cookie holding the customer's choice, a banner, a settings dialog and a gate component) is built now for feature 18 to plug analytics into; the banner stays hidden until a cookie that needs consent actually exists. One small migration adds two nullable columns to `orders`; nothing else touches the database. + +## Requirements + +**User stories**: +- As a customer, I want to read the terms, privacy, returns, shipping and cookie rules before I buy, so I know what I'm agreeing to and how to send something back. +- As a customer, I want to accept the terms at checkout and get a record of which version applied, so a later dispute is about the text I actually saw. +- As a customer, I want to refuse optional cookies as easily as I accept them, and change my mind later, so the store tracks me only if I say yes. +- As an admin, I want each order to show the terms version the customer accepted, so I handle a dispute against the right text. +- As the developer of feature 18, I want one small API that tells me whether analytics or marketing is allowed, so I never load a tracking script without consent. + +**Acceptance criteria** (the contract): + +Legal pages + +- **AC-1**: `/terms`, `/privacy`, `/returns`, `/shipping` and `/cookies` render inside the store shell. Each shows its title as the only `h1`, a "Last updated " line, and its body from `src/lib/legal/content/.md`. The date is the file's front matter `updated` (a `YYYY-MM-DD` calendar date), formatted as a long date in `STORE_LOCALE` with `timeZone: "UTC"`, so it never shifts by a day. Every page except `/shipping` is fully static; `/shipping` is static plus the cached settings read (AC-5). None reads cookies or other request data. +- **AC-2**: Each page sets `title` (the front matter `title`), `description` (the front matter `description`) and `alternates.canonical` (`NEXT_PUBLIC_SITE_URL` plus the path). The pages are indexable: no `noindex`, no `X-Robots-Tag`. +- **AC-3**: Every page that renders `StoreShell` (the store layout, `app/not-found.tsx`, the style guide) lists, in its footer nav, links to Terms of sale, Privacy policy, Returns, Shipping and Cookie policy (in that order), then a "Cookie settings" control (AC-16). The admin panel shows none of them. In print, the store header, the footer and the consent banner are hidden (`print:hidden`), so printing a legal page prints only its text. +- **AC-4**: The Markdown may use placeholders `{{name}}`. The seller placeholders (`{{seller.name}}`, `{{seller.address}}`, `{{seller.taxId}}`, `{{seller.registration}}`, `{{seller.email}}`, `{{seller.phone}}`) are filled from `brand.legal` in `src/lib/brand/brand.ts`; the store ones (`{{store.country}}`, `{{store.currency}}`) from `STORE_COUNTRY` (its display name in `STORE_LOCALE`) and `STORE_CURRENCY`; the shipping ones from AC-5. A placeholder with no known value throws when the module loads, so the build and `pnpm test` fail rather than ship `{{…}}` text. Drafts mark every store specific blank as `[[TO FILL: what goes here]]`; a `brand.legal` field still `null`, or any `[[TO FILL` left in a legal file, makes the loader throw when `VERCEL_ENV` is `production` (other environments render the marker as is, so the drafts stay reviewable). +- **AC-5**: `/shipping` fills `{{deliveryFee}}` with the flat fee and `{{freeDeliveryRule}}` with "Delivery is free on orders of or more." when a threshold is set, or "The same fee applies to every order." when it is not. Both come from `getShippingSettings()` (the existing cached read tagged `storeSettingsTag`), formatted in `STORE_CURRENCY` and `STORE_LOCALE`. Saving new values in `/admin/settings` changes the page on its next load with no deploy, because that save already expires the tag. +- **AC-6**: `/returns` covers the 14 day right of withdrawal and how to use it, and ends with a "Model withdrawal form" section holding the EU model form text, the seller's name, address and email filled in from AC-4, and a line saying it can be printed or copied into an email to `{{seller.email}}`. +- **AC-7**: Each draft covers at least the sections listed under *Content outline* below, in plain language, with a note at the top of each file (an HTML comment, not rendered) saying it is a draft that needs legal review before launch. + +Checkout and accounts + +- **AC-8**: The `/checkout` form shows an unticked checkbox above Pay, labelled "I accept the [terms of sale] and have read the [privacy policy]." Both links open in a new tab and say so in their accessible name ("terms of sale, opens in a new tab"), so the typed form survives. Under the Pay button, next to the existing Stripe line, one line links Returns and Shipping the same way. Pay with the box unticked shows "Tick the box to accept the terms of sale." under it, moves focus to it, and sends nothing to the server. The Pay button is never disabled because of the box. +- **AC-9**: `startCheckout` takes two new inputs: `acceptTerms` (must be `true`) and `termsVersion` (a `YYYY-MM-DD` string, the version the page showed). A missing or false `acceptTerms` returns `validation` with the AC-8 message on the `acceptTerms` field. A `termsVersion` that is not the current terms `updated` returns the new error `{ code: "terms_changed", currentVersion }`; so does a missing or malformed one, since the customer never saw the current text (it is not a field they type, so `validation` would have nothing to point at). Both checks run before any database work, so a refusal creates no order and takes no lock. +- **AC-10**: On `terms_changed` the form unticks the box, switches its hidden version to `currentVersion`, shows "Our terms of sale changed on . Please read them and tick the box again." with a link to `/terms` (new tab), above the box in an alert, and moves focus to the box. Every other field keeps its value. The next Pay with the box ticked succeeds. +- **AC-11**: A successful `startCheckout` writes `terms_version` (the accepted version) and `terms_accepted_at` (the server time at Pay) on the new order, in the same transaction that creates it. A database CHECK makes the two columns either both null or both set. Orders created before this feature keep both null. The PII purge (spec 0008) leaves both columns alone. +- **AC-12**: `/account/sign-up` shows, under the submit button, "By creating an account you accept the [terms of sale] and confirm you have read the [privacy policy]." Both links open in a new tab, as in AC-8. Nothing new is stored. +- **AC-13**: `/admin/orders/[id]` shows "Terms accepted: version , on " (the version as in AC-1, the time in `STORE_TIMEZONE`), or "Terms accepted: not recorded" when both columns are null. +- **AC-14**: The order confirmation email (spec 0011) gains, above its footer, the line "Terms of sale version applied to this order." and links to Terms of sale, Returns and Privacy policy (absolute URLs from `NEXT_PUBLIC_SITE_URL`). An order without a recorded version (paid before this feature) omits the version line and keeps the links. The template still never reads env (spec 0011, AC-18): both arrive as props. + +Cookie consent + +- **AC-15**: The choice lives in one first party cookie `bestore_consent`, written only by the browser (`document.cookie`, not `httpOnly`), `path=/`, `SameSite=Lax`, `Secure` when `location.protocol` is `https:`, `max-age` 12 months. Its value is URL encoded JSON `{"v":"","at":,"analytics":,"marketing":}`. A missing cookie, a value that fails the Zod schema, a `v` different from the current `/cookies` front matter `updated`, or an `at` older than 12 months all count as "no choice yet". The cookie is itself necessary (it records a choice) and is listed as such. +- **AC-16**: The "Cookie settings" footer control is a link to `/cookies` in the server HTML; after hydration it becomes a button that opens the settings dialog instead. `/cookies` also shows a "Cookie settings" button (after hydration) that opens the same dialog. The dialog lists Necessary (checked and disabled, "Always on"), Analytics and Marketing, each with a one line purpose, and has three buttons: Save choices, Accept all, Reject all. A category with no entry in the registry (AC-17) shows "Not used right now" and its checkbox is disabled and unticked. Saving writes the AC-15 cookie and closes the dialog. +- **AC-17**: `src/lib/consent/registry.ts` lists every cookie the store sets, with name (or a name pattern), category (`necessary`, `analytics`, `marketing`), purpose, lifetime and who sets it. It ships with the necessary ones only: `bestore_cart`, `bestore_consent`, the Supabase auth cookies (`sb--auth-token` and its numbered chunks) a line for Stripe's own cookies on its hosted payment page (set on Stripe's domain, not ours) and `sidebar_state` (the admin panel's sidebar, staff only). `/cookies` renders the registry as a table after its Markdown body. +- **AC-18**: The banner shows on storefront pages only, and only when all three hold: the registry has at least one `analytics` or `marketing` entry, there is no valid choice (AC-15), and the browser does not send Global Privacy Control (AC-21). With today's registry it never shows. It mounts after hydration, so the server HTML never contains it and a returning visitor never sees it flash. +- **AC-19**: The banner is a bar fixed to the bottom of the viewport that does not block the page and takes no focus. It is a `section` with `aria-label="Cookie consent"`, placed in the DOM right after the skip link so keyboard and screen reader users reach it early. It holds one or two sentences naming what optional cookies are for, a link to the cookie policy, and three buttons of the same size and style: Accept all, Reject all, Choose cookies (which opens the AC-16 dialog). While it shows, the page gets a bottom scroll padding equal to its height, so a focused element is never hidden behind it (WCAG 2.4.11). Accept all and Reject all write the cookie and hide the banner. +- **AC-20**: When a saved choice turns a category from on to off, the browser deletes every cookie the registry lists for that category (matching each registry name or pattern against the names currently in `document.cookie`, deleted on `path=/` for the host only, since the store sets no `Domain` attribute), writes the new choice, then reloads the page, so a script already running stops. Turning a category on needs no reload. +- **AC-21**: When `navigator.globalPrivacyControl === true` and there is no valid choice, analytics and marketing count as refused, the banner does not show and nothing is written. The customer can still opt in from Cookie settings; that saved choice then wins over the signal. +- **AC-22**: `src/lib/consent/` exports, for feature 18: `ConsentProvider` (mounted once in the store layout, taking the cookie policy version and the registry as props), `useConsent()` returning `{ ready, analytics, marketing, openSettings }`, and `` which renders its children only when `ready` and the category is granted. Before mount `ready` is false and every gate renders nothing. +- **AC-23**: On a first visit to the home page, a product page, `/cart` and `/checkout` (no consent cookie set), the browser holds no cookie outside the registry's `necessary` entries (checked with JavaScript on and off), and `localStorage` and `sessionStorage` hold no keys (checked with JavaScript on, the only case where a script could write them). +- **AC-24**: `/style-guide` (non production only, as today) gets a Consent section that renders the provider, banner and dialog with a demo registry holding one analytics cookie, so the banner, the three buttons, the dialog and the withdrawal reload can be exercised end to end. The demo cookie name starts with `demo_` and is set by the demo only. + +**Content outline** (what each draft must cover; `/develop` writes the text, a lawyer reviews it): +- **Terms of sale** (`/terms`): who the seller is (AC-4 placeholders); scope and how to contact; accounts (optional, how to delete); placing an order and when the contract is formed (on payment); prices (tax inclusive, `STORE_CURRENCY`) and payment by card through Stripe; promo codes (one per order, rules from spec 0014); delivery (points to `/shipping`); right of withdrawal (points to `/returns`); complaints (rękojmia, the statutory warranty for non conforming goods: how to file, 14 day answer); out of court dispute resolution; governing law; how the terms change and which version applies to an order (the one accepted at checkout). +- **Privacy policy** (`/privacy`): the controller (seller placeholders); what data is collected (checkout email, name, address, phone; account data; cart; order history); purposes and legal bases (contract, legal obligation for tax records, legitimate interest for fraud prevention); processors (Supabase for hosting and auth, Vercel for hosting, Stripe for payments, Resend for email); retention (expired order PII purged after the spec 0008 period, account deletion under spec 0012, paid orders kept for tax law); data subject rights and how to use them; the right to complain to the supervisory authority (UODO in Poland); cookies (points to `/cookies`). +- **Returns** (`/returns`): the 14 day withdrawal right from delivery, how to tell the seller, sending the goods back within 14 days, who pays return shipping, refunds within 14 days to the original card (spec 0010 refunds), exceptions; the model withdrawal form (AC-6). +- **Shipping** (`/shipping`): the one country served (`STORE_COUNTRY`), the live fee and free delivery rule (AC-5), carriers and typical delivery time (`[[TO FILL]]`), the shipped email with tracking (spec 0011). +- **Cookie policy** (`/cookies`): what cookies are, the three categories, how to change your choice (the Cookie settings button), GPC respected, then the registry table (AC-17). + +## Decision + +**Chosen option**: Markdown in the repo for the pages, a required and recorded terms checkbox at checkout, and a small first party consent system gated on the cookie registry. + +The five legal texts are Markdown files bundled into the build with a validated front matter date that doubles as the version; checkout records the accepted terms version on each order; consent is a browser written cookie read through a tiny React API, and the banner appears only once an optional cookie is registered. + +**Implementation skills**: `shadcn` (`shadcn-ui/ui`, `.agents/skills/shadcn/`) for the dialog, checkbox and alert · `prisma-cli` (`prisma/skills`, `.agents/skills/prisma-cli/`) for the migration · `supabase-postgres-best-practices` (`supabase/agent-skills`, `.agents/skills/supabase-postgres-best-practices/`) for the CHECK and the migration · `next-cache-components` (Vercel plugin skill) for keeping the legal pages and the store shell static. + +## Rationale + +Reasoning and options: see [rationale.md](rationale.md). + +## Feature design + +**Module layout** (shared code in `src/lib/`, so checkout, account, orders and emails never import another feature's internals): + +| Path | Owns | +|---|---| +| `src/lib/legal/content/{terms,privacy,returns,shipping,cookies}.md` | The texts, each with front matter `title`, `description`, `updated`. | +| `src/lib/legal/front-matter.ts` | Pure. Splits `---` front matter from the body and parses the three keys with Zod (no YAML dependency; only `key: value` lines). | +| `src/lib/legal/placeholders.ts` | Pure. `fillPlaceholders(body, values)`, throwing on an unknown `{{name}}`; `findFillMarkers(text)`. | +| `src/lib/legal/documents.ts` | `import "server-only"`. Imports the five files as raw strings, parses them once at module load, and exports `legalDocuments` (slug, path, title, description, `updated`, body) and `currentTermsVersion`. Applies the AC-4 production guard. | +| `src/lib/legal/paths.ts` | Pure, no server import. `legalPaths` (`/terms`, …) and `legalLinkLabels`, so client components and the email builder can link without the texts. | +| `src/features/legal/` | `components/legal-page.tsx` (title, date, body), `components/legal-markdown.tsx` (the renderer), `components/cookie-table.tsx`, `shipping-values.ts` (AC-5). | +| `app/(store)/{terms,privacy,returns,shipping,cookies}/page.tsx` | Thin routes: `generateMetadata`, then ``. | +| `src/lib/consent/consent.ts` | Pure. The cookie schema, `parseConsent(raw, policyVersion, nowMs)`, `serializeConsent`, `cookiesToDelete(previous, next, registry)`, `shouldShowBanner(...)`. | +| `src/lib/consent/registry.ts` | The AC-17 registry (data only). | +| `src/lib/consent/consent-provider.tsx`, `consent-gate.tsx`, `consent-banner.tsx`, `consent-dialog.tsx`, `cookie-settings-control.tsx` | `"use client"`. The React side of AC-15 to AC-22. | + +**Raw Markdown import**: the `.md` files are bundled as strings at build time (a Turbopack rule in `next.config.ts` running `scripts/raw-text-loader.cjs` on `src/lib/legal/content/*.md`, and the same transform for Vitest in its config), never read with `fs` at runtime, so a revalidated `/shipping` render and `startCheckout` never depend on file tracing. Add a `declare module "*.md"` typing beside them. Turbopack's own `raw` module type was tried first and dropped: it exports nothing a JavaScript import can read. + +**Renderer**: `legal-markdown.tsx` is its own renderer, not the product description one (that one drops relative links and demotes `h1`). Allowed: `h2`, `h3`, `p`, `ul`, `ol`, `li`, `strong`, `em`, `a`, `blockquote`, `hr`. Raw HTML skipped. Links allowed: relative paths starting with `/`, `#` anchors, `https:` and `mailto:`; anything else becomes plain text. A Markdown `#` heading is an error in the draft (the title is the page `h1`), caught by a unit test over the five files. + +**Build details** (settled in the cross check, so the build does not invent them): + +1. **Shell and provider.** `StoreShell`'s `footerLinks` defaults to the legal links from `src/lib/legal/footer-links.ts` (shared code, so the shell imports no feature) instead of `[]` (the style guide keeps passing its demo links), so `app/not-found.tsx` gets them too. The `CookieSettingsControl` works with no `ConsentProvider` above it: it then stays a plain link to `/cookies`. `ConsentProvider` and the banner are mounted only in `app/(store)/layout.tsx`. The admin panel never renders `StoreShell`, which is what keeps it free of legal links and the banner. `store-shell.test.ts` gets a mock for the new client control. +2. **Dates.** One new pure helper, `formatCalendarDate(date: Date, locale: string)` in `src/lib/dates.ts` (`dateStyle: "long"`, `timeZone: "UTC"`), used for every terms or policy version: the legal pages, the `terms_changed` alert, the admin line, the email. A `YYYY-MM-DD` string becomes `new Date(\`${ymd}T00:00:00Z\`)`, and Prisma returns `@db.Date` at UTC midnight, so it is never formatted through `formatDate(..., STORE_TIMEZONE)`. The acceptance time on the admin page uses the existing `formatDateTime` in `STORE_TIMEZONE`. Front matter `updated` is checked as a real calendar date with `parseCalendarDay` (rejects 2026-02-31). +3. **Email.** `EmailFormat` (`src/lib/email/props.ts`) gains `siteUrl`, filled by `emailFormat()` in `src/lib/email/content.ts` from `NEXT_PUBLIC_SITE_URL`. The confirmation select in `content.ts` adds `termsVersion`, and `ConfirmationSource` gains `termsVersion: Date | null`. The pure `confirmationProps` builds `termsVersionLabel` (with `formatCalendarDate`) and `legalLinks` (`siteUrl` + `legalPaths`). `fixtures.ts`, `PreviewProps` and the `render.test.ts` snapshots cover both a version and none. +4. **Checkout schema and action.** `acceptTerms` joins `checkoutSchema(country)` as `z.literal(true, { error: "Tick the box to accept the terms of sale." })`; the form default is `false`. `checkoutFields` and `checkoutFieldErrors` learn `acceptTerms`, so the server field error and focus work like any other field; `CheckoutPrefill` and `emptyPrefill` do not change. `termsVersion` is not a user field: `startCheckout(input)` reads it from the same input object but parses it with its own schema (`YYYY-MM-DD`, a real date), and the form holds it in component state from the page prop, never in an editable field. The version comparison is the first thing `run` does after parsing, before `getCustomer()` and any database call. `StartCheckoutError` gains the `terms_changed` arm. The form maps it to the AC-10 alert through its existing `FormNotice`, and `logCheckoutRefused` logs `code` only. The checkbox is wired with `aria-invalid` and `aria-describedby` to its error, like the other fields. +5. **Order write.** `run` takes `acceptedAt = new Date()` once, after the version check. `createPendingOrder` gains `terms: { version: Date; acceptedAt: Date }` and writes both columns in its create. A replaced or restarted checkout creates a new order with its own fresh values and copies nothing from the old one. The CHECK lives only in the SQL migration (Prisma can't express it). A db test proves it with a raw update that clears one column of a created order, and the purge db test (`purge-expired`) asserts both columns survive a purge. +6. **Raw Markdown.** `next.config.ts` gains a `turbopack.rules` entry running `scripts/raw-text-loader.cjs` (`export default `, `as: "*.js"`) on only `src/lib/legal/content/*.md` (scoped with a path condition, Turbopack only). `vitest.config.mts` gains a small inline plugin that turns those same files into `export default `. A `declare module "*.md"` (default export `string`) sits in `src/lib/legal/content/markdown.d.ts`. Check that `next dev` reloads on a text edit. +7. **Text normalisation and the guard.** The loader strips a leading BOM and turns CRLF into LF before parsing. The draft note is an HTML comment at the top of the body, and the loader removes it before `findFillMarkers` and rendering, so the note may mention the marker without tripping the guard. A seller placeholder whose `brand.legal` field is `null` renders `[[TO FILL: seller.address]]` (the field name), which the production guard then catches. The guard test runs `next build` with `VERCEL_ENV=production` against a fixture text holding a marker, and expects it to fail. At runtime the same throw also protects `startCheckout`, which imports `documents.ts`. +8. **Renderer.** `legal-markdown.tsx` is built on `react-markdown`, like the product renderer, with no plugins beyond its own element filter. External `https:` links get `rel="noopener noreferrer"`. Headings get no ids (no anchor links in v1). +9. **Hydration and the banner.** The link to button switch, the provider's first read and the banner use `useSyncExternalStore` with a server snapshot of "not ready", the pattern the checkout form already uses (`noSubscription`). While the banner shows, a `ResizeObserver` on it sets `scroll-padding-bottom` on `` to its height, and the padding is removed when it hides. Syncing a choice made in another tab is out of scope: the other tab picks it up on its next load. +10. **Registry.** `CART_COOKIE` moves to a pure module (`src/lib/cart/cookie-name.ts`) so the client registry imports it instead of repeating the string; `cookie.ts` imports it from there. A unit test greps `src/` and `app/` for cookie names the code sets (`cookies().set`, `document.cookie =`) and asserts each is in the registry. A policy version is a date, so the cookie policy can change at most once a day without a new version, which is accepted. +11. **Named files.** The admin line reads both columns through `getAdminOrder` in `src/features/orders/admin-queries.ts` and renders in `components/admin-order-detail.tsx`. The sign up notice goes in `src/features/account/components/sign-up-form.tsx`, with an e2e assertion on `/account/sign-up`. The checkout page (`app/(store)/checkout/page.tsx`) imports `currentTermsVersion` from `documents.ts` and passes it down to `checkout-form.tsx` as a prop. +12. **E2E GPC.** GPC is emulated with `page.addInitScript` defining `navigator.globalPrivacyControl` as `true`. No request header is used, because the store reads the signal only in the browser. +13. **Static rendering.** The legal pages need no `dynamic` or `revalidate` export: under `cacheComponents`, a page that reads no request data prerenders. `/shipping`'s cached settings read needs the database at build time, exactly like the catalog pages today. + +**Data model sketch**: + +| Table | Column | Type | Null | Notes | +|---|---|---|---|---| +| `orders` | `terms_version` | `date` | yes | The accepted terms `updated` date. | +| `orders` | `terms_accepted_at` | `timestamptz(3)` | yes | Server time at Pay. | + +- CHECK `orders_terms_acceptance_check`: `(terms_version IS NULL) = (terms_accepted_at IS NULL)`. +- No index (never filtered on). No new table, so no new RLS statement; `orders` already has RLS on. +- Prisma: `termsVersion DateTime? @map("terms_version") @db.Date`, `termsAcceptedAt DateTime? @map("terms_accepted_at") @db.Timestamptz(3)`. +- The consent choice is not in the database (cookie only, AC-15). + +**State transitions**: none on orders (the columns are written once at creation, never changed). Consent in the browser: `no choice` → `decided` (Accept all, Reject all, Save) → `decided` (Save again) ; `decided` → `no choice` when the cookie policy `updated` changes or 12 months pass. GPC is a default, not a state. + +**API surface**: + +| Surface | Kind | Key inputs | Key outputs | Auth | Key errors | +|---|---|---|---|---|---| +| `/terms`, `/privacy`, `/returns`, `/shipping`, `/cookies` | GET pages | none | static HTML | public | none (a broken file fails the build) | +| `startCheckout` (changed) | server action | existing inputs + `acceptTerms: true` (req), `termsVersion: string YYYY-MM-DD` (req) | `{ url }` as today | as today | `validation` (field `acceptTerms`), new `terms_changed { currentVersion }` | +| `ConsentProvider` / `useConsent` / `ConsentGate` | client API | `policyVersion`, `registry` | `{ ready, analytics, marketing, openSettings }` | browser only | invalid cookie reads as no choice | +| Order confirmation email (changed) | template props | `termsVersionLabel: string \| null`, `legalLinks: { terms, returns, privacy }` | email | n/a | none | + +**Value sourcing**: + +| Action | Value produced / displayed | Source | +|---|---|---| +| Legal page | title, description, "Last updated" | the file's front matter (`legalDocuments`) | +| Legal page | canonical URL | `NEXT_PUBLIC_SITE_URL` + `legalPaths[slug]` | +| Legal page | seller name, address, tax id, registration, email, phone | `brand.legal` in `src/lib/brand/brand.ts` (new block, all `string \| null`) | +| `/shipping` | delivery fee, free delivery rule | `getShippingSettings()` + `formatMoney` with `STORE_CURRENCY`, `STORE_LOCALE` | +| Any formatted calendar date | long date text | `Intl.DateTimeFormat(STORE_LOCALE, { dateStyle: "long", timeZone: "UTC" })` on `YYYY-MM-DDT00:00:00Z` | +| Checkout page | `termsVersion` the form sends | `currentTermsVersion`, read by the server page and passed to the form as a prop | +| `startCheckout` | current version to compare | `currentTermsVersion` | +| `startCheckout` | `terms_version`, `terms_accepted_at` | the validated input; `new Date()` at Pay | +| `terms_changed` alert | the new date | `currentVersion` in the error, formatted as above | +| Admin order page | version and time | `orders.terms_version`, `orders.terms_accepted_at` in `STORE_TIMEZONE` | +| Confirmation email | version line, links | `orders.terms_version` formatted by the sender; `NEXT_PUBLIC_SITE_URL` + `legalPaths` | +| Consent cookie | `v` | `/cookies` front matter `updated`, passed from the store layout to `ConsentProvider` | +| Consent cookie | `at` | `Date.now()` in the browser at the choice | +| Banner visibility | optional cookies exist | `registry` entries with category `analytics` or `marketing` | +| Banner visibility | GPC | `navigator.globalPrivacyControl` | +| Withdrawal | cookie names to delete | `cookiesToDelete(previous, next, registry)` | + +**Key invariants**: +- No order is created after this feature ships without `terms_version` and `terms_accepted_at`; the version stored equals the version the customer's form showed. +- The two columns are both null or both set (DB CHECK). +- No `analytics` or `marketing` cookie, script or storage exists in the browser unless `useConsent()` reports that category granted. Every optional cookie is in the registry; adding one means bumping the `/cookies` `updated` date, which asks everyone again. +- Refusing is never harder than accepting: Accept all and Reject all are always side by side with the same style, in the banner and the dialog. +- Rendering a page never sets a cookie (spec 0001); only the browser writes `bestore_consent`. +- The legal pages and the store shell stay static: neither the pages nor the provider read cookies on the server. +- Legal texts in production contain no `[[TO FILL` marker and no unfilled `{{…}}`. + +**Security model**: +- Compliance scope: GDPR (consent for optional cookies must be prior, informed, granular and as easy to withdraw as to give; ability to show what was offered), the ePrivacy rule on storing anything on a device (only strictly necessary storage without consent), EU and Polish consumer law (pre contract information, the 14 day withdrawal right with the model form, terms available free before the contract in a printable form). +- The legal pages are public and read only. `bestore_consent` is not trusted by the server for anything; it only controls what the browser loads. +- Terms acceptance is enforced on the server in `startCheckout`, never only in the form. +- The admin order page stays behind `requireAdmin()` as today; the new line adds no new data path. +- Audit trail: the order columns are the record of acceptance; consent proof rests on the cookie plus the banner code and registry in git history (the engineer chose no server log). +- Logs: `checkout.refused` carries `code: "terms_changed"` or the `validation` field names, never the email. + +**Configuration required**: no new environment variables. New values in `brand.legal` (code, filled before launch). `next.config.ts` and the Vitest config gain the raw Markdown rule. + +**Critical test scenarios**: +- Happy path: a guest ticks the box and pays; the order row has `terms_version` = the terms date and `terms_accepted_at` set; the admin order page and the confirmation email show the version, verifies **AC-8**, **AC-11**, **AC-13**, **AC-14** +- Failure case: `startCheckout` called with an older `termsVersion` returns `terms_changed`, creates no order (db test counts orders before and after), and the form shows the alert with the box unticked, verifies **AC-9**, **AC-10** +- Failure case: Pay unticked shows the field error and no request reaches the server; a direct call with `acceptTerms: false` returns `validation`, verifies **AC-8**, **AC-9** +- Consent: on the style guide demo, Reject all writes `analytics:false`, no `demo_` cookie appears, reload hides the banner; Accept all then turning analytics off in the dialog deletes the `demo_` cookie and reloads, verifies **AC-15**, **AC-19**, **AC-20**, **AC-24** +- Consent: with GPC emulated, the demo banner does not show and the gate stays closed until the customer opts in, verifies **AC-21** +- Privacy: a first visit to home, product, cart and checkout, with JavaScript on and off, leaves only necessary cookies and empty web storage, verifies **AC-23** +- Static: a build with a `{{unknown}}` placeholder fails; `VERCEL_ENV=production` with a `[[TO FILL` marker throws, verifies **AC-4** +- Live values: changing the fee in `/admin/settings` changes `/shipping` on the next load, verifies **AC-5** +- Permission: an anonymous visitor reaches every legal page; the admin panel shows no legal footer and no banner, verifies **AC-3**, **AC-18** + +## Build plan + +Tracer Bullet: the first slice runs one legal page through every layer (content file, loader, page, footer, checkout, database, admin, email) before the other pages and the consent system are added. + +1. **Thin thread, terms end to end.** Raw Markdown rule in `next.config.ts` and the Vitest config; `front-matter.ts`, `placeholders.ts`, `paths.ts`, `documents.ts` with unit tests; `brand.legal` block; `legal-markdown.tsx` and `legal-page.tsx`; `app/(store)/terms/page.tsx` with metadata; the terms draft; footer link through `StoreShell`'s `footerLinks`, satisfies **AC-1**, **AC-2**, **AC-4**, **AC-7** +2. Migration for the two `orders` columns and the CHECK; `startCheckout` takes `acceptTerms` and `termsVersion`, checks them before any database work, writes both columns in the order transaction; checkout form checkbox, field error, links and the Returns and Shipping line; the checkout page passes `currentTermsVersion`; db test plus e2e for tick and pay, satisfies **AC-8**, **AC-9**, **AC-11** +3. Admin order page "Terms accepted" line; confirmation email version line and links (props built in the sender, `PreviewProps` updated, both with and without a version), satisfies **AC-13**, **AC-14** +4. `terms_changed` handling in the form (alert, untick, new version, focus) with an e2e that calls the action with an old version, satisfies **AC-9**, **AC-10** +5. The other four pages and drafts: privacy, returns (with the model withdrawal form), shipping (with `shipping-values.ts` and the live settings), cookies; all five footer links in order; print styles; sign up notice; a unit test that every draft has front matter, no `#` heading and only known placeholders, satisfies **AC-1**, **AC-2**, **AC-3**, **AC-5**, **AC-6**, **AC-7**, **AC-12** +6. Consent core: `consent.ts` (schema, parse, serialize, `cookiesToDelete`, `shouldShowBanner`) with unit tests; `CART_COOKIE` moved to a pure module; `registry.ts` with the test that every cookie the code sets is listed; `ConsentProvider`, `useConsent`, `ConsentGate` mounted in the store layout; the cookie table on `/cookies`, satisfies **AC-15**, **AC-17**, **AC-22** +7. Settings dialog and the Cookie settings control (link before hydration, button after) in the footer and on `/cookies`; banner with the visibility rule, DOM position, scroll padding; withdrawal delete and reload; GPC, satisfies **AC-16**, **AC-18**, **AC-19**, **AC-20**, **AC-21** +8. Style guide Consent demo with the demo registry and its e2e (banner buttons, dialog, withdrawal reload, GPC through an init script defining `navigator.globalPrivacyControl`), and the first visit cookie and storage e2e with scripts on and blocked (`blockAppScripts`), plus axe checks on a legal page, the banner and the dialog, satisfies **AC-19**, **AC-20**, **AC-21**, **AC-23**, **AC-24** + +## Consequences + +**Positive**: +- Every order from now on carries proof of which terms applied, visible where disputes are handled. +- Feature 18 gets a ready consent API and only has to register its cookies and wrap its script in `ConsentGate`; the banner then appears by itself. +- The pages stay static and cheap, and every text change is a reviewed commit with history. +- No new dependency, no third party script, no new table. + +**Negative / tradeoffs**: +- Editing a legal text needs a developer and a deploy; admins cannot fix a typo themselves. +- Old versions of the terms are only in git history, not on the site; answering "show me the version from March" means checking out a commit. +- Consent proof is the cookie plus git history, not a server log; if a regulator asks for per visitor records, there are none. +- Checkout gains one more required step, which costs a little conversion, and a new refusal (`terms_changed`) a customer can hit when the terms change during their visit. +- The drafts are not legal advice; until a lawyer reviews them, launching on them is a risk the production guard only partly covers (it catches blanks, not wrong law). + +**Neutral**: +- One migration with two nullable columns and a CHECK; old orders read "not recorded". +- `next.config.ts` gains a Turbopack rule; Vitest gains the matching transform. +- A new `src/lib/legal/` and `src/lib/consent/` area, and a `src/features/legal/` feature, each worth a nested `AGENTS.md` at `/sync` time. + +## Follow-up + +- [ ] Language: the store sells to consumers in Poland, where consumer contract terms generally have to be offered in Polish. The drafts follow the store's current language (`STORE_LOCALE`, English today). Settle the final language with the lawyer before launch; a Polish version may mean switching `STORE_LOCALE` or adding translated files. +- [ ] Legal review of all five drafts and the checkout and sign up wording before launch (feature 19 should not ship while any `[[TO FILL` remains, which the AC-4 guard enforces). +- [ ] Fill `brand.legal` (and `brand.contactEmail`) with the real seller details. +- [ ] Feature 18: register each analytics cookie in `src/lib/consent/registry.ts`, bump the `/cookies` `updated` date, and wrap scripts in `ConsentGate`. If it picks a cookieless analytics tool, decide whether it needs consent at all; server side events (the paid webhook) carry no cookie and are outside this gate. +- [ ] Feature 17: add the five legal pages to the sitemap. +- [ ] `/sync`: add nested `AGENTS.md` files for `src/lib/legal/`, `src/lib/consent/` and `src/features/legal/`, and a root rule that every new cookie or web storage key goes in the consent registry. diff --git a/docs/specs/0017-legal-pages-cookie-consent/rationale.md b/docs/specs/0017-legal-pages-cookie-consent/rationale.md new file mode 100644 index 0000000..a442fdc --- /dev/null +++ b/docs/specs/0017-legal-pages-cookie-consent/rationale.md @@ -0,0 +1,135 @@ +# 0017. Legal pages and cookie consent: rationale + +The decision record behind [index.md](index.md). `/develop` builds from `index.md` and skips this file. + +## Context + +> ⚠️ Premise note: the store has no analytics or marketing cookies today; they arrive with feature 18. A banner asking for consent to cookies that don't exist collects meaningless consent and teaches customers to click past it. The right framing is to build and test the consent system now, and let the banner appear only once an optional cookie is registered. The engineer agreed. + +> ⚠️ Premise note: the store sells only to consumers in Poland (`storeCountries = ["PL"]`), and Polish law generally expects consumer terms in Polish, while the store's language is set by `STORE_LOCALE` (English today). Drafting in English is fine for review, but the launch text's language is a legal call, recorded as a Follow-up rather than decided here. + +BeStore is a single brand shop selling to consumers in one EU country (Poland), with guest and account checkout and card payment through Stripe's hosted page. Before launch it needs the texts a consumer shop must publish: terms of sale (the contract), a privacy policy (GDPR information duties), the withdrawal right with the EU model form, delivery terms, and a cookie policy. Polish law also requires the terms to be available free, before the contract, in a form the customer can save and print. The compliance scope is GDPR, the ePrivacy rule on device storage, and EU and Polish consumer law. + +The terms matter beyond being published. When a customer disputes an order, the store has to know which version of the terms applied. Today nothing links an order to any text. The terms will change over time (new promo rules, carriers, prices), so "the current terms" is not an answer for an order placed last year. + +On cookies, the app today sets only strictly necessary ones: the signed cart cookie and the Supabase auth cookies; no code touches `localStorage` or `sessionStorage`. Feature 18 (analytics and error tracking) will want to load tracking, and the scope says that must respect consent. EU regulators check that consent is prior (nothing loads before a yes), granular (per purpose), informed, and that refusing is as easy as accepting and withdrawing as easy as giving. Feature 18 should not have to design that from scratch. + +The stack shapes the solution: Cache Components keep the store shell static, so anything reading request cookies on the server pushes pages behind Suspense. Features must not import each other's internals, yet checkout, accounts, admin orders and the confirmation email all need to know the terms version and the legal paths. `react-markdown` is already installed for product descriptions, and `StoreShell` already has an empty `footerLinks` slot reserved for this feature. + +## Options considered + +### Where the legal text lives + +#### Option 1: Markdown files in the repo (chosen) + +One `.md` file per page with a small front matter block (`title`, `description`, `updated`), bundled into the build and rendered with `react-markdown`. + +**Pros**: +- Every change is a reviewed commit with author, diff and date; git history is the archive of old versions. +- Pages stay fully static with no new table, editor or cache tag. +- Reuses an installed dependency. + +**Cons**: +- Only a developer can change the text, and every change needs a deploy. +- Old versions are not browsable on the site. + +#### Option 2: Admin editable pages in the database + +A `legal_pages` table and a Markdown editor in `/admin`, with a cache tag per page. + +**Pros**: +- Non developers can edit without a deploy. +- Versions could be stored as rows and shown on the site. + +**Cons**: +- A migration, an editor page, actions, cache tags, tests, and a versioning scheme to get the "which version applied" guarantee right. +- Legal text changes stop being reviewed like code; a stray edit goes live instantly. + +#### Option 3: Text written in TSX pages + +The text lives directly in React components. + +**Pros**: +- No parser and no raw import rule. + +**Cons**: +- Legal prose mixed with markup is hard to edit and to review, and a lawyer cannot work on the file. + +### How terms acceptance is captured + +#### Option 1: Required checkbox, recorded on the order (chosen) + +An unticked checkbox above Pay, enforced by `startCheckout`, saving the terms version and time on the order. + +**Pros**: +- Standard practice for Polish shops, and proof of exactly which text applied to each order. +- Enforced on the server, so no client trick skips it. + +**Cons**: +- One more step at checkout, plus a migration and a new refusal when the terms change mid visit. + +#### Option 2: Required checkbox, not recorded + +**Pros**: +- No migration. + +**Cons**: +- No proof of which version applied to a disputed order. + +#### Option 3: Notice text only + +"By paying you accept the terms" under Pay. + +**Pros**: +- No friction, no new code path. + +**Cons**: +- Weaker under Polish practice, and still no record. + +#### Option 4: Stripe Checkout's terms checkbox + +`consent_collection.terms_of_service` on Stripe's hosted page. + +**Pros**: +- Stripe shows and enforces it. + +**Cons**: +- Acceptance happens after our order exists and lives only in Stripe; the order row and admin page know nothing. + +### How consent is managed + +#### Option 1: A small first party consent system (chosen) + +A browser written cookie with the choice, a registry of every cookie, a React provider with `useConsent` and `ConsentGate`, a shadcn styled banner and dialog. + +**Pros**: +- No third party script; the static shell, the visual style and the WCAG baseline stay under our control. +- The registry drives the cookie policy table and the banner's visibility, so they cannot drift apart. + +**Cons**: +- We own the edge cases (cookie deletion on withdrawal, GPC, re asking on policy change) and their tests. +- No automatic cookie scanning: a developer must register each new cookie. + +#### Option 2: An open source consent library (vanilla-cookieconsent) + +**Pros**: +- Banner, categories, script blocking and translations are done and widely used. + +**Cons**: +- Its own markup and styles fight the shadcn look; one more dependency to keep current; React integration is a wrapper around an imperative API. + +#### Option 3: A hosted consent platform (Cookiebot, CookieYes) + +**Pros**: +- Cookie scanning, a server side consent log, Google Consent Mode support. + +**Cons**: +- A third party script that must load before everything else on every page, a monthly cost, and a banner style we only partly control. + +## Rationale + +The texts belong in the repo because the hard requirement here is knowing which text applied to which order, and git already gives an exact, reviewed, dated history for free. With the version being the file's `updated` date, the order only needs to store that date: no versions table, no editor, no new cache tag. An admin editor would solve a problem this shop does not have yet (frequent non developer edits) and would make the important guarantee harder, because any edit would go live unreviewed. The cost, a deploy per text change, is acceptable for documents that change a few times a year. Bundling the files as raw strings (rather than reading them with `fs`) keeps `startCheckout` and the revalidated `/shipping` page independent of file tracing on Vercel. + +The recorded checkbox is chosen because the record is the point. Polish shops conventionally ask for explicit acceptance of the terms, and storing the version on the order turns "the customer agreed" into "the customer agreed to the 2026-10-20 text". Comparing the version the form showed with the current one closes the one gap left: a customer who opened checkout before a deploy is never bound to text they had no chance to open. The check runs before any lock or write, so it costs nothing on the hot path. Stripe's checkbox was rejected because the acceptance would land after our order exists and outside our data. + +A first party consent system fits because the store has no optional cookies yet, so a hosted platform would be paying for a scanner with nothing to scan, and both third party options add a script that sits in front of the static shell the project worked to keep. The registry is the piece that makes the small system safe: it is the single list behind the cookie policy table, the withdrawal deletion and the banner's visibility, so the store cannot show a banner for nothing or forget to delete a cookie it knows about. Mounting the banner after hydration keeps the server HTML free of request data; without JavaScript no optional script can run, so there is nothing to consent to. Keeping proof to the cookie (no server log) was the engineer's call; it is enough for a small first party shop, and the follow up is cheap if feature 18 brings a tool that needs a log. diff --git a/docs/specs/0017-legal-pages-cookie-consent/verify.md b/docs/specs/0017-legal-pages-cookie-consent/verify.md new file mode 100644 index 0000000..a2de5ef --- /dev/null +++ b/docs/specs/0017-legal-pages-cookie-consent/verify.md @@ -0,0 +1,49 @@ +# Verify: Legal pages & cookie consent · spec 0017 · updated 2026-10-20 +_Steps derived from spec 0017 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._ + +## UI / manual +- [x] Open `/terms`, `/privacy`, `/returns`, `/shipping`, `/cookies` → one `h1` with the front matter title, "Last updated October 20, 2026", the Markdown body, no `{{` text → AC-1 +- [x] View source of each legal page → `` is the front matter title, `meta description` set, `link rel=canonical` is `NEXT_PUBLIC_SITE_URL` + path, no `noindex` meta and no `X-Robots-Tag` header → AC-2 +- [x] On `/`, `/cart`, a 404 page and `/style-guide` → footer nav lists Terms of sale, Privacy policy, Returns, Shipping, Cookie policy, then Cookie settings (the style guide keeps its demo links, then Cookie settings) → AC-3 +- [x] Open `/admin` signed in → no legal footer links, no consent banner → AC-3, AC-18 +- [x] Print preview a legal page → header, footer and banner hidden, only the text prints → AC-3 +- [x] With `brand.legal` fields null, the pages show `[[TO FILL: seller.name]]` etc. outside production → AC-4 +- [x] `/returns` ends with "Model withdrawal form", the seller name, address and email filled in, and the "print it, or copy it into an email" line → AC-6 +- [x] Each draft starts with a DRAFT HTML comment (not rendered) and covers the content outline sections → AC-7 +- [x] `/checkout`: the box is unticked, its links and the Returns / Shipping line open in a new tab and announce it; Pay unticked shows "Tick the box to accept the terms of sale.", focuses the box, sends no request; Pay stays enabled → AC-8 +- [x] `/checkout`: make the page's version stale (edit `terms.md` `updated` while the page is open, then Pay ticked) → alert "Our terms of sale changed on <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 +- [x] `pnpm test` → legal loader, placeholders, front matter, content drafts, consent, registry, schemas, email render all pass → AC-4, AC-7, AC-9, AC-14, AC-15, AC-17, AC-20, AC-21 +- [x] `pnpm test:db tests/db/start-checkout.db.test.ts` → terms recorded, `terms_changed` and unticked create no order, CHECK refuses one column alone → AC-9, AC-11 +- [x] `pnpm test:db tests/db/purge-expired-orders.db.test.ts` → both terms columns survive the purge → AC-11 +- [x] `VERCEL_ENV=production pnpm build` → fails while any `[[TO FILL` is left → AC-4 +- [x] `pnpm test:e2e tests/e2e/legal` and `tests/e2e/admin/settings.spec.ts` → all pass → AC-1 to AC-3, AC-5, AC-8, AC-10, AC-12, AC-13, AC-16 to AC-24 + +## Value sourcing +- [ ] Change `updated` in `terms.md` → the page date, `currentTermsVersion`, the checkout's sent version and the stored `terms_version` all follow → Value sourcing: front matter +- [ ] Set `NEXT_PUBLIC_SITE_URL` to another origin → canonical URLs and email links follow → canonical, email links +- [ ] Fill one `brand.legal` field → it replaces its marker on every page → seller placeholders +- [x] Save fee 9.99 and threshold 50 in `/admin/settings` → `/shipping` shows "€9.99" and "Delivery is free on orders of €50.00 or more." on next load; clear the threshold → "The same fee applies to every order." → `/shipping` values +- [ ] Set `STORE_LOCALE=pl-PL` → dates read "20 października 2026" on pages, the admin line and the email, never a day off (version formatted in UTC) → calendar dates +- [ ] Accept at 23:30 Warsaw time → admin "on" time shows the Warsaw time, the version stays the file's date → admin version and time +- [x] Change `cookies.md` `updated` → a saved demo choice no longer counts, the banner asks again → consent cookie `v` +- [x] Set a consent cookie with `at` older than 12 months → reads as no choice → consent cookie `at` +- [x] Demo registry with an analytics entry → banner shows; real registry → never → banner visibility, optional cookies +- [x] GPC on → banner hidden → banner visibility, GPC +- [x] Withdraw analytics with `demo_analytics` set → only that cookie deleted → withdrawal cookie names + +## Acceptance-criteria coverage +- AC-1, AC-2: legal pages e2e, front matter and loader unit tests · AC-3: footer e2e, store shell unit, print e2e, admin e2e · AC-4: loader and content unit tests, production build · AC-5: settings e2e, shipping values unit · AC-6: content unit, returns e2e · AC-7: content unit · AC-8: terms box e2e · AC-9: schema unit, start-checkout db · AC-10: terms changed e2e · AC-11: start-checkout and purge db · AC-12: sign up e2e · AC-13: admin db and e2e · AC-14: render unit, order emails db · AC-15: consent unit · AC-16: store consent e2e · AC-17: registry unit, /cookies e2e · AC-18: store e2e · AC-19: demo e2e · AC-20: consent unit, demo e2e · AC-21: demo GPC e2e · AC-22: gate via demo e2e · AC-23: first visit e2e · AC-24: demo e2e diff --git a/emails/order-confirmation.tsx b/emails/order-confirmation.tsx index c1a10bb..72262f1 100644 --- a/emails/order-confirmation.tsx +++ b/emails/order-confirmation.tsx @@ -1,14 +1,16 @@ -import { Heading, Section, Text } from "react-email"; +import { Heading, Link, Section, Text } from "react-email"; import { AddressSection, bodyCellStyle, + colors, DataTable, type EmailAddressLines, type EmailFrame, EmailLayout, headerCellStyle, itemLabel, + mutedStyle, subheadingStyle, textStyle, } from "./components/layout"; @@ -37,6 +39,14 @@ export type OrderConfirmationProps = { readonly delivery: string; readonly total: string; readonly address: EmailAddressLines | null; + // spec 0017, AC-14: the terms version as a long date, or null for an order paid before it was + // recorded. The links are absolute; the template never reads env. + readonly termsVersionLabel: string | null; + readonly legalLinks: { + readonly terms: string; + readonly returns: string; + readonly privacy: string; + }; }; export function orderConfirmationSubject(orderNumber: number): string { @@ -103,10 +113,30 @@ export function OrderConfirmationEmail(props: OrderConfirmationProps) { <Text style={{ ...textStyle, marginTop: "24px" }}> We'll email you when your order ships. </Text> + {props.termsVersionLabel === null ? null : ( + <Text style={mutedStyle}> + Terms of sale version {props.termsVersionLabel} applied to this order. + </Text> + )} + <Text style={mutedStyle}> + <Link href={props.legalLinks.terms} style={legalLinkStyle}> + Terms of sale + </Link> + {" · "} + <Link href={props.legalLinks.returns} style={legalLinkStyle}> + Returns + </Link> + {" · "} + <Link href={props.legalLinks.privacy} style={legalLinkStyle}> + Privacy policy + </Link> + </Text> </EmailLayout> ); } +const legalLinkStyle = { color: colors.text, textDecoration: "underline" } as const; + function SummaryRow({ label, value, @@ -155,6 +185,12 @@ OrderConfirmationEmail.PreviewProps = { city: "Warszawa", countryName: "Poland", }, + termsVersionLabel: "October 20, 2026", + legalLinks: { + terms: "https://example.com/terms", + returns: "https://example.com/returns", + privacy: "https://example.com/privacy", + }, } satisfies OrderConfirmationProps; // React Email's preview server (`pnpm email:dev`) finds a template by its default export. diff --git a/eslint.config.mjs b/eslint.config.mjs index b252638..b7b423f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,6 +9,9 @@ const eslintConfig = defineConfig([ { rules: { "@typescript-eslint/no-explicit-any": "error", + // Destructuring a key out only to leave it off the rest (`{ a, ...rest }`) is how an + // immutable object drops a field. + "@typescript-eslint/no-unused-vars": ["warn", { ignoreRestSiblings: true }], // spec 0016: a GET form must work before the app's JavaScript loads, and a dead Select // fails silently (the form submits the value it was rendered with). "no-restricted-imports": [ diff --git a/next.config.test.ts b/next.config.test.ts index 0ccf8a5..8087603 100644 --- a/next.config.test.ts +++ b/next.config.test.ts @@ -9,6 +9,21 @@ describe("next config", () => { }); }); +// spec 0017: only the legal texts are bundled as strings, so no other Markdown changes meaning. +describe("legal text bundling", () => { + const rule = nextConfig.turbopack?.rules?.["*.md"]; + const path = (rule as { condition?: { path?: RegExp } } | undefined)?.condition?.path; + + it("runs the raw text loader on the legal content folder only", () => { + expect(rule).toMatchObject({ loaders: ["./scripts/raw-text-loader.cjs"], as: "*.js" }); + expect(path?.test("src/lib/legal/content/terms.md")).toBe(true); + expect(path?.test(".next/legal-guard/marker/src/lib/legal/content/terms.md")).toBe(true); + expect(path?.test("tests/fixtures/notsrc/lib/legal/content/terms.md")).toBe(false); + expect(path?.test("docs/specs/0017-legal-pages-cookie-consent/index.md")).toBe(false); + expect(path?.test("src/lib/legal/content/nested/terms.md")).toBe(false); + }); +}); + describe("confirmation page headers", () => { // spec 0006, AC-11 and spec 0012, AC-2: both URLs carry a key. it.each(["/checkout/complete", "/account/confirm"])( diff --git a/next.config.ts b/next.config.ts index 1d8c987..a64596c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; import { productImagesConfig } from "./src/lib/image-config"; +import { legalContent } from "./src/lib/legal/content-path"; const nextConfig: NextConfig = { // Static catalog shell, request data behind Suspense (spec 0005, Caching model). @@ -9,6 +10,17 @@ const nextConfig: NextConfig = { // prints admin passwords and TOTP codes to the terminal (spec 0004, AC-13). logging: { serverFunctions: false }, images: productImagesConfig(process.env.NEXT_PUBLIC_SUPABASE_URL), + // The legal texts are bundled as strings, never read with fs at runtime (spec 0017). Scoped to + // their folder, so no other Markdown file changes meaning. + turbopack: { + rules: { + "*.md": { + condition: { path: legalContent }, + loaders: ["./scripts/raw-text-loader.cjs"], + as: "*.js", + }, + }, + }, // The confirmation URL carries a Stripe session id, the key to the order it shows; it must // never reach another site through a Referer header or a search index (spec 0006, AC-11). async headers() { diff --git a/package.json b/package.json index c7875e0..ac8ff8b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "vitest run --project unit", "test:db": "tsx scripts/test-db.ts", "test:stack": "vitest run --project stack", + "test:build": "vitest run --project build", "test:e2e": "playwright test", "postinstall": "prisma generate && simple-git-hooks", "db:generate": "prisma generate", diff --git a/prisma/migrations/20261020164331_order_terms_acceptance/migration.sql b/prisma/migrations/20261020164331_order_terms_acceptance/migration.sql new file mode 100644 index 0000000..c3b6fdf --- /dev/null +++ b/prisma/migrations/20261020164331_order_terms_acceptance/migration.sql @@ -0,0 +1,13 @@ +-- spec 0017, AC-11: the terms of sale version accepted at Pay, and when. Both nullable: orders +-- from before this feature keep both null and the admin shows "not recorded". orders already has +-- RLS on, and the columns are never filtered on, so no index. +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "terms_accepted_at" TIMESTAMPTZ(3), +ADD COLUMN "terms_version" DATE; + +-- ─── Raw SQL: what Prisma cannot express (spec 0017) ──────────────────────── +-- CHECK constraints are invisible to `prisma migrate dev` drift detection, so they live only here. + +ALTER TABLE "orders" + ADD CONSTRAINT "orders_terms_acceptance_check" + CHECK (("terms_version" IS NULL) = ("terms_accepted_at" IS NULL)); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 606e738..5445822 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -406,6 +406,10 @@ model Order { expiredAt DateTime? @map("expired_at") @db.Timestamptz(3) // Set once by the expired order PII purge (spec 0008), never cleared. Only on expired orders. piiPurgedAt DateTime? @map("pii_purged_at") @db.Timestamptz(3) + // spec 0017, AC-11: the terms of sale version (its `updated` date) accepted at Pay, and when. + // Both or neither (a CHECK in the migration); null on orders from before the feature. + termsVersion DateTime? @map("terms_version") @db.Date + termsAcceptedAt DateTime? @map("terms_accepted_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) diff --git a/scripts/raw-text-loader.cjs b/scripts/raw-text-loader.cjs new file mode 100644 index 0000000..5e04a89 --- /dev/null +++ b/scripts/raw-text-loader.cjs @@ -0,0 +1,6 @@ +// A Turbopack loader (next.config.ts): a legal text becomes a module whose default export is the +// file's text, so the texts are bundled at build time and never read with fs (spec 0017). +// Turbopack's own `raw` module type exports nothing a JavaScript import can read. +module.exports = function rawTextLoader(source) { + return `export default ${JSON.stringify(source)};`; +}; diff --git a/scripts/raw-text-loader.test.ts b/scripts/raw-text-loader.test.ts new file mode 100644 index 0000000..bc5c175 --- /dev/null +++ b/scripts/raw-text-loader.test.ts @@ -0,0 +1,17 @@ +import { createRequire } from "node:module"; + +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const loader = require("./raw-text-loader.cjs") as (source: string) => string; + +// spec 0017: what Turbopack bundles for a legal text. +describe("raw text loader", () => { + it("exports the file text as the default string, quotes and newlines intact", () => { + const source = '---\ntitle: "A"\n---\n\nIt\'s `{{seller.name}}` \\ done\n'; + const code = loader(source); + + expect(code).toBe(`export default ${JSON.stringify(source)};`); + expect(JSON.parse(code.slice("export default ".length, -1))).toBe(source); + }); +}); diff --git a/src/components/form-notice.tsx b/src/components/form-notice.tsx index 280a8c8..34df9c3 100644 --- a/src/components/form-notice.tsx +++ b/src/components/form-notice.tsx @@ -1,9 +1,11 @@ import { CircleAlertIcon, InfoIcon } from "lucide-react"; +import type { ReactNode } from "react"; import { Alert, AlertDescription } from "@/components/ui/alert"; type FormNoticeProps = { - readonly message: string | null; + // Text, or text with a link (spec 0017, AC-10). + readonly message: ReactNode; // `error` is announced at once (role alert); `info` politely (role status). readonly tone?: "error" | "info"; readonly id?: string; diff --git a/src/components/inline-link.ts b/src/components/inline-link.ts new file mode 100644 index 0000000..22d421e --- /dev/null +++ b/src/components/inline-link.ts @@ -0,0 +1,4 @@ +// A link inside running text: underlined and in the body colour, so it reads as a link without +// relying on colour alone (WCAG 1.4.1). +export const inlineLinkClass = + "text-foreground underline underline-offset-4 hover:text-foreground/80"; diff --git a/src/components/layout/store-shell.test.ts b/src/components/layout/store-shell.test.ts index 18a2bfe..5c3394d 100644 --- a/src/components/layout/store-shell.test.ts +++ b/src/components/layout/store-shell.test.ts @@ -61,7 +61,24 @@ describe("StoreShell", () => { describe("footer links", () => { it("renders no footer nav when there are no links", () => { - expect(render()).not.toContain('aria-label="Footer"'); + expect(render({ footerLinks: [] })).not.toContain('aria-label="Footer"'); + }); + + // spec 0017, AC-3 and AC-16: the legal pages by default, in order, then Cookie settings, + // a plain link to /cookies with no ConsentProvider above (and in the server HTML). + it("lists the legal pages by default, then Cookie settings", () => { + const links = [...footer(render()).matchAll(/<a[^>]*href="([^"]+)"[^>]*>([^<]+)</g)] + .map((match) => [match[1], match[2]]) + .filter(([href]) => !href?.startsWith("mailto:") && href !== "/"); + + expect(links).toEqual([ + ["/terms", "Terms of sale"], + ["/privacy", "Privacy policy"], + ["/returns", "Returns"], + ["/shipping", "Shipping"], + ["/cookies", "Cookie policy"], + ["/cookies", "Cookie settings"], + ]); }); it("renders each link from the footerLinks prop in a labelled nav", () => { @@ -83,6 +100,23 @@ describe("StoreShell", () => { expect(render({ navItems: [] })).not.toContain("Open menu"); }); + // spec 0017, AC-19: the consent bar comes right after the skip link in the DOM. + it("places the banner right after the skip link", () => { + const html = render({ banner: createElement("section", { "aria-label": "Cookie consent" }) }); + + expect(html).toMatch( + /Skip to content<\/a><section aria-label="Cookie consent"><\/section><header/, + ); + }); + + // spec 0017, AC-3: printing a legal page prints only its text. + it("hides the header and the footer in print", () => { + const html = render(); + + expect(html).toMatch(/<header class="[^"]*print:hidden/); + expect(html).toMatch(/<footer class="[^"]*print:hidden/); + }); + it("renders header actions from the actions prop", () => { const html = render({ actions: createElement("button", { type: "button" }, "Cart") }); diff --git a/src/components/layout/store-shell.tsx b/src/components/layout/store-shell.tsx index 4a86351..400e254 100644 --- a/src/components/layout/store-shell.tsx +++ b/src/components/layout/store-shell.tsx @@ -3,6 +3,8 @@ import { Suspense } from "react"; import { Wordmark } from "@/components/wordmark"; import { brand } from "@/lib/brand/brand"; +import { CookieSettingsControl } from "@/lib/consent/cookie-settings-control"; +import { legalFooterLinks } from "@/lib/legal/footer-links"; import { cn } from "@/lib/utils"; import { storeContainer } from "./container"; @@ -11,11 +13,17 @@ import type { NavItem } from "./nav"; import { StoreDesktopNav, StoreMobileNav } from "./store-nav-menu"; import { storeNavItems } from "./store-nav"; +const footerLinkClass = + "inline-flex min-h-6 cursor-pointer items-center text-sm text-muted-foreground hover:text-foreground"; + type StoreShellProps = { // Header actions (search, account, cart), filled by features 13, 12 and 6. readonly actions?: React.ReactNode; - // Footer links (legal pages, feature 16). + // Footer links; the legal pages by default (spec 0017, AC-3), demo links in the style guide. readonly footerLinks?: readonly NavItem[]; + // The cookie consent bar, right after the skip link (spec 0017, AC-19). Only the store layout + // and the style guide demo pass one. + readonly banner?: React.ReactNode; // Defaults to the real list; the style guide passes demo links. readonly navItems?: readonly NavItem[]; readonly children: React.ReactNode; @@ -23,7 +31,8 @@ type StoreShellProps = { export function StoreShell({ actions, - footerLinks = [], + footerLinks = legalFooterLinks, + banner, navItems = storeNavItems, children, }: StoreShellProps) { @@ -35,7 +44,9 @@ export function StoreShell({ > Skip to content </a> - <header className="sticky top-0 z-40 h-(--header-height) border-b bg-background"> + {banner} + {/* Printing a legal page prints only its text (AC-3). */} + <header className="sticky top-0 z-40 h-(--header-height) border-b bg-background print:hidden"> <div className={cn(storeContainer, "flex h-full items-center gap-4")}> <div className="flex items-center gap-1"> {/* The navs read the pathname, which a route with unknown params (a product page) @@ -56,7 +67,7 @@ export function StoreShell({ <main id="main" tabIndex={-1} className="flex flex-1 flex-col"> {children} </main> - <footer className="border-t"> + <footer className="border-t print:hidden"> <div className={cn( storeContainer, @@ -82,14 +93,14 @@ export function StoreShell({ <ul className="flex flex-wrap gap-x-6 gap-y-2"> {footerLinks.map((link) => ( <li key={link.href}> - <Link - href={link.href} - className="inline-flex min-h-6 items-center text-sm text-muted-foreground hover:text-foreground" - > + <Link href={link.href} className={footerLinkClass}> {link.label} </Link> </li> ))} + <li> + <CookieSettingsControl className={footerLinkClass} /> + </li> </ul> </nav> ) : null} diff --git a/src/components/new-tab-link.tsx b/src/components/new-tab-link.tsx new file mode 100644 index 0000000..dc0c3d0 --- /dev/null +++ b/src/components/new-tab-link.tsx @@ -0,0 +1,23 @@ +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({ + href, + children, +}: { + readonly href: string; + readonly children: string; +}) { + return ( + <a + href={href} + target="_blank" + rel="noopener" + aria-label={`${children}, opens in a new tab`} + className={inlineLinkClass} + > + {children} + </a> + ); +} 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 ? <Spinner data-icon="inline-start" /> : null} Continue </Button> + {/* spec 0017, AC-12: nothing new is stored; the terms apply to orders placed later. */} + <p className="text-sm text-muted-foreground"> + By creating an account you accept the{" "} + <NewTabLink href={legalPaths.terms}>terms of sale</NewTabLink> and confirm you have read + the <NewTabLink href={legalPaths.privacy}>privacy policy</NewTabLink>. + </p> </FieldGroup> </form> ); 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/checkout/actions/start-checkout.ts b/src/features/checkout/actions/start-checkout.ts index 63c0a32..e11fc7a 100644 --- a/src/features/checkout/actions/start-checkout.ts +++ b/src/features/checkout/actions/start-checkout.ts @@ -11,7 +11,10 @@ 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, type CalendarDayText } 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"; import { orderLineImage } from "@/lib/orders/order-image"; @@ -35,7 +38,12 @@ import { logStripeFailed, stripeErrorCode, } from "../log"; -import { type CheckoutFieldErrors, checkoutFieldErrors, checkoutSchema } from "../schemas"; +import { + type CheckoutFieldErrors, + checkoutFieldErrors, + 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: CalendarDayText }; type Result<T> = ActionResult<T, StartCheckoutError>; @@ -81,8 +91,22 @@ async function run(input: unknown): Promise<Result<{ readonly url: string }>> { 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 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 }); + } + // 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 +121,7 @@ async function run(input: unknown): Promise<Result<{ readonly url: string }>> { 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; @@ -279,6 +303,7 @@ async function createPendingOrder( shipTo: ShipTo, account: CheckoutAccount | null, codeInput: unknown, + terms: TermsAcceptance, ): Promise<Result<CreatedOrder>> { try { const created = await db.$transaction( @@ -352,6 +377,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..eae3fe5 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, type CalendarDayText, 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: 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. @@ -112,6 +126,9 @@ export function CheckoutForm({ const [pending, startTransition] = useTransition(); const [redirecting, setRedirecting] = useState(false); 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<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( @@ -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({ </FieldLabel> </Field> ) : null} + <FormNotice + message={ + termsChangedOn === null ? null : ( + <> + Our terms of sale changed on{" "} + {formatCalendarDate(calendarDate(termsChangedOn), format.locale)}. Please read{" "} + <NewTabLink href={legalPaths.terms}>them</NewTabLink> and tick the box again. + </> + ) + } + /> + <TermsField control={form.control} error={errors.acceptTerms} /> <Button type="submit" size="lg" className="h-11" disabled={busy || !hydrated}> {busy ? <Spinner data-icon="inline-start" /> : null} {redirecting ? "Redirecting to payment…" : `Pay ${formatMoney(totalCents, format)}`} @@ -303,11 +340,54 @@ export function CheckoutForm({ <LockIcon aria-hidden="true" className="size-4 shrink-0" /> You enter your card details on Stripe's secure payment page. </p> + <p className="text-sm text-muted-foreground"> + Before you buy, see how <NewTabLink href={legalPaths.returns}>returns</NewTabLink> and{" "} + <NewTabLink href={legalPaths.shipping}>shipping</NewTabLink> work. + </p> </FieldGroup> </form> ); } +// spec 0017, AC-8: unticked to start, never disabling Pay. Pay unticked shows the error under +// the box and focuses it; the server refuses the same (AC-9). +function TermsField({ + control, + error, +}: { + readonly control: Control<CheckoutValues>; + readonly error: FormFieldError | undefined; +}) { + const id = "checkout-accept-terms"; + const errorId = `${id}-error`; + return ( + <Field orientation="horizontal" data-invalid={error ? true : undefined}> + <Controller + control={control} + name="acceptTerms" + render={({ field }) => ( + <Checkbox + id={id} + ref={field.ref} + checked={field.value === true} + onCheckedChange={(checked) => field.onChange(checked === true ? true : undefined)} + onBlur={field.onBlur} + aria-invalid={error ? true : undefined} + aria-describedby={error ? errorId : undefined} + /> + )} + /> + <FieldContent> + <FieldLabel htmlFor={id} className="inline leading-normal font-normal"> + I accept the <NewTabLink href={legalPaths.terms}>terms of sale</NewTabLink> and have read + the <NewTabLink href={legalPaths.privacy}>privacy policy</NewTabLink>. + </FieldLabel> + {error ? <FieldError id={errorId} errors={[error]} /> : null} + </FieldContent> + </Field> + ); +} + function TextField({ id, label, diff --git a/src/features/checkout/components/checkout-payment.tsx b/src/features/checkout/components/checkout-payment.tsx index a3a184e..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"; @@ -47,6 +48,8 @@ export function CheckoutPayment({ readonly countryName: string; readonly prefill: CheckoutPrefill; readonly account: CheckoutAccount | null; + // spec 0017, AC-9: the terms version the page shows. + readonly termsVersion: 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 acd4ac7..5129e00 100644 --- a/src/features/checkout/components/checkout-summary.tsx +++ b/src/features/checkout/components/checkout-summary.tsx @@ -2,6 +2,8 @@ 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"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -48,8 +50,11 @@ const cartPath = "/cart"; // (AC-9); Pay checks the code again under its lock. export async function CheckoutSummary({ searchParams, + termsVersion, }: { readonly searchParams: Promise<Record<string, string | string[] | undefined>>; + // spec 0017, AC-9: the current terms version, which Pay sends back. + readonly termsVersion: CalendarDayText; }) { const [cart, params] = await Promise.all([loadOwnedCart(), searchParams]); if (!cart || !cart.canCheckout) redirect(cartPath); @@ -164,10 +169,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> @@ -177,6 +179,7 @@ export async function CheckoutSummary({ countryName={countryDisplayName(env.STORE_COUNTRY, env.STORE_LOCALE)} prefill={prefill} account={account} + termsVersion={termsVersion} /> </section> </div> diff --git a/src/features/checkout/schemas.test.ts b/src/features/checkout/schemas.test.ts index 19a6328..e62202b 100644 --- a/src/features/checkout/schemas.test.ts +++ b/src/features/checkout/schemas.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { checkoutFieldErrors, checkoutSchema, sessionIdSchema } from "./schemas"; +import { + checkoutFieldErrors, + checkoutSchema, + sessionIdSchema, + startCheckoutInputSchema, +} from "./schemas"; // spec 0006, AC-1, AC-2 and AC-11; spec 0007, AC-2. @@ -13,6 +18,7 @@ const address = { postalCode: "00950", city: "Warsaw", phone: "", + acceptTerms: true, }; describe("checkoutSchema", () => { @@ -26,6 +32,7 @@ describe("checkoutSchema", () => { city: "Warsaw", phone: null, saveAddress: false, + acceptTerms: true, }); }); @@ -72,6 +79,28 @@ describe("checkoutSchema", () => { }); }); +// spec 0017, AC-8 and AC-9. +describe("acceptTerms", () => { + it.each([false, undefined, "true", 1])("refuses %j with the tick the box message", (value) => { + const result = schema.safeParse({ email: "a@example.com", ...address, acceptTerms: value }); + + if (result.success) throw new Error("expected the input to be refused"); + expect(checkoutFieldErrors(result.error)).toEqual({ + acceptTerms: "Tick the box to accept the terms of sale.", + }); + }); +}); + +describe("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", (termsVersion) => { + expect(startCheckoutInputSchema.safeParse({ termsVersion }).success).toBe(false); + }); +}); + describe("checkoutFieldErrors", () => { it("maps each invalid field to its first message", () => { const result = schema.safeParse({ ...address, email: "x", city: "", postalCode: "1" }); diff --git a/src/features/checkout/schemas.ts b/src/features/checkout/schemas.ts index 9b38baf..ba344fc 100644 --- a/src/features/checkout/schemas.ts +++ b/src/features/checkout/schemas.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { calendarDayTextSchema } from "@/lib/dates"; import { normalizeEmail } from "@/lib/discounts/schema"; import { shippingAddressSchema, type StoreCountry } from "@/lib/shipping/address"; @@ -7,6 +8,8 @@ import { shippingAddressSchema, type StoreCountry } from "@/lib/shipping/address export const checkoutMessages = { emailInvalid: "Enter a valid email address.", + // spec 0017, AC-8 and AC-9. + acceptTerms: "Tick the box to accept the terms of sale.", } as const; // Lowercasing and trimming twice gives the same email, so the server can parse the form's output. @@ -27,13 +30,20 @@ export function checkoutSchema(country: StoreCountry) { // spec 0014, AC-4: the applied promo code, checked again by Pay under the code lock. Any // value is passed on as typed: one that is not a code is refused as "This code isn't valid." discountCode: z.unknown().optional(), + // spec 0017, AC-8 and AC-9: unticked (the form's undefined), false or missing all refuse. + acceptTerms: z.literal(true, { error: checkoutMessages.acceptTerms }), }); } +// spec 0017, AC-9: the terms version the page showed, held in component state, never a field the +// customer edits. startCheckout parses it on its own, before any database work. +export const startCheckoutInputSchema = z.object({ termsVersion: calendarDayTextSchema }); + export type CheckoutValues = z.input<ReturnType<typeof checkoutSchema>>; export type CheckoutInput = z.output<ReturnType<typeof checkoutSchema>>; -export const checkoutFields = [ +// The typed fields, which a return visit can fill in. +const checkoutTextFields = [ "email", "fullName", "line1", @@ -43,10 +53,17 @@ export const checkoutFields = [ "phone", ] as const satisfies readonly (keyof CheckoutValues)[]; +// Every field a validation refusal can name, in the order the form shows them, so the first +// invalid one gets focus. +export const checkoutFields = [ + ...checkoutTextFields, + "acceptTerms", +] as const satisfies readonly (keyof CheckoutValues)[]; + export type CheckoutField = (typeof checkoutFields)[number]; // What a return visit fills in (spec 0007, AC-9); every field empty for a fresh cart. -export type CheckoutPrefill = Record<CheckoutField, string>; +export type CheckoutPrefill = Record<(typeof checkoutTextFields)[number], string>; export const emptyPrefill: CheckoutPrefill = { email: "", diff --git a/src/features/legal/AGENTS.md b/src/features/legal/AGENTS.md new file mode 100644 index 0000000..c1e1278 --- /dev/null +++ b/src/features/legal/AGENTS.md @@ -0,0 +1,24 @@ +# 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`. +- `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/features/legal/components/cookie-table.tsx b/src/features/legal/components/cookie-table.tsx new file mode 100644 index 0000000..a92be65 --- /dev/null +++ b/src/features/legal/components/cookie-table.tsx @@ -0,0 +1,54 @@ +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +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. +export function CookieTable({ registry }: { readonly registry: CookieRegistry }) { + return ( + <Table + containerProps={{ + tabIndex: 0, + role: "region", + "aria-labelledby": "cookie-table-caption", + className: "rounded-md border", + }} + className="min-w-xl" + > + <TableCaption id="cookie-table-caption" className="mt-0 mb-2 caption-top text-left"> + Every cookie the store sets + </TableCaption> + <TableHeader> + <TableRow> + <TableHead scope="col">Cookie</TableHead> + <TableHead scope="col">Category</TableHead> + <TableHead scope="col">Purpose</TableHead> + <TableHead scope="col">Lifetime</TableHead> + <TableHead scope="col">Set by</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {registry.map((entry) => ( + <TableRow key={entry.name}> + <TableCell className="align-top font-mono text-xs break-all whitespace-normal"> + {entry.name} + </TableCell> + <TableCell className="align-top whitespace-normal"> + {consentCategoryLabels[entry.category]} + </TableCell> + <TableCell className="align-top whitespace-normal">{entry.purpose}</TableCell> + <TableCell className="align-top whitespace-normal">{entry.lifetime}</TableCell> + <TableCell className="align-top whitespace-normal">{entry.setBy}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ); +} 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 [ + "<h2", + "<h3", + "<p", + "<strong", + "<em", + "<ul", + "<ol", + "<li", + "<blockquote", + "<hr", + ]) { + expect(html).toContain(tag); + } + }); + + it("never renders an h1, raw HTML or an image", () => { + const html = render( + "# Title\n\n<script>alert(1)</script><b>raw</b>\n\n![x](https://e.x/i.png)", + ); + expect(html).not.toMatch(/<h1|<script|<b>|<img/); + expect(html).toContain("Title"); + }); + + it("links relative pages and marks external ones noopener noreferrer", () => { + 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("<span>x</span>"); + }); +}); diff --git a/src/features/legal/components/legal-markdown.tsx b/src/features/legal/components/legal-markdown.tsx new file mode 100644 index 0000000..e733b46 --- /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"; + +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 +// HTML comment), and anything outside the list below is unwrapped to its text. + +export const allowedElements: readonly string[] = [ + "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 components: Components = { + h2: ({ children }) => ( + <h2 className="mt-6 font-heading text-2xl text-foreground md:text-3xl">{children}</h2> + ), + h3: ({ children }) => <h3 className="mt-2 font-heading text-xl text-foreground">{children}</h3>, + p: ({ children }) => <p className="leading-relaxed">{children}</p>, + ul: ({ children }) => <ul className="flex list-disc flex-col gap-1.5 pl-5">{children}</ul>, + ol: ({ children }) => <ol className="flex list-decimal flex-col gap-1.5 pl-5">{children}</ol>, + li: ({ children }) => <li className="leading-relaxed">{children}</li>, + strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>, + blockquote: ({ children }) => ( + <blockquote className="flex flex-col gap-3 rounded-md border bg-muted/40 p-5 text-foreground"> + {children} + </blockquote> + ), + hr: () => <hr className="border-border" />, + a: ({ href, children }) => { + if (!href) return <span>{children}</span>; + if (href.startsWith("/")) { + return ( + <Link href={href} className={inlineLinkClass}> + {children} + </Link> + ); + } + return ( + <a + href={href} + className={inlineLinkClass} + {...(href.startsWith("https:") ? { rel: "noopener noreferrer" } : {})} + > + {children} + </a> + ); + }, +}; + +export function LegalMarkdown({ markdown }: { readonly markdown: string }) { + return ( + <div className="flex flex-col gap-4"> + <Markdown + allowedElements={allowedElements} + unwrapDisallowed + skipHtml + urlTransform={legalUrl} + components={components} + > + {markdown} + </Markdown> + </div> + ); +} 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/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<Record<string, string>>; + // What follows the text: the cookie table and settings button on /cookies. + readonly children?: React.ReactNode; +}) { + const document = legalDocuments[slug]; + return ( + <article className={cn(storeContainer, "flex flex-col gap-8 py-12 md:py-16")}> + <header className="flex max-w-prose flex-col gap-3"> + <h1 className="font-heading text-4xl md:text-5xl">{document.title}</h1> + <p className="text-sm text-muted-foreground"> + Last updated{" "} + <time dateTime={document.updated}> + {formatCalendarDate(calendarDate(document.updated), env.STORE_LOCALE)} + </time> + </p> + </header> + <div className="flex max-w-prose flex-col gap-8"> + <LegalMarkdown markdown={fillPlaceholders(document.body, values)} /> + {children} + </div> + </article> + ); +} 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/features/orders/admin-queries.ts b/src/features/orders/admin-queries.ts index b4f8d01..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,6 +247,8 @@ export type AdminOrderDetail = { readonly shippedAt: Date | null; readonly deliveredAt: Date | null; readonly cancelledAt: 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; @@ -317,6 +320,8 @@ export async function getAdminOrder( shippedAt: true, deliveredAt: true, cancelledAt: true, + termsVersion: true, + termsAcceptedAt: true, carrier: true, trackingNumber: true, phone: true, @@ -463,6 +468,11 @@ export async function getAdminOrder( shippedAt: order.shippedAt, deliveredAt: order.deliveredAt, cancelledAt: order.cancelledAt, + // The CHECK keeps the two together. + terms: + order.termsVersion === null || order.termsAcceptedAt === null + ? null + : { version: order.termsVersion, acceptedAt: order.termsAcceptedAt }, carrier: order.carrier, trackingNumber: order.trackingNumber, phone: order.phone, diff --git a/src/features/orders/components/admin-order-detail.tsx b/src/features/orders/components/admin-order-detail.tsx index e322538..3545992 100644 --- a/src/features/orders/components/admin-order-detail.tsx +++ b/src/features/orders/components/admin-order-detail.tsx @@ -23,7 +23,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { type DateFormat, formatDateTime } from "@/lib/dates"; +import { type DateFormat, formatCalendarDate, formatDateTime } from "@/lib/dates"; import { discountLabel } from "@/lib/discounts/rule"; import { deliveryLabel } from "@/lib/shipping/rule"; import { cn } from "@/lib/utils"; @@ -277,6 +277,12 @@ export function AdminOrderDetail({ {formatDateTime(order.cancelledAt, dateFormat)} </DetailRow> ) : null} + {/* spec 0017, AC-13: which terms text applies in a dispute. */} + <DetailRow label="Terms accepted"> + {order.terms === null + ? "not recorded" + : `version ${formatCalendarDate(order.terms.version, dateFormat.locale)}, on ${formatDateTime(order.terms.acceptedAt, dateFormat)}`} + </DetailRow> {order.carrier || order.trackingNumber ? ( <DetailRow label="Tracking"> {[order.carrier, order.trackingNumber].filter(Boolean).join(", ")} diff --git a/src/features/style-guide/components/consent-demo.tsx b/src/features/style-guide/components/consent-demo.tsx new file mode 100644 index 0000000..d6cf2f2 --- /dev/null +++ b/src/features/style-guide/components/consent-demo.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect } from "react"; + +import { Button } from "@/components/ui/button"; +import type { CookieRegistry } from "@/lib/consent/consent"; +import { ConsentGate } from "@/lib/consent/consent-gate"; +import { useConsent } from "@/lib/consent/consent-provider"; + +// spec 0017, AC-24: the real registry has no optional cookie, so the banner never shows in the +// store. This demo registry adds one analytics cookie, set by the demo only, so the banner, the +// dialog and the withdrawal reload can be tried and tested end to end. +export const DEMO_ANALYTICS_COOKIE = "demo_analytics"; + +export const demoRegistry: CookieRegistry = [ + { + name: DEMO_ANALYTICS_COOKIE, + match: DEMO_ANALYTICS_COOKIE, + category: "analytics", + purpose: "A stand in analytics cookie, set by the style guide demo only.", + lifetime: "Session", + setBy: "The style guide demo", + }, +]; + +// Its own version, so a choice made here never grants anything in the real store. +export const demoPolicyVersion = "style-guide-demo"; + +function DemoAnalyticsCookie() { + useEffect(() => { + document.cookie = `${DEMO_ANALYTICS_COOKIE}=1; Path=/; SameSite=Lax`; + }, []); + return null; +} + +export function ConsentDemo() { + const consent = useConsent(); + const state = !consent.ready ? "Waiting for the page to load" : consent.analytics ? "On" : "Off"; + return ( + <div className="flex flex-col gap-4"> + <ConsentGate category="analytics"> + <DemoAnalyticsCookie /> + </ConsentGate> + <dl className="grid max-w-md grid-cols-[1fr_auto] gap-x-6 gap-y-2 text-sm"> + <dt className="text-muted-foreground">Demo analytics</dt> + <dd className="text-right" data-testid="consent-demo-analytics"> + {state} + </dd> + </dl> + <Button + variant="outline" + className="h-11 self-start" + disabled={!consent.ready} + onClick={consent.openSettings} + > + Open cookie settings + </Button> + </div> + ); +} 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 ( - <StoreShell navItems={demoNav} actions={<HeaderActions />} footerLinks={demoFooterLinks}> - <div className={cn(storeContainer, "flex flex-col gap-16 py-12 md:py-16")}> - <div className="flex max-w-2xl flex-col gap-3"> - <Badge variant="outline">Not shown in production</Badge> - <h1 className="font-heading text-4xl md:text-5xl">Style guide</h1> - <p className="text-lg text-muted-foreground"> - 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. - </p> - </div> + <ConsentProvider policyVersion={demoPolicyVersion} registry={demoRegistry}> + <StoreShell + navItems={demoNav} + actions={<HeaderActions />} + footerLinks={demoFooterLinks} + banner={<ConsentBanner />} + > + <div className={cn(storeContainer, "flex flex-col gap-16 py-12 md:py-16")}> + <div className="flex max-w-2xl flex-col gap-3"> + <Badge variant="outline">Not shown in production</Badge> + <h1 className="font-heading text-4xl md:text-5xl">Style guide</h1> + <p className="text-lg text-muted-foreground"> + 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. + </p> + </div> + + <ShowcaseSection + id="colors" + level="h2" + title="Colors" + description="Near black on white; photos carry the color. Components use these semantic tokens only." + > + <ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6"> + {colors.map(([name, value]) => ( + <li key={name} className="flex flex-col gap-2"> + <div + className="h-12 rounded-md border" + style={{ background: `var(--${name})` }} + aria-hidden="true" + /> + <div className="flex min-w-0 flex-col"> + <span className="truncate font-mono text-sm">--{name}</span> + <span className="font-mono text-xs text-muted-foreground">{value}</span> + </div> + </li> + ))} + </ul> + </ShowcaseSection> + + <ShowcaseSection + id="contrast" + level="h2" + title="Contrast" + description="Measured from the stylesheet at build time. Text needs 4.5:1; control borders and the focus ring need 3:1." + > + <Table> + <TableCaption>WCAG AA contrast for every token pair in use</TableCaption> + <TableHeader> + <TableRow> + <TableHead>Pair</TableHead> + <TableHead>Kind</TableHead> + <TableHead className="text-right">Ratio</TableHead> + <TableHead className="text-right">Needs</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {pairs.map((pair) => ( + <TableRow key={pair.label}> + {/* Wraps instead of scrolling sideways on phones. */} + <TableCell className="whitespace-normal">{pair.label}</TableCell> + <TableCell className="whitespace-normal text-muted-foreground"> + {pair.kind} + </TableCell> + <TableCell className="text-right tabular-nums"> + {pair.ratio.toFixed(2)}:1 + </TableCell> + <TableCell className="text-right tabular-nums"> + {pair.min}:1 {pair.ratio >= pair.min ? "✓" : "✗"} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </ShowcaseSection> - <ShowcaseSection - id="colors" - level="h2" - title="Colors" - description="Near black on white; photos carry the color. Components use these semantic tokens only." - > - <ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6"> - {colors.map(([name, value]) => ( - <li key={name} className="flex flex-col gap-2"> + <ShowcaseSection + id="type" + level="h2" + title="Typography" + description="Newsreader for storefront headings and the wordmark; Inter for body text, controls and all admin text. Body text is never below 16px." + > + <dl className="flex flex-col gap-6"> + {typeScale.map((style) => ( <div - className="h-12 rounded-md border" - style={{ background: `var(--${name})` }} - aria-hidden="true" - /> - <div className="flex min-w-0 flex-col"> - <span className="truncate font-mono text-sm">--{name}</span> - <span className="font-mono text-xs text-muted-foreground">{value}</span> + key={style.name} + className="grid gap-1 md:grid-cols-[12rem_1fr] md:items-baseline md:gap-6" + > + <dt className="text-sm text-muted-foreground">{style.name}</dt> + <dd className={style.className}>Quiet clothes for loud days</dd> </div> - </li> - ))} - </ul> - </ShowcaseSection> - - <ShowcaseSection - id="contrast" - level="h2" - title="Contrast" - description="Measured from the stylesheet at build time. Text needs 4.5:1; control borders and the focus ring need 3:1." - > - <Table> - <TableCaption>WCAG AA contrast for every token pair in use</TableCaption> - <TableHeader> - <TableRow> - <TableHead>Pair</TableHead> - <TableHead>Kind</TableHead> - <TableHead className="text-right">Ratio</TableHead> - <TableHead className="text-right">Needs</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {pairs.map((pair) => ( - <TableRow key={pair.label}> - {/* Wraps instead of scrolling sideways on phones. */} - <TableCell className="whitespace-normal">{pair.label}</TableCell> - <TableCell className="whitespace-normal text-muted-foreground"> - {pair.kind} - </TableCell> - <TableCell className="text-right tabular-nums"> - {pair.ratio.toFixed(2)}:1 - </TableCell> - <TableCell className="text-right tabular-nums"> - {pair.min}:1 {pair.ratio >= pair.min ? "✓" : "✗"} - </TableCell> - </TableRow> ))} - </TableBody> - </Table> - </ShowcaseSection> - - <ShowcaseSection - id="type" - level="h2" - title="Typography" - description="Newsreader for storefront headings and the wordmark; Inter for body text, controls and all admin text. Body text is never below 16px." - > - <dl className="flex flex-col gap-6"> - {typeScale.map((style) => ( - <div - key={style.name} - className="grid gap-1 md:grid-cols-[12rem_1fr] md:items-baseline md:gap-6" - > - <dt className="text-sm text-muted-foreground">{style.name}</dt> - <dd className={style.className}>Quiet clothes for loud days</dd> + </dl> + </ShowcaseSection> + + <ShowcaseSection + id="layout" + level="h2" + title="Layout, radius and images" + description="Mobile first on Tailwind's 4px grid, gap for spacing, a 7xl container. Radius is small (0.25rem). Product images are 4:5." + > + <div className="grid gap-10 lg:grid-cols-3"> + <div className="flex flex-col gap-3"> + <p className="text-sm font-medium">Spacing</p> + <ul className="flex flex-col gap-2"> + {spacing.map((step) => ( + <li key={step} className="flex items-center gap-3"> + <span className="w-8 font-mono text-xs text-muted-foreground">{step}</span> + <span + className="h-3 rounded-sm bg-primary" + style={{ width: `calc(var(--spacing) * ${step})` }} + aria-hidden="true" + /> + </li> + ))} + </ul> </div> - ))} - </dl> - </ShowcaseSection> - - <ShowcaseSection - id="layout" - level="h2" - title="Layout, radius and images" - description="Mobile first on Tailwind's 4px grid, gap for spacing, a 7xl container. Radius is small (0.25rem). Product images are 4:5." - > - <div className="grid gap-10 lg:grid-cols-3"> - <div className="flex flex-col gap-3"> - <p className="text-sm font-medium">Spacing</p> - <ul className="flex flex-col gap-2"> - {spacing.map((step) => ( - <li key={step} className="flex items-center gap-3"> - <span className="w-8 font-mono text-xs text-muted-foreground">{step}</span> - <span - className="h-3 rounded-sm bg-primary" - style={{ width: `calc(var(--spacing) * ${step})` }} - aria-hidden="true" - /> - </li> - ))} - </ul> - </div> - <div className="flex flex-col gap-3"> - <p className="text-sm font-medium">Radius</p> - <div className="flex flex-wrap gap-3"> - {["rounded-sm", "rounded-md", "rounded-lg", "rounded-xl"].map((radius) => ( - <div key={radius} className="flex flex-col items-center gap-1"> - <div className={cn("size-14 border bg-muted", radius)} aria-hidden="true" /> - <span className="font-mono text-xs text-muted-foreground">{radius}</span> - </div> - ))} + <div className="flex flex-col gap-3"> + <p className="text-sm font-medium">Radius</p> + <div className="flex flex-wrap gap-3"> + {["rounded-sm", "rounded-md", "rounded-lg", "rounded-xl"].map((radius) => ( + <div key={radius} className="flex flex-col items-center gap-1"> + <div className={cn("size-14 border bg-muted", radius)} aria-hidden="true" /> + <span className="font-mono text-xs text-muted-foreground">{radius}</span> + </div> + ))} + </div> </div> - </div> - <div className="flex flex-col gap-3"> - <p className="text-sm font-medium">Product image (aspect-product, placeholder)</p> - <div className="flex max-w-48 flex-col gap-2"> - <div className="relative aspect-product overflow-hidden rounded-md bg-muted"> - <Image src="/placeholder.svg" alt="" fill className="object-cover" /> + <div className="flex flex-col gap-3"> + <p className="text-sm font-medium">Product image (aspect-product, placeholder)</p> + <div className="flex max-w-48 flex-col gap-2"> + <div className="relative aspect-product overflow-hidden rounded-md bg-muted"> + <Image src="/placeholder.svg" alt="" fill className="object-cover" /> + </div> + <p className="text-sm">Linen shirt</p> + <Price cents={4900} className="text-sm text-muted-foreground" /> </div> - <p className="text-sm">Linen shirt</p> - <Price cents={4900} className="text-sm text-muted-foreground" /> </div> </div> - </div> - </ShowcaseSection> - - <ShowcaseSection - id="money" - level="h2" - title="Money" - description="Prices are integer minor units, formatted only by formatMoney and Price, in the store's locale and currency." - > - <dl className="grid max-w-md grid-cols-[1fr_auto] gap-x-6 gap-y-2 text-sm"> - <dt className="text-muted-foreground">Store currency</dt> - <dd className="text-right"> - <Price cents={1999} /> - </dd> - <dt className="text-muted-foreground">JPY (no minor unit)</dt> - <dd className="text-right"> - <Price cents={1999} currency="JPY" /> - </dd> - <dt className="text-muted-foreground">Negative (refund)</dt> - <dd className="text-right"> - <Price cents={-500} /> - </dd> - <dt className="text-muted-foreground">pl-PL, PLN</dt> - <dd className="text-right"> - <StoreFormatProvider locale="pl-PL" currency="PLN"> - <Price cents={1234567} /> - </StoreFormatProvider> - </dd> - </dl> - </ShowcaseSection> - - <Separator /> - - <section - id="components" - aria-labelledby="components-title" - className="flex flex-col gap-12" - > - <div className="flex max-w-2xl flex-col gap-1"> - <h2 id="components-title" className="font-heading text-3xl"> - Components - </h2> - <p className="text-muted-foreground"> - shadcn/ui on Base UI, in src/components/ui. Each one in the states it has. - </p> - </div> - <ComponentShowcase /> - </section> - </div> - </StoreShell> + </ShowcaseSection> + + <ShowcaseSection + id="money" + level="h2" + title="Money" + description="Prices are integer minor units, formatted only by formatMoney and Price, in the store's locale and currency." + > + <dl className="grid max-w-md grid-cols-[1fr_auto] gap-x-6 gap-y-2 text-sm"> + <dt className="text-muted-foreground">Store currency</dt> + <dd className="text-right"> + <Price cents={1999} /> + </dd> + <dt className="text-muted-foreground">JPY (no minor unit)</dt> + <dd className="text-right"> + <Price cents={1999} currency="JPY" /> + </dd> + <dt className="text-muted-foreground">Negative (refund)</dt> + <dd className="text-right"> + <Price cents={-500} /> + </dd> + <dt className="text-muted-foreground">pl-PL, PLN</dt> + <dd className="text-right"> + <StoreFormatProvider locale="pl-PL" currency="PLN"> + <Price cents={1234567} /> + </StoreFormatProvider> + </dd> + </dl> + </ShowcaseSection> + + <Separator /> + + <section + id="components" + aria-labelledby="components-title" + className="flex flex-col gap-12" + > + <div className="flex max-w-2xl flex-col gap-1"> + <h2 id="components-title" className="font-heading text-3xl"> + Components + </h2> + <p className="text-muted-foreground"> + shadcn/ui on Base UI, in src/components/ui. Each one in the states it has. + </p> + </div> + <ComponentShowcase /> + </section> + + <Separator /> + + <ShowcaseSection + id="consent" + level="h2" + title="Consent" + description="The cookie banner and settings dialog, with a demo registry holding one analytics cookie. The real store shows the banner only once an optional cookie is registered." + > + <ConsentDemo /> + </ShowcaseSection> + </div> + </StoreShell> + </ConsentProvider> ); } 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/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/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/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<HTMLElement>(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 ( + <section + ref={ref} + aria-label="Cookie consent" + className="fixed inset-x-0 bottom-0 z-40 border-t bg-background shadow-lg print:hidden" + > + <div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-4 sm:px-6 md:flex-row md:items-center md:justify-between lg:px-8"> + <p className="max-w-prose text-sm"> + We would like to use optional cookies to understand how the store is used. They stay off + unless you say yes. Read the{" "} + <Link href={legalPaths.cookies} className="underline underline-offset-4"> + cookie policy + </Link> + . + </p> + <div className="grid grid-cols-1 gap-2 sm:grid-cols-3 md:shrink-0"> + <Button + variant="outline" + className="h-11" + onClick={() => state.save(acceptAllChoices(state.registry))} + > + Accept all + </Button> + <Button variant="outline" className="h-11" onClick={() => state.save(refusedAll)}> + Reject all + </Button> + <Button variant="outline" className="h-11" onClick={state.openSettings}> + Choose cookies + </Button> + </div> + </div> + </section> + ); +} diff --git a/src/lib/consent/consent-dialog.tsx b/src/lib/consent/consent-dialog.tsx new file mode 100644 index 0000000..56f1c04 --- /dev/null +++ b/src/lib/consent/consent-dialog.tsx @@ -0,0 +1,176 @@ +"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 { legalPaths } from "@/lib/legal/paths"; + +import { + acceptAllChoices, + categoryUsed, + type Choices, + choicesFrom, + type ConsentCategory, + consentCategoryLabels, + type CookieRegistry, + type OptionalCategory, + optionalCategories, + refusedAll, +} from "./consent"; + +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). +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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent + className="sm:max-w-lg" + finalFocus={finalFocus === null ? true : () => finalFocus} + > + {/* Keyed by open, so each opening starts from the saved choice, not the last edits. */} + {open ? ( + <ConsentForm registry={registry} granted={granted} onSave={onSave} /> + ) : ( + <DialogHeader> + <DialogTitle>Cookie settings</DialogTitle> + </DialogHeader> + )} + </DialogContent> + </Dialog> + ); +} + +function ConsentForm({ + registry, + granted, + onSave, +}: { + readonly registry: CookieRegistry; + readonly granted: Choices; + readonly onSave: (next: Choices) => void; +}) { + const [choices, setChoices] = useState<Choices>(() => + choicesFrom((category) => granted[category] && categoryUsed(registry, category)), + ); + + return ( + <> + <DialogHeader> + <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={legalPaths.cookies}>cookie policy</a>{" "} + for every cookie we use. + </DialogDescription> + </DialogHeader> + <FieldSet className="gap-4"> + <legend className="sr-only">Cookie categories</legend> + {/* No data-disabled on the fields: it fades the label text below AA contrast. */} + <Field orientation="horizontal"> + <Checkbox + id="consent-necessary" + checked + disabled + aria-describedby="consent-necessary-hint" + /> + <FieldContent> + <FieldLabel htmlFor="consent-necessary"> + {consentCategoryLabels.necessary} + <span className="font-normal text-muted-foreground">Always on</span> + </FieldLabel> + <FieldDescription id="consent-necessary-hint"> + {categoryPurposes.necessary} + </FieldDescription> + </FieldContent> + </Field> + {optionalCategories.map((category) => ( + <CategoryField + key={category} + category={category} + used={categoryUsed(registry, category)} + checked={choices[category]} + onChange={(checked) => setChoices((current) => ({ ...current, [category]: checked }))} + /> + ))} + </FieldSet> + <DialogFooter className="sm:justify-between"> + <Button variant="outline" onClick={() => onSave(choices)}> + Save choices + </Button> + <div className="flex flex-col-reverse gap-2 sm:flex-row"> + <Button variant="outline" onClick={() => onSave(refusedAll)}> + Reject all + </Button> + <Button variant="outline" onClick={() => onSave(acceptAllChoices(registry))}> + Accept all + </Button> + </div> + </DialogFooter> + </> + ); +} + +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 ( + <Field orientation="horizontal"> + <Checkbox + id={id} + checked={used && checked} + disabled={!used} + onCheckedChange={(value) => onChange(value === true)} + aria-describedby={`${id}-hint`} + /> + <FieldContent> + <FieldLabel htmlFor={id}> + {consentCategoryLabels[category]} + {used ? null : ( + <span className="font-normal text-muted-foreground">Not used right now</span> + )} + </FieldLabel> + <FieldDescription id={`${id}-hint`}>{categoryPurposes[category]}</FieldDescription> + </FieldContent> + </Field> + ); +} 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<ConsentStore | null>(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<HTMLElement | null>(null); + // Made once; the props never change for a mounted layout. + const [store] = useState(() => + createConsentStore(policyVersion, registry, (element) => { + setOpener(element); + setSettingsOpen(true); + }), + ); + + return ( + <ConsentContext value={store}> + {children} + <SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} opener={opener} /> + </ConsentContext> + ); +} + +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 ( + <ConsentDialog + open={open} + onOpenChange={onOpenChange} + registry={state.registry} + granted={state.granted} + onSave={(next) => { + 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..49e6ad8 --- /dev/null +++ b/src/lib/consent/consent.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; + +import { + acceptAllChoices, + categoryUsed, + choicesFrom, + CONSENT_COOKIE, + CONSENT_MAX_AGE_SECONDS, + type ConsentChoice, + consentCookieString, + cookieNames, + cookiesToDelete, + type CookieRegistry, + entryMatches, + expiredCookieString, + grantedChoices, + optionalCategories, + 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]>): 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); + }); +}); + +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 new file mode 100644 index 0000000..3af2cbd --- /dev/null +++ b/src/lib/consent/consent.ts @@ -0,0 +1,181 @@ +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]; + +// 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]; + +// 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-<project ref>-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<Record<OptionalCategory, boolean>>; + +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(), +}); + +// 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). +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 : choicesFrom((category) => choice[category]); +} + +// 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 choicesFrom((category) => categoryUsed(registry, category)); +} 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 ( + <Link href={legalPaths.cookies} className={className}> + {label} + </Link> + ); + } + return ( + <button type="button" className={className} onClick={state.openSettings}> + {label} + </button> + ); +} + +// 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 ( + <Button variant="outline" className="h-11 self-start" onClick={state.openSettings}> + {label} + </Button> + ); +} 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-<project ref>-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/src/lib/dates.test.ts b/src/lib/dates.test.ts index 5bbda9a..0c765fc 100644 --- a/src/lib/dates.test.ts +++ b/src/lib/dates.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest"; import { addDays, + calendarDate, + calendarDayTextSchema, + formatCalendarDate, formatDate, formatDateTime, parseCalendarDay, @@ -27,6 +30,34 @@ 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(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(calendarDayTextSchema.parse("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"); + }); +}); + describe("formatDateTime", () => { it("shows the store's local time", () => { const moment = new Date("2026-03-31T23:30:00Z"); @@ -132,3 +163,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 b9f4425..c5653dd 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; @@ -18,6 +20,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: CalendarDayText): 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); @@ -29,6 +42,16 @@ 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). 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" }) + .brand<"CalendarDayText">(); + +export type CalendarDayText = z.output<typeof calendarDayTextSchema>; + 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/email/content.ts b/src/lib/email/content.ts index db9da74..84f9e69 100644 --- a/src/lib/email/content.ts +++ b/src/lib/email/content.ts @@ -30,6 +30,7 @@ function emailFormat(): EmailFormat { timeZone: env.STORE_TIMEZONE, storeName: brand.name, contactAddress: env.EMAIL_FROM_ADDRESS, + siteUrl: env.NEXT_PUBLIC_SITE_URL, }; } @@ -53,6 +54,7 @@ export async function loadEmailContent(row: ContentRow): Promise<EmailContent> { discountCode: true, shippingCents: true, totalCents: true, + termsVersion: true, ...deliveryAddressColumns, lines: { orderBy: lineOrder, diff --git a/src/lib/email/props.ts b/src/lib/email/props.ts index 0ec842d..4dbfc55 100644 --- a/src/lib/email/props.ts +++ b/src/lib/email/props.ts @@ -2,8 +2,9 @@ import type { EmailFrame } from "../../../emails/components/layout"; import type { OrderConfirmationProps } from "../../../emails/order-confirmation"; import type { OrderRefundedProps } from "../../../emails/order-refunded"; import type { OrderShippedProps } from "../../../emails/order-shipped"; -import { formatDate } from "@/lib/dates"; +import { formatCalendarDate, formatDate } from "@/lib/dates"; import { discountRowLabel } from "@/lib/discounts/rule"; +import { legalPaths, type LegalSlug } from "@/lib/legal/paths"; import { formatMoney } from "@/lib/money"; import type { DeliveryAddress } from "@/lib/shipping/address"; @@ -14,7 +15,10 @@ export type EmailFormat = { readonly locale: string; readonly timeZone: string; readonly storeName: string; + // The address part of EMAIL_FROM. readonly contactAddress: string; + // NEXT_PUBLIC_SITE_URL, for the legal links (spec 0017, AC-14). + readonly siteUrl: string; }; export type EmailContent = @@ -65,6 +69,8 @@ export type ConfirmationSource = OrderBasics & { readonly discountCode: string | null; readonly shippingCents: number; readonly totalCents: number; + // spec 0017, AC-14: the accepted terms version, a UTC midnight date; null before the feature. + readonly termsVersion: Date | null; readonly lines: readonly (SourceItem & { readonly unitPriceCents: number; readonly lineTotalCents: number; @@ -97,9 +103,21 @@ export function confirmationProps( delivery: order.shippingCents === 0 ? "Free" : money(order.shippingCents), total: money(order.totalCents), address: order.address, + termsVersionLabel: + order.termsVersion === null ? null : formatCalendarDate(order.termsVersion, format.locale), + legalLinks: { + terms: 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; diff --git a/src/lib/email/render.test.ts b/src/lib/email/render.test.ts index f7f70eb..c9cddb1 100644 --- a/src/lib/email/render.test.ts +++ b/src/lib/email/render.test.ts @@ -17,6 +17,7 @@ const format: EmailFormat = { timeZone: "Europe/Warsaw", storeName: "Shop", contactAddress: "orders@example.com", + siteUrl: "https://shop.example", }; const address = { @@ -59,6 +60,7 @@ function confirmation(overrides: Partial<Parameters<typeof confirmationProps>[0] discountCode: null, shippingCents: 999, totalCents: 10699, + termsVersion: new Date("2026-10-20T00:00:00.000Z"), lines, ...overrides, }, @@ -141,6 +143,7 @@ describe("the confirmation", () => { discountCode: null, shippingCents: 0, totalCents: 1000, + termsVersion: null, lines: [], }, { ...format, locale: "pl-PL" }, @@ -152,16 +155,91 @@ describe("the confirmation", () => { expect(props.paidDate).toBe("13 paź 2026"); }); - it("is accessible and private: one h1, presentation layout, a data table, no links or images", async () => { + // spec 0017, AC-14: the only links are the three legal pages, so nothing in the email leads + // back to the order or the checkout. + it("is accessible and private: one h1, presentation layout, a data table, only legal links, no images", async () => { const { html } = await renderEmail({ kind: "order_confirmation", props: confirmation() }); expect(html.match(/<h1/g)).toHaveLength(1); expect(html).toContain('role="presentation"'); expect(html).toContain('<th scope="col"'); - expect(html).not.toMatch(/<a\s/); + expect([...html.matchAll(/<a\s[^>]*href="([^"]+)"/g)].map((match) => match[1])).toEqual([ + "https://shop.example/terms", + "https://shop.example/returns", + "https://shop.example/privacy", + ]); expect(html).not.toMatch(/<img\s/); expect(html).not.toContain("checkout"); }); + + describe("terms of sale (spec 0017, AC-14)", () => { + it("names the accepted version as a long date, with the legal links", async () => { + const email = await renderEmail({ kind: "order_confirmation", props: confirmation() }); + + expect(email.text).toContain("Terms of sale version October 20, 2026 applied to this order."); + for (const text of ["Terms of sale", "Returns", "Privacy policy"]) { + expect(email.text).toContain(text); + } + }); + + it("leaves the version line out for an order paid before it was recorded, keeping the links", async () => { + const email = await renderEmail({ + kind: "order_confirmation", + props: confirmation({ termsVersion: null }), + }); + + expect(email.text).not.toContain("applied to this order"); + expect(email.html).toContain('href="https://shop.example/terms"'); + }); + + // 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( + { + number: 1, + customerName: null, + currency: "PLN", + address: null, + paidAt: new Date("2026-10-13T10:00:00Z"), + subtotalCents: 1000, + discountCents: 0, + discountCode: null, + shippingCents: 0, + totalCents: 1000, + termsVersion: new Date("2026-01-01T00:00:00.000Z"), + lines: [], + }, + { ...format, locale: "pl-PL", timeZone: "America/Los_Angeles" }, + ); + expect(props.termsVersionLabel).toBe("1 stycznia 2026"); + }); + }); }); describe("shipped", () => { diff --git a/src/lib/legal/AGENTS.md b/src/lib/legal/AGENTS.md new file mode 100644 index 0000000..1a71b5c --- /dev/null +++ b/src/lib/legal/AGENTS.md @@ -0,0 +1,27 @@ +# 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. +- `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. + +## 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` 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`. +- `/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 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 }; 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/src/lib/legal/content.test.ts b/src/lib/legal/content.test.ts new file mode 100644 index 0000000..61e8206 --- /dev/null +++ b/src/lib/legal/content.test.ts @@ -0,0 +1,110 @@ +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(/^<!--\s*DRAFT\b[\s\S]*?-->/); + }); + + // 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"); + }); + + // 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]); + 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 +--- + +<!-- +DRAFT. This policy was drafted from the store's cookie registry (spec 0017) and is not legal +advice. It needs a review by a lawyer before launch. The table below is built from +src/lib/consent/registry.ts; adding an optional cookie there means changing the date above, which +asks every visitor again. +--> + +## 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..236eed9 --- /dev/null +++ b/src/lib/legal/content/markdown.d.ts @@ -0,0 +1,6 @@ +// 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/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 +--- + +<!-- +DRAFT. This policy was drafted from how the store actually handles data (spec 0017) and is not +legal advice. It needs a review by a lawyer before launch. Every store specific blank is marked +TO FILL in double square brackets; a production build refuses to start while any is left. +--> + +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 +--- + +<!-- +DRAFT. This page was drafted from the store's own refund rules (spec 0017) and is not legal +advice. It needs a review by a lawyer before launch. Every store specific blank is marked TO FILL +in double square brackets; a production build refuses to start while any is left. +--> + +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 +--- + +<!-- +DRAFT. This page was drafted from the store's delivery settings (spec 0017) and is not legal +advice. It needs a review by a lawyer before launch. Every store specific blank is marked TO FILL +in double square brackets; a production build refuses to start while any is left. The delivery +fee and the free delivery rule below come from the admin settings and update on their own. +--> + +## 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 +--- + +<!-- +DRAFT. These terms were drafted from the store's own rules (spec 0017) and are not legal advice. +They need a review by a lawyer before launch. Every store specific blank is marked TO FILL in +double square brackets; a production build refuses to start while any is left. +--> + +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/footer-links.ts b/src/lib/legal/footer-links.ts new file mode 100644 index 0000000..45fe0da --- /dev/null +++ b/src/lib/legal/footer-links.ts @@ -0,0 +1,11 @@ +import type { NavItem } from "@/components/layout/nav"; + +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. Shared code, so the shell never imports a +// feature. +export const legalFooterLinks: readonly NavItem[] = legalSlugs.map((slug) => ({ + href: legalPaths[slug], + label: legalLinkLabels[slug], +})); 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..692a747 --- /dev/null +++ b/src/lib/legal/front-matter.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +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). + +const frontMatterSchema = z.object({ + title: z.string().min(1), + description: z.string().min(1), + updated: calendarDayTextSchema, +}); + +export type FrontMatter = z.output<typeof frontMatterSchema>; + +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<string, string> = {}; + 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<!-- DRAFT: mentions [[TO FILL]] -->\n\n${body}\n`; +} + +function sources(overrides: Partial<Record<LegalSlug, string>> = {}): Record<LegalSlug, string> { + const all = Object.fromEntries(legalSlugs.map((slug) => [slug, file(`${slug} body`)])); + return { ...all, ...overrides } as Record<LegalSlug, string>; +} + +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<!-- a -->\n<!-- b -->\ntext <!-- c -->")).toBe( + "text <!-- c -->", + ); + }); +}); diff --git a/src/lib/legal/load.ts b/src/lib/legal/load.ts new file mode 100644 index 0000000..495fdb2 --- /dev/null +++ b/src/lib/legal/load.ts @@ -0,0 +1,80 @@ +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"; +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: CalendarDayText; + // 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<Record<string, string>> { + 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\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<Record<LegalSlug, string>>, + options: { + readonly seller: SellerDetails; + readonly store: StoreFacts; + readonly production: boolean; + }, +): Readonly<Record<LegalSlug, LegalDocument>> { + 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<LegalSlug, LegalDocument>; +} diff --git a/src/lib/legal/paths.ts b/src/lib/legal/paths.ts new file mode 100644 index 0000000..61e9fb9 --- /dev/null +++ b/src/lib/legal/paths.ts @@ -0,0 +1,24 @@ +// 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]; + +export const legalPaths: Readonly<Record<LegalSlug, `/${LegalSlug}`>> = { + terms: "/terms", + privacy: "/privacy", + returns: "/returns", + shipping: "/shipping", + cookies: "/cookies", +}; + +// What the footer and other links call each page. +export const legalLinkLabels: Readonly<Record<LegalSlug, string>> = { + 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<Record<string, string>>, + 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/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/tests/db/admin-orders.db.test.ts b/tests/db/admin-orders.db.test.ts index fba1766..c4e9b6a 100644 --- a/tests/db/admin-orders.db.test.ts +++ b/tests/db/admin-orders.db.test.ts @@ -196,6 +196,21 @@ describe("getAdminOrder", () => { expect(detail?.events.map((event) => event.type)).toEqual(["created"]); }); + // spec 0017, AC-13. + it("returns the accepted terms version and time, or null when none was recorded", async () => { + const { order } = await seedPendingOrder(); + expect((await getAdminOrder("1001"))?.terms).toBeNull(); + + const version = new Date("2026-10-20T00:00:00.000Z"); + const acceptedAt = new Date("2026-10-20T21:30:00.000Z"); + await testDb.order.update({ + where: { id: order.id }, + data: { termsVersion: version, termsAcceptedAt: acceptedAt }, + }); + + expect((await getAdminOrder("1001"))?.terms).toEqual({ version, acceptedAt }); + }); + it("shows the name and SKU the customer bought, even after the catalog changes", async () => { const { product, variant } = await seedPendingOrder(); const bought = { productName: product.name, sku: variant.sku }; diff --git a/tests/db/discounts-checkout.db.test.ts b/tests/db/discounts-checkout.db.test.ts index 493ff3b..bbd3c4a 100644 --- a/tests/db/discounts-checkout.db.test.ts +++ b/tests/db/discounts-checkout.db.test.ts @@ -68,6 +68,7 @@ vi.mock("@/lib/customer/session", () => ({ })); const { startCheckout } = await import("@/features/checkout/actions/start-checkout"); +const { currentTermsVersion } = await import("@/lib/legal/documents"); const { applyDiscountCode } = await import("@/features/checkout/actions/apply-discount"); const { prefillDiscountCode } = await import("@/features/checkout/queries"); const { countRedemptions, lockCode, previewDiscount, redeem } = @@ -99,6 +100,8 @@ const address = { postalCode: "00950", city: "Warsaw", phone: "", + acceptTerms: true, + termsVersion: currentTermsVersion, }; function pay(discountCode?: string, email = "a@example.com") { diff --git a/tests/db/order-emails.db.test.ts b/tests/db/order-emails.db.test.ts index d2dfa3b..a969b23 100644 --- a/tests/db/order-emails.db.test.ts +++ b/tests/db/order-emails.db.test.ts @@ -49,6 +49,7 @@ vi.mock("@/lib/env", () => ({ EMAIL_TRANSPORT: "log", EMAIL_FROM: "BeStore <orders@example.com>", EMAIL_FROM_ADDRESS: "orders@example.com", + NEXT_PUBLIC_SITE_URL: "https://shop.example", }, })); vi.mock("@/lib/stripe", async () => ({ @@ -225,6 +226,29 @@ describe("the confirmation (AC-1, AC-8, AC-16)", () => { expect(scheduledIds()).toEqual([]); }); + // spec 0017, AC-14: the version comes from the order row, formatted at send time. + it.each([ + ["names the accepted terms version", new Date("2026-10-20T00:00:00.000Z"), true], + ["leaves the version out for an order without one", null, false], + ])("%s, with the legal links", async (_, termsVersion, named) => { + const { order } = await seedPendingOrder(); + await testDb.order.update({ + where: { id: order.id }, + data: { termsVersion, termsAcceptedAt: termsVersion === null ? null : new Date() }, + }); + await post( + sessionEvent("checkout.session.completed", { orderId: order.id, amountTotal: 5000 }), + ); + const { transport, sent } = stubTransport(); + + await deliver((await onlyEmail(order.id)).id, transport); + + expect(sent[0]?.text.includes("Terms of sale version October 20, 2026 applied")).toBe(named); + expect(sent[0]?.html).toContain('href="https://shop.example/terms"'); + expect(sent[0]?.html).toContain('href="https://shop.example/returns"'); + expect(sent[0]?.html).toContain('href="https://shop.example/privacy"'); + }); + it("queues nothing and schedules nothing when the paid transaction rolls back", async () => { const { order } = await seedPendingOrder(); mocks.failAfterPaid = true; diff --git a/tests/db/purge-expired-orders.db.test.ts b/tests/db/purge-expired-orders.db.test.ts index 89df7f4..704cd0f 100644 --- a/tests/db/purge-expired-orders.db.test.ts +++ b/tests/db/purge-expired-orders.db.test.ts @@ -80,6 +80,9 @@ async function seedOrder( shipCountryCode: "FR", stripeCheckoutSessionId: `cs_test_${order.number}`, stripePaymentIntentId: `pi_test_${order.number}`, + // spec 0017, AC-11: the accepted terms are not personal data, so the purge keeps them. + termsVersion: new Date("2026-10-20T00:00:00.000Z"), + termsAcceptedAt: new Date("2026-10-20T09:15:00.000Z"), }, }); } @@ -127,6 +130,8 @@ const keptFields = [ "stripePaymentIntentId", "needsAttention", "expiredAt", + "termsVersion", + "termsAcceptedAt", "lines", "events", ] as const; diff --git a/tests/db/start-checkout.db.test.ts b/tests/db/start-checkout.db.test.ts index 13c6d25..6a7f7b0 100644 --- a/tests/db/start-checkout.db.test.ts +++ b/tests/db/start-checkout.db.test.ts @@ -68,6 +68,7 @@ vi.mock("@/lib/customer/session", () => ({ })); const { startCheckout } = await import("@/features/checkout/actions/start-checkout"); +const { currentTermsVersion } = await import("@/lib/legal/documents"); resetDatabaseBeforeEach(); @@ -92,6 +93,9 @@ const address = { postalCode: "00950", city: "Warsaw", phone: "", + // spec 0017, AC-9: the ticked box and the version the page showed. + acceptTerms: true, + termsVersion: currentTermsVersion, }; // What the checkout form sends: the email plus a valid delivery address. @@ -398,6 +402,108 @@ describe("delivery (spec 0007)", () => { }); }); +// spec 0017, AC-9 and AC-11. +describe("terms of sale", () => { + it("records the accepted version and the time of Pay on the new order", async () => { + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + const before = Date.now(); + + expect((await startCheckout(checkout())).ok).toBe(true); + + const order = await testDb.order.findFirstOrThrow(); + expect(order.termsVersion?.toISOString()).toBe(`${currentTermsVersion}T00:00:00.000Z`); + expect(order.termsAcceptedAt?.getTime()).toBeGreaterThanOrEqual(before - 1000); + expect(order.termsAcceptedAt?.getTime()).toBeLessThanOrEqual(Date.now() + 1000); + }); + + it("gives a replaced checkout's new order its own acceptance time", async () => { + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + await startCheckout(checkout()); + const first = await testDb.order.findFirstOrThrow(); + mocks.retrieve.mockResolvedValueOnce({ + id: first.stripeCheckoutSessionId, + status: "open", + payment_status: "unpaid", + }); + + expect((await startCheckout(checkout())).ok).toBe(true); + + const second = await testDb.order.findFirstOrThrow({ where: { id: { not: first.id } } }); + expect(second.termsVersion).toEqual(first.termsVersion); + expect(second.termsAcceptedAt!.getTime()).toBeGreaterThanOrEqual( + first.termsAcceptedAt!.getTime(), + ); + }); + + it.each([ + ["unticked", { acceptTerms: false }], + ["missing", { acceptTerms: undefined }], + ])("refuses the box %s with the field message, before any database work", async (_, terms) => { + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + + expect(await startCheckout(checkout("a@example.com", terms))).toEqual({ + ok: false, + error: { + code: "validation", + fields: { acceptTerms: "Tick the box to accept the terms of sale." }, + }, + }); + expect(await testDb.order.count()).toBe(0); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it.each([ + ["an older version", "2020-01-01"], + ["no version", undefined], + ["a version that is not a date", "latest"], + ])("answers terms_changed for %s and creates no order", async (_, termsVersion) => { + const { variant } = await createSimpleProduct("a", 5); + const cart = await cartWith([{ variantId: variant.id, quantity: 1 }]); + + expect(await startCheckout(checkout("a@example.com", { termsVersion }))).toEqual({ + ok: false, + error: { code: "terms_changed", currentVersion: currentTermsVersion }, + }); + expect(await testDb.order.count()).toBe(0); + expect(mocks.create).not.toHaveBeenCalled(); + // No lock was taken: the cart is free for the next Pay. + expect((await startCheckout(checkout())).ok).toBe(true); + expect(await testDb.order.count({ where: { cartId: cart.id } })).toBe(1); + }); + + it("logs a terms_changed refusal by its code only", async () => { + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + + await startCheckout(checkout("a@example.com", { termsVersion: "2020-01-01" })); + + expect(mocks.info).toHaveBeenCalledWith( + { event: "checkout.refused", reason: "terms_changed" }, + "checkout.refused", + ); + }); + + it("keeps the two columns together with a CHECK", async () => { + const { variant } = await createSimpleProduct("a", 5); + await cartWith([{ variantId: variant.id, quantity: 1 }]); + await startCheckout(checkout()); + const order = await testDb.order.findFirstOrThrow(); + + await expect( + testDb.$executeRaw`UPDATE orders SET terms_accepted_at = NULL WHERE id = ${order.id}::uuid`, + ).rejects.toThrow(/orders_terms_acceptance_check/); + await expect( + testDb.$executeRaw`UPDATE orders SET terms_version = NULL WHERE id = ${order.id}::uuid`, + ).rejects.toThrow(/orders_terms_acceptance_check/); + // Both null, as on an order from before the feature, is allowed. + await testDb.$executeRaw` + UPDATE orders SET terms_version = NULL, terms_accepted_at = NULL WHERE id = ${order.id}::uuid`; + }); +}); + describe("refusals create nothing", () => { it("refuses an invalid email with a field error", async () => { const { variant } = await createSimpleProduct("a", 5); diff --git a/tests/e2e/account/checkout.spec.ts b/tests/e2e/account/checkout.spec.ts index 0409ff9..9307549 100644 --- a/tests/e2e/account/checkout.spec.ts +++ b/tests/e2e/account/checkout.spec.ts @@ -9,6 +9,7 @@ import { payButton, postPaidEvent, seedPendingOrder, + termsBox, } from "../checkout/support"; import { signInAsCustomer } from "./support"; @@ -83,6 +84,7 @@ test("a signed in Pay goes to Stripe with the account on the order and the addre await page.getByLabel("Address line 1").fill("ul. Marszałkowska 1"); await page.getByLabel("Postal code").fill("00950"); await page.getByLabel("City").fill("Warsaw"); + await termsBox(page).check(); await payButton(page).click(); await page.waitForURL(/^https:\/\/checkout\.stripe\.com\//, { timeout: 30_000 }); diff --git a/tests/e2e/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(); 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/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)); diff --git a/tests/e2e/checkout/support.ts b/tests/e2e/checkout/support.ts index 140aa75..6c53c4b 100644 --- a/tests/e2e/checkout/support.ts +++ b/tests/e2e/checkout/support.ts @@ -51,6 +51,12 @@ export async function fillCheckout(page: Page, email = "e2e.customer@example.com await page.getByLabel("Address line 1").fill(testAddress.line1); await page.getByLabel("Postal code").fill(testAddress.postalCode); await page.getByLabel("City").fill(testAddress.city); + await termsBox(page).check(); +} + +// spec 0017, AC-8: the box every Pay needs ticked. +export function termsBox(page: Page) { + return page.getByRole("checkbox", { name: /^I accept the terms of sale/ }); } // The Pay button whatever the delivery fee: the store_settings row is shared by every spec file diff --git a/tests/e2e/legal/consent.spec.ts b/tests/e2e/legal/consent.spec.ts new file mode 100644 index 0000000..3bcfdfa --- /dev/null +++ b/tests/e2e/legal/consent.spec.ts @@ -0,0 +1,274 @@ +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)); +} + +// 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. + 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(/<a[^>]*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-<project ref>-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); + // 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([]); + 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 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]); + }); +}); + +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"); + }); +}); diff --git a/tests/e2e/legal/legal-pages.spec.ts b/tests/e2e/legal/legal-pages.spec.ts new file mode 100644 index 0000000..f3824e8 --- /dev/null +++ b/tests/e2e/legal/legal-pages.spec.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; + +import { expect, type Page, type Request, test } from "@playwright/test"; + +import { expectNoA11yViolations } from "../a11y"; +import { createTestUser, signInFully, withDb } from "../admin/support"; +import { seedProduct } from "../catalog/support"; +import { + addToCart, + cartIdOf, + fillCheckout, + hasRealStripeKey, + payButton, + seedPendingOrder, + termsBox, +} from "../checkout/support"; + +// spec 0017: the five legal pages, the footer links, the checkout terms box and its record on +// the order, the sign up notice (AC-1 to AC-3, AC-6, AC-8 to AC-13). + +const pages = [ + { path: "/terms", title: "Terms of sale", link: "Terms of sale" }, + { path: "/privacy", title: "Privacy policy", link: "Privacy policy" }, + { path: "/returns", title: "Returns", link: "Returns" }, + { path: "/shipping", title: "Shipping", link: "Shipping" }, + { path: "/cookies", title: "Cookie policy", link: "Cookie policy" }, +] as const; + +const siteUrl = "http://localhost:3000"; + +function footer(page: Page) { + return page.getByRole("navigation", { name: "Footer" }); +} + +for (const legal of pages) { + test(`${legal.path} shows its title, version date and text, and can be indexed`, async ({ + page, + }) => { + const response = await page.goto(legal.path); + + expect(response?.status()).toBe(200); + expect(response?.headers()["x-robots-tag"]).toBeUndefined(); + await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(legal.title); + await expect(page.getByText(/^Last updated \w+ \d{1,2}, \d{4}$/)).toBeVisible(); + await expect(page).toHaveTitle(new RegExp(`^${legal.title} · `)); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + `${siteUrl}${legal.path}`, + ); + await expect(page.locator('meta[name="description"]')).toHaveAttribute("content", /\w/); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + // No placeholder ever reaches the page. + await expect(page.locator("main")).not.toContainText("{{"); + await expectNoA11yViolations(page); + }); +} + +test("every store footer links the legal pages in order, then Cookie settings", async ({ + page, +}) => { + for (const path of ["/", "/cart", "/no-such-page-here"]) { + await page.goto(path); + const links = footer(page).getByRole("listitem"); + await expect(links).toHaveText([...pages.map((legal) => legal.link), "Cookie settings"]); + } + + await page.goto("/"); + await footer(page).getByRole("link", { name: "Returns" }).click(); + await expect(page).toHaveURL("/returns"); + await expect(page.getByRole("heading", { level: 1, name: "Returns" })).toBeVisible(); +}); + +test("the returns page ends with the model withdrawal form", async ({ page }) => { + await page.goto("/returns"); + + const heading = page.getByRole("heading", { level: 2, name: "Model withdrawal form" }); + await expect(heading).toBeVisible(); + await expect(page.locator("main blockquote")).toContainText("I/We (*) hereby give notice"); + await expect(page.getByText(/You can print it, or copy it into an email to/)).toBeVisible(); +}); + +test("printing a legal page prints only its text", async ({ page }) => { + await page.goto("/terms"); + await page.emulateMedia({ media: "print" }); + + await expect(page.getByRole("banner")).toBeHidden(); + await expect(page.getByRole("contentinfo")).toBeHidden(); + await expect(page.getByRole("heading", { level: 1, name: "Terms of sale" })).toBeVisible(); +}); + +test("the admin panel shows no legal footer and no consent banner", async ({ page }) => { + const admin = await createTestUser({ enrolled: true }); + await signInFully(page, admin); + + await page.goto("/admin"); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await expect(footer(page)).toHaveCount(0); + await expect(page.getByRole("link", { name: "Terms of sale" })).toHaveCount(0); + await expect(page.getByRole("region", { name: "Cookie consent" })).toHaveCount(0); +}); + +test("sign up says the terms and privacy policy apply, with links that open a new tab", async ({ + page, +}) => { + await page.goto("/account/sign-up"); + + await expect( + page.getByText("By creating an account you accept the terms of sale and confirm"), + ).toBeVisible(); + const terms = page.getByRole("link", { name: "terms of sale, opens in a new tab" }); + await expect(terms).toHaveAttribute("href", "/terms"); + await expect(terms).toHaveAttribute("target", "_blank"); + await expect( + page.getByRole("link", { name: "privacy policy, opens in a new tab" }), + ).toHaveAttribute("href", "/privacy"); +}); + +// The server action Pay calls: a POST to /checkout carrying the Next-Action header. +function isPay(request: Request): boolean { + return request.method() === "POST" && request.headers()["next-action"] !== undefined; +} + +test.describe("the checkout terms box", () => { + test.beforeEach(async ({ page }) => { + const socks = await seedProduct({ stock: 5, priceCents: 1250 }); + await addToCart(page, socks); + await page.goto("/checkout"); + }); + + test("is unticked, links the texts in a new tab, and Pay without it sends nothing", async ({ + page, + }) => { + await expect(termsBox(page)).not.toBeChecked(); + for (const name of [ + "terms of sale, opens in a new tab", + "privacy policy, opens in a new tab", + "returns, opens in a new tab", + "shipping, opens in a new tab", + ]) { + await expect(page.getByRole("link", { name })).toHaveAttribute("target", "_blank"); + } + + await fillCheckout(page); + await termsBox(page).uncheck(); + const sent: Request[] = []; + page.on("request", (request) => { + if (isPay(request)) sent.push(request); + }); + await expect(payButton(page)).toBeEnabled(); + await payButton(page).click(); + + await expect(page.getByText("Tick the box to accept the terms of sale.")).toBeVisible(); + await expect(termsBox(page)).toBeFocused(); + await expect(termsBox(page)).toHaveAttribute("aria-invalid", "true"); + await expect(payButton(page)).toBeEnabled(); + expect(sent).toEqual([]); + await expectNoA11yViolations(page); + }); + + test("asks again when the terms changed since the page loaded, keeping every field", async ({ + page, + }) => { + // Unique per run: the local database keeps orders from earlier runs. + const email = `terms.changed.${randomUUID()}@example.com`; + await fillCheckout(page, email); + // Stand in for terms that changed after this page loaded: the first Pay carries an old + // version, as a page loaded before the change would. + let first = true; + await page.route("**/checkout", async (route) => { + const request = route.request(); + if (!isPay(request) || !first) return route.continue(); + first = false; + const body = request.postData() ?? ""; + expect(body).toMatch(/"termsVersion":"\d{4}-\d{2}-\d{2}"/); + await route.continue({ + postData: body.replace(/"termsVersion":"[^"]*"/, '"termsVersion":"2020-01-01"'), + }); + }); + + await payButton(page).click(); + + const alert = page.getByRole("alert").filter({ hasText: "Our terms of sale changed on" }); + await expect(alert).toContainText(/changed on \w+ \d{1,2}, \d{4}\. Please read them/); + await expect(alert.getByRole("link", { name: "them, opens in a new tab" })).toHaveAttribute( + "href", + "/terms", + ); + await expect(termsBox(page)).not.toBeChecked(); + await expect(termsBox(page)).toBeFocused(); + await expect(page.getByLabel("Email")).toHaveValue(email); + await expect(page.getByLabel("City")).toHaveValue("Warsaw"); + expect( + await withDb((db) => db.query("SELECT 1 FROM orders WHERE email = $1", [email])).then( + (r) => r.rowCount, + ), + ).toBe(0); + await expectNoA11yViolations(page); + + // The next Pay with the box ticked sends the current version. + await termsBox(page).check(); + const retry = page.waitForRequest(isPay); + await payButton(page).click(); + expect((await retry).postData()).not.toContain('"termsVersion":"2020-01-01"'); + await expect(alert).toHaveCount(0); + }); +}); + +test("a ticked Pay records the terms version on the order, shown to the admin", async ({ + page, + browser, +}) => { + test.skip(!hasRealStripeKey, "needs a real Stripe test key (STRIPE_SECRET_KEY)"); + const socks = await seedProduct({ stock: 5, priceCents: 1250 }); + await addToCart(page, socks); + await page.goto("/checkout"); + const email = `terms.accepted.${randomUUID()}@example.com`; + await fillCheckout(page, email); + await payButton(page).click(); + await page.waitForURL(/^https:\/\/checkout\.stripe\.com\//, { timeout: 30_000 }); + + const row = await withDb((db) => + db.query<{ number: number; version: string; accepted: boolean }>( + `SELECT number, terms_version::text AS version, terms_accepted_at IS NOT NULL AS accepted + FROM orders WHERE email = $1`, + [email], + ), + ); + expect(row.rows[0]).toMatchObject({ + version: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + accepted: true, + }); + + const admin = await createTestUser({ enrolled: true }); + const adminPage = await (await browser.newContext()).newPage(); + await signInFully(adminPage, admin); + await adminPage.goto(`/admin/orders/${row.rows[0]!.number}`); + await expect(adminPage.getByText(/^version \w+ \d{1,2}, \d{4}, on /)).toBeVisible(); +}); + +test("an order from before the feature reads 'not recorded' on the admin page", async ({ + page, + browser, +}) => { + const socks = await seedProduct({ stock: 5, priceCents: 1250 }); + await addToCart(page, socks); + const order = await seedPendingOrder(await cartIdOf(page), socks, { + quantity: 1, + priceCents: 1250, + }); + + const admin = await createTestUser({ enrolled: true }); + const adminPage = await (await browser.newContext()).newPage(); + await signInFully(adminPage, admin); + await adminPage.goto(`/admin/orders/${order.number}`); + const details = adminPage.locator("dl").filter({ hasText: "Terms accepted" }); + await expect(details.getByText("not recorded")).toBeVisible(); + + await withDb((db) => + db.query( + `UPDATE orders SET terms_version = '2026-10-20', terms_accepted_at = '2026-10-20T21:30:00Z' + WHERE id = $1`, + [order.id], + ), + ); + await adminPage.reload(); + // The version is a calendar day; the acceptance time is in the store's time zone. + await expect(details.getByText(/^version October 20, 2026, on Oct 2[01], 2026/)).toBeVisible(); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 850fdfe..6d1c193 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,9 +1,30 @@ -import { defineConfig } from "vitest/config"; +import { createRequire } from "node:module"; -// Three projects: `unit` needs no database (`pnpm test`); `db` runs the *.db.test.ts suites +import { defineConfig, type Plugin } from "vitest/config"; + +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: rawTextLoader(code), map: null }; + }, + }; +} + +// 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 }, test: { environment: "node", @@ -21,6 +42,7 @@ export default defineConfig({ "tests/e2e/**", "**/*.db.test.ts", "**/*.stack.test.ts", + "**/*.build.test.ts", ], unstubEnvs: true, }, @@ -45,6 +67,15 @@ export default defineConfig({ testTimeout: 20_000, }, }, + { + extends: true, + test: { + name: "build", + include: ["**/*.build.test.ts"], + exclude: ["node_modules/**", ".next/**"], + testTimeout: 300_000, + }, + }, ], }, });