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¶
- First visit: banner slides in from the bottom (clears the mobile bottom nav on small screens, pins bottom-right on desktop).
- Three primary actions — Accept all, Essential only, Customise.
- Customise expands an inline panel with three toggles:
- Essential (always on, disabled).
- Analytics (default off).
- Marketing (default off).
- On any decision we write
localStorage.fp_consent_v1and dispatch thefp:consent-changeevent so listening components (newsletter form, analytics bootstrap) re-evaluate without a page reload. - Returning visits: if the key exists, the banner stays hidden.
- 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.
Cookie inventory¶
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.ts—getConsent,setConsent,clearConsent,hasConsent(category),buildConsent. All reads are SSR-safe and returnnull/falseon the server.apps/web/components/legal/ConsentBanner.tsx— mounted inapp/layout.tsxalongsideMobileNav,MiniCart,InstallPrompt.apps/web/app/cookies/page.tsx— public cookie policy page.- Newsletter signup in
apps/web/components/layout/NewsletterSignup.tsxgates onhasConsent("marketing")— if explicitly false, it swaps the form for a deep-link to/cookiesso 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 auseEffectthat subscribes tofp:consent-change, not at import time. - Don't read the
fp_consent_v1localStorage key from inside a server component; use the"use client"boundary and the helpers above.