Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
23 changes: 13 additions & 10 deletions config/analytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
],
];
4 changes: 2 additions & 2 deletions config/banner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),

Expand Down
46 changes: 43 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_<stream>`, 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

Expand Down
82 changes: 82 additions & 0 deletions resources/js/components/landing/consent-bar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
aria-label="Analytics cookies"
className="consent-bar fixed bottom-3 left-3 right-[5.5rem] z-50 rounded-xl border border-rule-strong bg-popover p-4 text-popover-foreground shadow-lg sm:bottom-4 sm:left-4 sm:right-auto sm:w-[23rem]"
>
<p className="text-sm font-semibold">Analytics cookies</p>
<p className="mt-1 text-sm text-muted-foreground">
May Google Analytics set cookies so we can see which pages bring people to TablePro? Declining changes nothing else.{' '}
<a href="/privacy#cookies" className={PROSE_LINK}>
Privacy policy
</a>
</p>
<div className="mt-3 flex gap-2">
<Button variant="secondary" size="sm" onClick={() => choose('granted')} className="flex-1">
Allow
</Button>
<Button variant="secondary" size="sm" onClick={() => choose('denied')} className="flex-1">
Decline
</Button>
</div>
</section>
);
}
31 changes: 24 additions & 7 deletions resources/js/components/landing/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 },
],
},
{
Expand Down Expand Up @@ -232,13 +239,23 @@ export default function Footer() {
<ul className="mt-4 grid gap-3">
{col.links.map((link) => (
<li key={link.label}>
<a
href={link.href}
{...(link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
className="text-muted-foreground hover:underline"
>
{link.label}
</a>
{link.action ? (
<button
type="button"
onClick={link.action}
className="cursor-pointer text-muted-foreground hover:underline"
>
{link.label}
</button>
) : (
<a
href={link.href}
{...(link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
className="text-muted-foreground hover:underline"
>
{link.label}
</a>
)}
</li>
))}
</ul>
Expand Down
6 changes: 3 additions & 3 deletions resources/js/components/landing/support-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions resources/js/components/ui/prose-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<FullLine />
<div className="p-6 sm:p-8">
<div id={id} className="p-6 sm:p-8">
<h2 className="text-xl font-bold text-foreground sm:text-2xl">{title}</h2>
<div className="mt-4">{children}</div>
</div>
Expand Down
8 changes: 8 additions & 0 deletions resources/js/layouts/landing-layout.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -135,6 +136,13 @@ export default function LandingLayout({ header, footer, children }: Props) {
aria-hidden="true"
/>
</div>
{/*
* 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.
*/}
<ConsentBar />
</div>
);
}
26 changes: 17 additions & 9 deletions resources/js/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> }) => void;
interface GtagWindow {
gtag?: (command: 'event', name: string, params: Record<string, string>) => void;
}

export function trackEvent(name: string, props: Record<string, string> = {}): void {
export function trackEvent(name: string, params: Record<string, string> = {}): 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);
}
}

Expand Down
12 changes: 7 additions & 5 deletions resources/js/lib/attribution.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down
Loading
Loading