Skip to content

Consent & cookies

UK GDPR requires an explicit opt-in before we run any non-essential cookie. The site ships a lightweight consent banner, a per-category localStorage store, and a plain-English /cookies page — no third-party CMP, no hidden network fetch on first paint.

User experience

  1. First visit: banner slides in from the bottom (clears the mobile bottom nav on small screens, pins bottom-right on desktop).
  2. Three primary actions — Accept all, Essential only, Customise.
  3. Customise expands an inline panel with three toggles:
  4. Essential (always on, disabled).
  5. Analytics (default off).
  6. Marketing (default off).
  7. On any decision we write localStorage.fp_consent_v1 and dispatch the fp:consent-change event so listening components (newsletter form, analytics bootstrap) re-evaluate without a page reload.
  8. Returning visits: if the key exists, the banner stays hidden.
  9. Preferences can be changed any time from /cookies, which clears the store and re-surfaces the banner.

prefers-reduced-motion swaps the slide-in for a plain fade.

Storage schema

{
  "essential": true,
  "analytics": false,
  "marketing": false,
  "timestamp": "2026-04-25T10:32:19.204Z"
}

Versioning: the _v1 suffix lets us bump to _v2 if the schema changes, forcing every visitor to re-consent on the next page load.

The /cookies page is the user-facing source of truth. Engineering summary:

Cookie Category Purpose Lifetime
fp_cart Essential Cart session identity Session (httpOnly)
fp_nonce Essential Woo CSRF token Session (httpOnly)
fp_auth Essential Login session 30 days (httpOnly, Secure)
fp_consent_v1 Essential Stores the consent choice Until cleared (localStorage)
_ph_* Analytics PostHog event + session 365 days
fp_mkt Marketing Campaign attribution 90 days

Four essential entries, one analytics entry, one marketing entry — six total classified by the banner.

Module surface

  • apps/web/lib/consent.tsgetConsent, setConsent, clearConsent, hasConsent(category), buildConsent. All reads are SSR-safe and return null/false on the server.
  • apps/web/components/legal/ConsentBanner.tsx — mounted in app/layout.tsx alongside MobileNav, MiniCart, InstallPrompt.
  • apps/web/app/cookies/page.tsx — public cookie policy page.
  • Newsletter signup in apps/web/components/layout/NewsletterSignup.tsx gates on hasConsent("marketing") — if explicitly false, it swaps the form for a deep-link to /cookies so the visitor can enable the category and try again.

Gotchas

  • Analytics gating (PostHog, GA4) consumes the same hasConsent("analytics") hook — bootstrap any analytics code inside a useEffect that subscribes to fp:consent-change, not at import time.
  • Don't read the fp_consent_v1 localStorage key from inside a server component; use the "use client" boundary and the helpers above.