diff --git a/.env.example b/.env.example index ac40083..3b2b4e0 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,7 @@ INERTIA_SSR_URL=http://127.0.0.1:13715 INERTIA_SSR_PORT=13715 # Optional. Leave both empty in development so no third-party script loads. -PLAUSIBLE_DOMAIN= -PLAUSIBLE_SCRIPT_URL= +# Production: GOOGLE_ANALYTICS_ID=G-FG4YV1QXYN, the same value the account +# portal's .env carries. +GOOGLE_ANALYTICS_ID= CRISP_WEBSITE_ID= diff --git a/config/analytics.php b/config/analytics.php index b7fbf8a..51ccbc8 100644 --- a/config/analytics.php +++ b/config/analytics.php @@ -3,21 +3,24 @@ return [ /* |-------------------------------------------------------------------------- - | Plausible Analytics + | Google Analytics 4 |-------------------------------------------------------------------------- | - | Self-hosted Plausible. Set PLAUSIBLE_DOMAIN to the site identifier you - | configured in your Plausible dashboard (typically the bare domain, e.g. - | "tablepro.app"). Set PLAUSIBLE_SCRIPT_URL to the script endpoint of your - | Plausible host (e.g. "https://plausible.tablepro.app/js/script.js"). + | Set GOOGLE_ANALYTICS_ID to the web stream's measurement ID (G-XXXXXXXXXX). + | The account portal at /account is a separate application on the same + | origin and carries its own copy of this setting; both must name the same + | stream, or a reader who crosses from one to the other becomes two users. | - | When PLAUSIBLE_DOMAIN is empty the script tag is not rendered, so dev - | and staging environments stay clean by default. + | The tag loads in Consent Mode with analytics storage denied, so no cookie + | is set until the reader allows it — see "Analytics and consent" in + | docs/architecture.md. + | + | When GOOGLE_ANALYTICS_ID is empty the tag is not rendered, so dev and + | staging environments stay clean by default. | */ - 'plausible' => [ - 'domain' => env('PLAUSIBLE_DOMAIN'), - 'script_url' => env('PLAUSIBLE_SCRIPT_URL', 'https://plausible.io/js/script.js'), + 'google' => [ + 'measurement_id' => env('GOOGLE_ANALYTICS_ID'), ], ]; diff --git a/config/banner.php b/config/banner.php index 59c9f08..286e8be 100644 --- a/config/banner.php +++ b/config/banner.php @@ -61,8 +61,8 @@ * Bump to re-show the banner to readers who dismissed the previous message. * * Stored as `tablepro:banner-dismissed` in `localStorage` with this value. - * There is no session and no cookie on this domain, so the browser is the - * only place a dismissal can live. + * There is no session on this domain and the server sets no cookie, so the + * browser is the only place a dismissal can live. */ 'version' => env('BANNER_VERSION', '1'), diff --git a/docs/architecture.md b/docs/architecture.md index d92b91d..1a792b2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,8 +64,8 @@ Neither half of this system can answer "where did this customer come from" on its own. This app sees the arrival and never learns that a sale happened: the overlay that takes the money runs on the payment provider's domain, and the license is written by the backend. The backend sees the sale and never saw the -arrival. Plausible measures visits on this domain only, so it can report the -source of a *visit* and not the source of a *sale*. +arrival. Google Analytics measures visits on this domain only, so it can report +the source of a *visit* and not the source of a *sale*. `POST /checkout` is the one request in which both are in scope, so the acquisition source is resolved in the browser and sent in that body as an @@ -107,7 +107,47 @@ Three things the backend end of this contract has to do: attribution is only worth collecting if it survives to sit beside the sale. Until that end exists, the field is sent and ignored, and the `checkout_started` -Plausible event is the only part of this that reports anything. +analytics event is the only part of this that reports anything. + +## Analytics and consent + +Google Analytics 4 replaced self-hosted Plausible on 2026-09-23. Plausible set no +cookies and needed no consent; GA4 sets `_ga` and `_ga_`, which in the +EEA and UK need the reader's permission first. So the tag runs in **Consent +Mode**, and nothing about it is optional: + +1. **`app.blade.php` loads the tag with every storage type denied**, then reads + `tablepro:analytics-consent` from `localStorage` and grants + `analytics_storage` if the reader said yes before — all ahead of + `gtag('config')`, so a returning reader's first page view carries its + cookies. Until then GA receives a cookieless ping per page and sets nothing. +2. **`ConsentBar` asks**, once, after hydration. Allow and Decline are the same + button at the same weight; that is a legal requirement, not a style choice. +3. **`resources/js/lib/consent.ts` applies the answer** to the running tag and, + on a decline, deletes any `_ga*` cookie already written. "Cookie settings" in + the footer and a button in `/privacy#cookies` reopen the bar. + +The advertising signals (`ad_storage`, `ad_user_data`, `ad_personalization`) +are denied for everyone, always. Nothing here advertises, and `/privacy` says so. + +**The account portal is the other half.** `/account`, `/checkout`, `/thank-you` +and the newsletter pages are the platform app, on this same origin. It carries a +copy of the tag, the bar and `consent.ts`, reads the same storage key, and so +shares one answer with this site. Change the key, the consent defaults or the +measurement ID in one repository and the other has to follow in the same +release, or a reader is asked twice and counted as two users. + +The platform app also redacts what it sends: its pages are reached through +signed links and order IDs, and GA — unlike Plausible — records the full URL. +See `App\Support\AnalyticsLocation` there. + +Events keep the names the Plausible goals had: `download_click` (`location`, +`platform`), `checkout_started` (`tier`, `cycle`) and +`newsletter_signup_clicked` (`source`). GA4 stores those parameters from the +first hit but only shows them in reports once each is registered as an +event-scoped custom dimension under Admin → Custom definitions. Page changes +between Inertia visits are counted by enhanced measurement's "page changes +based on browser history events", which must stay on in the web stream. ## Working on these forms locally diff --git a/resources/js/components/landing/consent-bar.tsx b/resources/js/components/landing/consent-bar.tsx new file mode 100644 index 0000000..f9e1dd4 --- /dev/null +++ b/resources/js/components/landing/consent-bar.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import Button from '@/components/ui/button'; +import { PROSE_LINK } from '@/components/ui/prose-link'; +import { CONSENT_OPEN_EVENT, readConsent, saveConsent, type ConsentChoice } from '@/lib/consent'; + +/** + * Asks once whether Google Analytics may set cookies, and again whenever a + * "Cookie settings" control reopens it. + * + * **Declining is as easy as allowing.** The two buttons are the same variant + * at the same size, side by side. A filled "Allow" beside an outlined + * "Decline" is the pattern regulators single out, and a choice nudged that way + * is not freely given. + * + * **It never renders on the server.** Whether it is needed depends on + * `localStorage`, which SSR cannot see, so the first render is always nothing + * and the check runs after hydration. The cost is that it appears a beat after + * the page — acceptable for a non-modal bar, and the alternative is a hydration + * mismatch on every page for every first-time reader. + * + * **It exists only where the tag does.** With no measurement ID configured + * there is no `gtag` and nothing to consent to, so development and staging + * never show it. + * + * **It stays clear of the chat bubble.** Crisp pins its launcher to the + * bottom-right corner above every z-index on the page — measured on the live + * site at 54px square, 14px in from the corner on a phone and 24px on a + * desktop. So the bar takes the bottom-left: a card from `sm` up, and on a + * phone the full width minus 5.5rem, which is the launcher's 68px column plus a + * 20px gap, rather than a bar the launcher would sit on top of. + */ +export default function ConsentBar() { + const [open, setOpen] = useState(false); + + useEffect(() => { + if (typeof (window as unknown as { gtag?: unknown }).gtag !== 'function') { + return; + } + + if (readConsent() === null) { + setOpen(true); + } + + const reopen = (): void => setOpen(true); + + window.addEventListener(CONSENT_OPEN_EVENT, reopen); + + return () => window.removeEventListener(CONSENT_OPEN_EVENT, reopen); + }, []); + + if (!open) { + return null; + } + + function choose(choice: ConsentChoice): void { + saveConsent(choice); + setOpen(false); + } + + return ( +
+

Analytics cookies

+

+ May Google Analytics set cookies so we can see which pages bring people to TablePro? Declining changes nothing else.{' '} + + Privacy policy + +

+
+ + +
+
+ ); +} diff --git a/resources/js/components/landing/footer.tsx b/resources/js/components/landing/footer.tsx index d9f5a80..e844df4 100644 --- a/resources/js/components/landing/footer.tsx +++ b/resources/js/components/landing/footer.tsx @@ -4,6 +4,7 @@ import Container from '@/components/ui/container'; import { FullLine } from '@/components/ui/full-line'; import { useEmailForm } from '@/hooks/use-email-form'; import { trackEvent } from '@/lib/analytics'; +import { openConsentSettings } from '@/lib/consent'; import { GITHUB_REPO_URL, GITHUB_SPONSORS_URL } from '@/data/links'; const columns = [ @@ -70,6 +71,12 @@ const columns = [ { label: 'Sponsor', href: GITHUB_SPONSORS_URL, external: true }, { label: 'Privacy', href: '/privacy' }, { label: 'Terms', href: '/terms' }, + /* + * Withdrawing consent has to be as easy as giving it, so the way + * back to the choice sits on every page rather than only inside + * the privacy policy. + */ + { label: 'Cookie settings', action: openConsentSettings }, ], }, { @@ -232,13 +239,23 @@ export default function Footer() { diff --git a/resources/js/components/landing/support-banner.tsx b/resources/js/components/landing/support-banner.tsx index cd804e3..e6647ba 100644 --- a/resources/js/components/landing/support-banner.tsx +++ b/resources/js/components/landing/support-banner.tsx @@ -55,9 +55,9 @@ export const BANNER_STORAGE_KEY = 'tablepro:banner-dismissed'; * * **It can be closed, and stays closed.** Dismissal writes the config's version * to `localStorage` — the browser is the only place it can live, because this - * domain has no session and no cookies. Reach is a first-impression property, - * so nothing is lost by letting a reader who has already read it, or already - * paid, put it away. + * app has no session and its server sets no cookies. Reach is a + * first-impression property, so nothing is lost by letting a reader who has + * already read it, or already paid, put it away. * * Visibility is entirely CSS. The element renders whenever the config enables * it and `html.has-banner` decides whether it is seen, which is what lets the diff --git a/resources/js/components/ui/prose-block.tsx b/resources/js/components/ui/prose-block.tsx index 1f09b66..fa5ef60 100644 --- a/resources/js/components/ui/prose-block.tsx +++ b/resources/js/components/ui/prose-block.tsx @@ -8,13 +8,15 @@ import { FullLine } from '@/components/ui/full-line'; * byte-identical local copy, which is two places for one rule to drift and two * places to fix when it does. * - * The heading is an h2 because these sit under the page's single h1. + * The heading is an h2 because these sit under the page's single h1. `id` + * makes a block linkable — the consent bar points at `/privacy#cookies` — and + * the global `scroll-padding-top` keeps the heading clear of the fixed header. */ -export function ProseBlock({ title, children }: { title: string; children: ReactNode }) { +export function ProseBlock({ title, id, children }: { title: string; id?: string; children: ReactNode }) { return ( <> -
+

{title}

{children}
diff --git a/resources/js/layouts/landing-layout.tsx b/resources/js/layouts/landing-layout.tsx index 5d87a2d..594cd41 100644 --- a/resources/js/layouts/landing-layout.tsx +++ b/resources/js/layouts/landing-layout.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react'; import { Toaster } from 'sonner'; +import ConsentBar from '@/components/landing/consent-bar'; import SupportBanner from '@/components/landing/support-banner'; interface Props { @@ -135,6 +136,13 @@ export default function LandingLayout({ header, footer, children }: Props) { aria-hidden="true" />
+ {/* + * Last in the document, so it is the last stop in the tab order + * rather than one a keyboard user must pass before the skip link + * and the nav. It is fixed-position, so DOM order costs it nothing + * visually. + */} + ); } diff --git a/resources/js/lib/analytics.ts b/resources/js/lib/analytics.ts index 8e26aab..558f894 100644 --- a/resources/js/lib/analytics.ts +++ b/resources/js/lib/analytics.ts @@ -1,27 +1,35 @@ /** - * Plausible events, for the handful of places worth counting. + * Google Analytics events, for the handful of places worth counting. * * Extracted from `footer-cta.tsx`, where it sat as a private function and so * could only ever instrument the newsletter form. The page offers five routes * to `/download` and none of them were counted, which meant no argument about * where a call to action belongs could be settled with anything but taste. * - * Silent when Plausible is absent — the script is not loaded in development and - * an analytics helper must never be the reason a button stops working. + * The names and parameters are the ones the Plausible goals used, so the two + * series read as one across the switch. GA4 records the parameters regardless, + * but shows them in reports only once each is registered as an event-scoped + * custom dimension — see "Analytics and consent" in docs/architecture.md. + * + * Consent is not this function's concern. `gtag` exists whenever the tag is + * configured; Consent Mode decides whether the event carries cookies. + * + * Silent when the tag is absent — it is not loaded in development and an + * analytics helper must never be the reason a button stops working. */ -interface PlausibleWindow { - plausible?: (event: string, options?: { props?: Record }) => void; +interface GtagWindow { + gtag?: (command: 'event', name: string, params: Record) => void; } -export function trackEvent(name: string, props: Record = {}): void { +export function trackEvent(name: string, params: Record = {}): void { if (typeof window === 'undefined') { return; } - const plausible = (window as unknown as PlausibleWindow).plausible; + const gtag = (window as unknown as GtagWindow).gtag; - if (typeof plausible === 'function') { - plausible(name, { props }); + if (typeof gtag === 'function') { + gtag('event', name, params); } } diff --git a/resources/js/lib/attribution.ts b/resources/js/lib/attribution.ts index 0daf14e..4ffe52e 100644 --- a/resources/js/lib/attribution.ts +++ b/resources/js/lib/attribution.ts @@ -1,10 +1,11 @@ /** * Where a reader came from, kept until the moment they buy. * - * Plausible already reports the source of a *visit*. What it cannot report is - * the source of a *sale*: the sale completes on the payment provider's overlay, - * on a domain Plausible does not measure, minutes after the click, and the - * license is written by the TablePro backend — none of which this app can see. + * Google Analytics already reports the source of a *visit*. What it cannot + * report is the source of a *sale*: the sale completes on the payment + * provider's overlay, on a domain the tag does not measure, minutes after the + * click, and the license is written by the TablePro backend — none of which + * this app can see. * * `POST /checkout` is the one moment the two halves touch. So the acquisition * source is resolved here, in the browser, and handed over in that request @@ -18,7 +19,8 @@ * customer come from — so the first attributable visit wins and is never * overwritten while it is still inside the window below. * - * Held in `localStorage` because this app has no session and sets no cookies. + * Held in `localStorage` because this app has no session and its server sets + * no cookies. * That storage is writable by the reader, so nothing read back from it is * trusted: `parseStored` rebuilds a fixed set of keys at a fixed length rather * than passing the parsed object through to the request body. diff --git a/resources/js/lib/consent.ts b/resources/js/lib/consent.ts new file mode 100644 index 0000000..461bcfe --- /dev/null +++ b/resources/js/lib/consent.ts @@ -0,0 +1,102 @@ +/** + * The reader's answer to "may Google Analytics set cookies?". + * + * `app.blade.php` loads the tag in Consent Mode with every storage type denied, + * then reads this key before `gtag('config')` so a returning reader who said + * yes is counted with cookies from their first page view. This module owns the + * other half: recording the answer, applying it to the running tag, and taking + * the cookies back when the reader changes their mind. + * + * The account portal at `/account` is a different application on the same + * origin, so it reads the same `localStorage` — one answer covers both, and + * withdrawing it on either side withdraws it everywhere. Its copy of this file + * must keep the key identical. + * + * Only `analytics_storage` is ever granted. The advertising signals stay denied + * for everyone because nothing on this site advertises. + */ +export const CONSENT_STORAGE_KEY = 'tablepro:analytics-consent'; + +/** Dispatched on `window` by the "Cookie settings" controls to reopen the bar. */ +export const CONSENT_OPEN_EVENT = 'tablepro:consent-open'; + +export type ConsentChoice = 'granted' | 'denied'; + +type Gtag = (command: 'consent', action: 'update', params: Record) => void; + +/** + * Anything but the two known values reads as "not asked yet", so a key written + * by hand, or by an older build, puts the question back rather than being + * mistaken for an answer. + */ +export function parseChoice(value: string | null): ConsentChoice | null { + return value === 'granted' || value === 'denied' ? value : null; +} + +/** + * Null when there is no answer, and also when storage throws — a private + * window refuses outright rather than returning null. + */ +export function readConsent(): ConsentChoice | null { + try { + return parseChoice(window.localStorage.getItem(CONSENT_STORAGE_KEY)); + } catch { + return null; + } +} + +/** + * The `_ga` cookie and one `_ga_` per measurement ID. Everything Google + * Analytics writes starts with that prefix, so this matches its cookies and + * none of the site's own (`nl_dismissed_at`, `nl_subscribed`). + */ +export function analyticsCookieNames(cookieHeader: string): string[] { + return cookieHeader + .split(';') + .map((pair) => pair.split('=')[0].trim()) + .filter((name) => name === '_ga' || name.startsWith('_ga_')); +} + +/** + * gtag writes to the widest domain the browser accepts — `.tablepro.app` — so + * that is where they have to be expired. The host-only form is cleared as well + * for a cookie written before the tag settled on a domain. + */ +function clearAnalyticsCookies(): void { + const expired = 'Max-Age=0; path=/'; + + for (const name of analyticsCookieNames(document.cookie)) { + document.cookie = `${name}=; ${expired}; domain=.${window.location.hostname}`; + document.cookie = `${name}=; ${expired}`; + } +} + +/** + * Records the answer and applies it to the tag already running on this page. + * + * Denying after allowing also deletes the cookies. Consent Mode stops gtag + * reading and writing them, but it leaves the ones already set where they are, + * and a withdrawal that leaves a two-year identifier behind is not one. + */ +export function saveConsent(choice: ConsentChoice): void { + try { + window.localStorage.setItem(CONSENT_STORAGE_KEY, choice); + } catch { + // No storage. The choice holds for this page and is asked again on the next. + } + + const gtag = (window as unknown as { gtag?: Gtag }).gtag; + + if (typeof gtag === 'function') { + gtag('consent', 'update', { analytics_storage: choice }); + } + + if (choice === 'denied') { + clearAnalyticsCookies(); + } +} + +/** Reopens the consent bar so a reader can change or withdraw their answer. */ +export function openConsentSettings(): void { + window.dispatchEvent(new Event(CONSENT_OPEN_EVENT)); +} diff --git a/resources/js/pages/Privacy.tsx b/resources/js/pages/Privacy.tsx index 4a13a82..4c33a49 100644 --- a/resources/js/pages/Privacy.tsx +++ b/resources/js/pages/Privacy.tsx @@ -8,6 +8,7 @@ import { FullLine } from '@/components/ui/full-line'; import { Bullet, ProseBlock } from '@/components/ui/prose-block'; import { PROSE_LINK } from '@/components/ui/prose-link'; import { ITEM_TITLE } from '@/components/ui/grid-cell'; +import { openConsentSettings } from '@/lib/consent'; interface Props { downloadUrls: { arm64: string; x86_64: string }; @@ -178,13 +179,14 @@ export default function Privacy({ downloadUrls }: Props) {
  • No connection credentials. Passwords and private keys stay in the Keychain on whichever device you entered them.
  • No personal information in either app beyond the email used at purchase.
  • No crash reports sent to any third party.
  • -
  • No third-party trackers. No Google Analytics, Mixpanel, Sentry, or similar SDK in either app.
  • +
  • No third-party trackers in the apps. No Google Analytics, Mixpanel, Sentry, or similar SDK in the Mac or iPhone app. The Website uses Google Analytics, and sets its cookies only if you allow it; see section 13.
    • Anonymous analytics: understand which app versions, OS versions, and database types our users run, to prioritise compatibility and bug fixes.
    • +
    • Website analytics: see which pages, links, and downloads bring people to TablePro, to decide what to write and where to put it.
    • License validation: confirm a License Key is valid and active.
    • Updates: deliver new versions of the Application.
    • Customer support: respond to questions and refund requests.
    • @@ -198,7 +200,7 @@ export default function Privacy({ downloadUrls }: Props) {
      • Contract (Art. 6(1)(b)): processing payment, providing the License Key, account portal access.
      • Legitimate interest (Art. 6(1)(f)): anonymous analytics, abuse detection, server logs, retention of business records.
      • -
      • Consent (Art. 6(1)(a)): newsletter subscriptions, optional features you enable.
      • +
      • Consent (Art. 6(1)(a)): newsletter subscriptions, Google Analytics cookies on the Website, optional features you enable.
      • Legal obligation (Art. 6(1)(c)): tax records, responses to lawful requests.
      @@ -209,7 +211,7 @@ export default function Privacy({ downloadUrls }: Props) {
    • LemonSqueezy or Polar: payment processing for License Key purchases.
    • Email delivery providers: transactional emails (magic links, receipts, newsletter). We use providers that do not sell or share contact data.
    • Hosting providers: server infrastructure for the Website, account portal, and analytics endpoint.
    • -
    • Plausible Analytics (self-hosted): aggregate, cookie-less Website analytics. No personal identifiers, no IP storage.
    • +
    • Google Analytics (Google Ireland Limited and Google LLC): Website and account portal analytics. The tag loads on every page, but until you allow analytics cookies it sends only a cookieless ping per page, with no identifier stored on your device. Advertising storage, ad personalisation, and ad user data are always denied. Google states that Google Analytics 4 does not log or store IP addresses.

    We do not sell, rent, or share personal data with advertisers. @@ -218,7 +220,7 @@ export default function Privacy({ downloadUrls }: Props) {

    - Our servers operate in multiple regions. When you interact with TablePro, your data may be transferred to or processed in countries outside your own. Where required, transfers from the EEA / UK rely on Standard Contractual Clauses or other approved mechanisms. + Our servers operate in multiple regions. When you interact with TablePro, your data may be transferred to or processed in countries outside your own. Where required, transfers from the EEA / UK rely on Standard Contractual Clauses or other approved mechanisms. Google processes Website analytics data in the United States under the EU-US Data Privacy Framework and its Standard Contractual Clauses.

    @@ -227,6 +229,7 @@ export default function Privacy({ downloadUrls }: Props) {
  • Anonymous analytics: aggregated indefinitely; the SHA-256 machine ID has no link to your identity.
  • Account and license data: kept while your license is active and for up to 7 years afterward for tax and audit purposes.
  • Newsletter subscribers: until you unsubscribe.
  • +
  • Website analytics: Google Analytics keeps event data for at most 14 months. Its cookies expire after 2 years, or immediately when you decline.
  • Server logs: 90 days.
  • Support emails: 2 years from the last interaction.
  • @@ -271,16 +274,26 @@ export default function Privacy({ downloadUrls }: Props) {

    - +

    - The marketing site sets two functional cookies and keeps a few values in your browser's own storage. No advertising, no profiles, no cross-site tracking, and nothing here is sold or handed to an advertiser. + The Website and account portal set a few cookies and keep a few values in your browser's own storage. Only the Google Analytics cookies need your consent, and they are not set until you give it. Nothing here is used for advertising, sold, or handed to an advertiser.

      +
    • _ga and _ga_<ID> (cookies, 2 years, only if you allow analytics): Google Analytics' random identifier for your browser and the state of your current visit, which let it tell a returning visit from a new one and link the pages of one visit together. Declining, or changing your answer later, deletes them. Lawful basis: consent.
    • +
    • tablepro:analytics-consent (local storage, until you clear it): your answer to the analytics question, so you are not asked on every page. It is shared by the Website and the account portal. Lawful basis: strictly necessary to honour your choice.
    • +
    • tablepro-session and XSRF-TOKEN (cookies, account portal, until you sign out or the session expires): keep you signed in and protect the portal's forms against cross-site request forgery. Lawful basis: strictly necessary to provide the account portal.
    • nl_dismissed_at (cookie, 90 days): records when you dismissed the newsletter prompt so we don't reshow it. Lawful basis: legitimate interest.
    • nl_subscribed (cookie, 365 days): records that you subscribed so we don't reprompt. Lawful basis: legitimate interest, performance of a subscription you initiated.
    • theme and tablepro:banner-dismissed (local storage, until you clear it): remember whether you chose light or dark, and which announcement bar you closed. Lawful basis: legitimate interest.
    • tablepro:attribution (local storage, 90 days): records how you first reached this site — the campaign tags on the link you followed, or the site that linked to us, and the page you landed on. If you buy a license it is sent with that purchase so we know which writing and which links pay for the work. It holds no identifier of you, it is never read on any other site, and clearing your browser storage removes it.
    +

    + You can change or withdraw your answer at any time with Cookie settings in the footer of every page, or here:{' '} + + . +

    diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index e93e9cd..8d2fe82 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -94,9 +94,37 @@ @else @endif - @if(config('analytics.plausible.domain')) - - + @if(config('analytics.google.measurement_id')) + {{-- + Google Analytics 4 in Consent Mode. The tag loads for everyone, but + with analytics storage denied it sets no cookie and sends only a + cookieless ping — until the reader allows it in the consent bar. + + The order is the contract: `consent default` must precede `config`, + and the stored choice is applied in between, so a reader who allowed + analytics on an earlier visit has their first page view counted with + cookies rather than as a stranger. The storage key is shared with + `resources/js/lib/consent.ts` and with the account portal, which + lives on this same origin and so reads the same choice. + --}} + + @endif @if(config('services.crisp.website_id')) diff --git a/tests/Feature/Landing/AnalyticsConsentTest.php b/tests/Feature/Landing/AnalyticsConsentTest.php new file mode 100644 index 0000000..0094e3f --- /dev/null +++ b/tests/Feature/Landing/AnalyticsConsentTest.php @@ -0,0 +1,142 @@ +`, which need + * permission first, so the tag loads in Consent Mode and the order of four + * calls in the document head is what keeps it lawful. None of that is visible + * to a typecheck, and a mistake in it is invisible in the browser too: a + * `config` that runs before `consent default` sets cookies for every visitor + * and every page still looks exactly the same. + * + * The consent module's behaviour is covered by execution in + * `tests/js/consent.test.ts`. This file holds the wiring around it. + */ +$readSource = static fn(string $relative): string => file_get_contents(base_path($relative)); + +it('loads the tag with the configured measurement id', function (): void { + config(['analytics.google.measurement_id' => 'G-TEST123']); + + $html = $this->get('/')->assertOk()->getContent(); + + expect($html)->toContain('https://www.googletagmanager.com/gtag/js?id=G-TEST123'); + expect($html)->toContain("gtag('config', \"G-TEST123\")"); +}); + +it('renders no tag when no measurement id is configured', function (): void { + config(['analytics.google.measurement_id' => null]); + + $html = $this->get('/')->assertOk()->getContent(); + + expect($html)->not->toContain('googletagmanager.com'); + expect($html)->not->toContain('gtag('); +}); + +/* + * Position, not presence. Each call in the head only means what it should if + * it runs before the next: storage denied, then a returning reader's stored + * answer applied, then the tag configured — at which point it sends the first + * page view with whatever consent state it has been given. + */ +it('denies storage before the tag is configured, and applies a stored answer in between', function (): void { + config(['analytics.google.measurement_id' => 'G-TEST123']); + + $html = $this->get('/')->assertOk()->getContent(); + + $default = strpos($html, "gtag('consent', 'default'"); + $stored = strpos($html, "gtag('consent', 'update', { analytics_storage: 'granted' })"); + $configured = strpos($html, "gtag('config'"); + + Assert::assertNotFalse($default, 'The tag must declare a consent default'); + Assert::assertNotFalse($stored, 'A returning reader who allowed analytics must be granted before the first hit'); + Assert::assertLessThan($stored, $default, 'The default must come before the stored answer'); + Assert::assertLessThan($configured, $stored, 'The stored answer must be applied before the tag is configured'); + + $defaults = substr($html, $default, $stored - $default); + + foreach (['ad_storage', 'ad_user_data', 'ad_personalization', 'analytics_storage'] as $signal) { + Assert::assertStringContainsString("{$signal}: 'denied'", $defaults, "{$signal} must default to denied"); + } +}); + +it('never grants an advertising signal', function () use ($readSource): void { + $sources = $readSource('resources/views/app.blade.php') . $readSource('resources/js/lib/consent.ts'); + + foreach (['ad_storage', 'ad_user_data', 'ad_personalization'] as $signal) { + expect($sources)->not->toMatch("/{$signal}:\\s*'granted'/"); + } +}); + +/* + * The head script and the module read the same key, written in two languages. + * Renamed on one side only, a reader who allowed analytics is asked again on + * every load and counted as a stranger in between. The account portal reads + * it too — see docs/architecture.md. + */ +it('reads the answer under the key the consent module writes', function () use ($readSource): void { + preg_match("/CONSENT_STORAGE_KEY = '([^']+)'/", $readSource('resources/js/lib/consent.ts'), $key); + + Assert::assertSame('tablepro:analytics-consent', $key[1] ?? null); + Assert::assertStringContainsString( + "localStorage.getItem('{$key[1]}')", + $readSource('resources/views/app.blade.php'), + 'app.blade.php must read the key consent.ts writes', + ); +}); + +/* + * `analytics.ts` is held to calls rather than words: its docblock names + * Plausible on purpose, to say where the event names came from. + */ +it('leaves nothing of Plausible behind', function () use ($readSource): void { + foreach ([ + 'resources/views/app.blade.php', + 'config/analytics.php', + '.env.example', + 'resources/js/pages/Privacy.tsx', + ] as $file) { + expect(stripos($readSource($file), 'plausible'))->toBeFalse(); + } + + expect($readSource('resources/js/lib/analytics.ts'))->not->toMatch('/plausible\s*\(|\.plausible\b/'); +}); + +it('asks on every page and lets the reader change their answer from any of them', function () use ($readSource): void { + $layout = $readSource('resources/js/layouts/landing-layout.tsx'); + $footer = $readSource('resources/js/components/landing/footer.tsx'); + + expect($layout)->toContain(''); + expect($footer)->toContain("{ label: 'Cookie settings', action: openConsentSettings }"); +}); + +/* + * Declining has to be as easy as allowing. The two buttons are the same + * variant at the same size; a filled Allow beside an outlined Decline is the + * nudge that makes consent not freely given. + */ +it('weighs Allow and Decline the same', function () use ($readSource): void { + $bar = $readSource('resources/js/components/landing/consent-bar.tsx'); + + preg_match_all('/\s*(Allow|Decline)\s*<\/Button>/s', $bar, $buttons, PREG_SET_ORDER); + + expect($buttons)->toHaveCount(2); + + $props = array_map(fn(array $match): string => str_replace(["'granted'", "'denied'"], '', $match[1]), $buttons); + + Assert::assertSame($props[0], $props[1], 'Allow and Decline must be styled identically'); +}); + +it('tells readers which cookies analytics sets and how to take consent back', function () use ($readSource): void { + $privacy = $readSource('resources/js/pages/Privacy.tsx'); + + foreach (['_ga', '_ga_<ID>', 'tablepro:analytics-consent', 'Google Analytics', 'Lawful basis: consent', 'onClick={openConsentSettings}'] as $needle) { + Assert::assertStringContainsString($needle, $privacy, "The privacy page must mention {$needle}"); + } + + expect($privacy)->not->toContain('cookie-less'); + expect($privacy)->toContain('id="cookies"'); +}); diff --git a/tests/js/consent.test.ts b/tests/js/consent.test.ts new file mode 100644 index 0000000..6a8d82a --- /dev/null +++ b/tests/js/consent.test.ts @@ -0,0 +1,138 @@ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + CONSENT_STORAGE_KEY, + analyticsCookieNames, + parseChoice, + readConsent, + saveConsent, +} from '../../resources/js/lib/consent.ts'; + +/* + * Behaviour tests for the analytics consent record. Run with `npm run test:js`. + * + * `saveConsent` is the one place a withdrawal actually happens: it has to tell + * the running tag and delete the cookies the tag already wrote. Both are side + * effects on browser globals, so the browser is faked below rather than the + * source being read — a source assertion would confirm a `document.cookie` + * write exists and nothing about whether it names the right domain. + */ + +interface FakeBrowser { + storage: Map; + gtagCalls: unknown[][]; + cookieWrites: string[]; +} + +function installBrowser(cookies: string, options: { storageThrows?: boolean; gtag?: boolean } = {}): FakeBrowser { + const fake: FakeBrowser = { storage: new Map(), gtagCalls: [], cookieWrites: [] }; + + const localStorage = { + getItem(key: string): string | null { + if (options.storageThrows) { + throw new Error('SecurityError'); + } + + return fake.storage.get(key) ?? null; + }, + setItem(key: string, value: string): void { + if (options.storageThrows) { + throw new Error('QuotaExceededError'); + } + + fake.storage.set(key, value); + }, + }; + + Object.assign(globalThis, { + window: { + localStorage, + location: { hostname: 'tablepro.app' }, + ...(options.gtag === false ? {} : { gtag: (...args: unknown[]) => fake.gtagCalls.push(args) }), + }, + document: { + get cookie(): string { + return cookies; + }, + set cookie(value: string) { + fake.cookieWrites.push(value); + }, + }, + }); + + return fake; +} + +beforeEach(() => { + delete (globalThis as Record).window; + delete (globalThis as Record).document; +}); + +test('reads only the two known answers', () => { + assert.equal(parseChoice('granted'), 'granted'); + assert.equal(parseChoice('denied'), 'denied'); + assert.equal(parseChoice(null), null); + assert.equal(parseChoice(''), null); + assert.equal(parseChoice('true'), null); + assert.equal(parseChoice('GRANTED'), null); +}); + +test('treats storage that throws as not asked yet', () => { + installBrowser('', { storageThrows: true }); + + assert.equal(readConsent(), null); +}); + +test('matches Google Analytics cookies and none of the site’s own', () => { + const names = analyticsCookieNames('nl_subscribed=1; _ga=GA1.1.123.456; _ga_FG4YV1QXYN=GS2.1.s1; _gab=x; nl_dismissed_at=2026'); + + assert.deepEqual(names, ['_ga', '_ga_FG4YV1QXYN']); +}); + +test('allowing records the answer and grants analytics storage only', () => { + const browser = installBrowser(''); + + saveConsent('granted'); + + assert.equal(browser.storage.get(CONSENT_STORAGE_KEY), 'granted'); + assert.deepEqual(browser.gtagCalls, [['consent', 'update', { analytics_storage: 'granted' }]]); + assert.deepEqual(browser.cookieWrites, []); +}); + +/* + * The withdrawal case. Consent Mode stops gtag touching its cookies, but leaves + * the ones already set in place — so declining after allowing must expire them, + * on the registrable domain gtag wrote them to, or a two-year identifier + * outlives the reader's "no". + */ +test('declining expires the analytics cookies on the domain gtag wrote them to', () => { + const browser = installBrowser('_ga=GA1.1.123.456; nl_subscribed=1; _ga_FG4YV1QXYN=GS2.1.s1'); + + saveConsent('denied'); + + assert.equal(browser.storage.get(CONSENT_STORAGE_KEY), 'denied'); + assert.deepEqual(browser.gtagCalls, [['consent', 'update', { analytics_storage: 'denied' }]]); + assert.deepEqual(browser.cookieWrites, [ + '_ga=; Max-Age=0; path=/; domain=.tablepro.app', + '_ga=; Max-Age=0; path=/', + '_ga_FG4YV1QXYN=; Max-Age=0; path=/; domain=.tablepro.app', + '_ga_FG4YV1QXYN=; Max-Age=0; path=/', + ]); +}); + +test('still applies the answer when storage refuses it', () => { + const browser = installBrowser('_ga=1', { storageThrows: true }); + + saveConsent('denied'); + + assert.deepEqual(browser.gtagCalls, [['consent', 'update', { analytics_storage: 'denied' }]]); + assert.equal(browser.cookieWrites.length, 2); +}); + +test('does not throw when the tag is not on the page', () => { + const browser = installBrowser('', { gtag: false }); + + assert.doesNotThrow(() => saveConsent('granted')); + assert.equal(browser.storage.get(CONSENT_STORAGE_KEY), 'granted'); +});