diff --git a/apps/web/e2e/content-skin.spec.ts b/apps/web/e2e/content-skin.spec.ts index 490ff5b9..46127eee 100644 --- a/apps/web/e2e/content-skin.spec.ts +++ b/apps/web/e2e/content-skin.spec.ts @@ -113,10 +113,28 @@ test.describe('content SEO loops', () => { }) } - test('the fronting hero CTA points at a campaign-coded /new', async ({ page }) => { + test('the fronting hero CTA points at a campaign-coded, locale-pinned /new', async ({ page }) => { await page.goto('/blog/fronting-a-group-trip') - await expect(page.locator('a[href="/new?campaign=content-fronting-a-group-trip"]').first()).toBeVisible() + await expect( + page.locator('a[href="/new?campaign=content-fronting-a-group-trip&locale=en"]').first() + ).toBeVisible() }) + + // A6: a paid Spanish click landed in an English room creator, because `/new` reads a cookie + // the landing never sets. Both localized landings state their language in the link itself. + for (const [path, locale] of [ + ['/es-419/splitwise-alternative', 'es-419'], + ['/pt-br/splitwise-alternative', 'pt-br'], + ]) { + test(`the ${locale} switcher landing hands its language to /new`, async ({ page }) => { + await page.goto(path) + const links = page.locator('a[href^="/new"]') + await expect(links.first()).toBeVisible() + for (const href of await links.evaluateAll((all) => all.map((a) => a.getAttribute('href')))) { + expect(href).toContain(`locale=${locale}`) + } + }) + } }) test.describe('tool page footer pin', () => { diff --git a/apps/web/src/app/(product-shell)/privacy/page.tsx b/apps/web/src/app/(product-shell)/privacy/page.tsx new file mode 100644 index 00000000..3306e619 --- /dev/null +++ b/apps/web/src/app/(product-shell)/privacy/page.tsx @@ -0,0 +1,340 @@ +import type { Metadata } from 'next' +import { Breadcrumbs } from '@/components/marketing/Breadcrumbs' +import { JsonLd } from '@/components/marketing/JsonLd' +import { SiteFooter } from '@/components/marketing/SiteFooter' +import { FEEDBACK_RETENTION_DAYS } from '@/lib/feedback-contract' +import { GOOGLE_ADS_ID } from '@/lib/google-ads' +import { breadcrumbSchema, pageMetadata, pageTitle } from '@/lib/seo' + +/** + * Split's own privacy notice. + * + * It exists because Split started running an advertising tag of its own on 2026-08-24 while the + * footer still pointed at peanut.me's policy, which describes a wallet: accounts, passkeys, + * identity documents, payment rails. None of that is this product, and a policy that describes + * the wrong product is worse than a missing one. + * + * This page sits directly under `(product-shell)`, not under `(marketing)`, so the one page that + * describes the advertising tag is the one page that does not load it. + * + * Written from what the code does, not from a template. Every claim below has a file behind it: + * the tag in `lib/google-ads.ts`, the analytics boundary in `lib/analytics.ts`, error reporting in + * `instrumentation-client.ts`, the receipt path in `server/model.ts`, and the stored shapes in + * `prisma/schema.prisma`. Change one of those and change this page in the same push. + * + * English only. `/es-419/privacy` and `/pt-br/privacy` are not routes — the same rule the rest of + * the untranslated surface follows. + */ + +const PATH = '/privacy' +const EFFECTIVE_DATE = '1 September 2026' + +export function generateMetadata(): Metadata { + return pageMetadata({ + title: pageTitle('Privacy'), + description: + 'What Peanut Split stores, what it measures, what its advertising tag does, and how to contact Squirrel Labs Ltd about it.', + path: PATH, + type: 'website', + }) +} + +const crumbs = [ + { name: 'Home', href: '/' }, + { name: 'Privacy', href: PATH }, +] + +const externalLink = 'font-semibold text-n-1 underline decoration-2 underline-offset-2 hover:opacity-70' + +export default function PrivacyPage() { + return ( +
+ + + +
+
+

Privacy

+

What Split knows about you

+

+ Split has no accounts, so most of what a privacy policy usually covers does not exist here. This + page says what is left. +

+

Effective {EFFECTIVE_DATE}

+
+
+ +
+
+

+ Who runs this +

+
+

+ The official service at peanutsplit.com is operated by Squirrel Labs Ltd, a company + registered in England and Wales (company number 14558823), registered office Office One, 1 + Coldbath Square, Farringdon, London EC1R 5HL. Squirrel Labs is the controller of the + personal data described here, under UK data protection law. +

+

+ This notice covers peanutsplit.com only. Peanut’s wallet and card are a different + product with a different policy, and nothing on this page describes them. If you settle a + balance through a Peanut payment link, you leave Split and{' '} + + Peanut’s privacy policy + {' '} + applies from that point. +

+
+
+ +
+

+ There is no account +

+
+

+ Split asks for no email address, no password, no phone number and no identity document. A + room is a link, and holding the link is what grants access. Nobody has to prove who they are + to use it, and there is no profile to look you up in afterwards. +

+

+ The room link is therefore a credential. Anyone you send it to can read and change the room, + and we cannot tell them apart from you. +

+
+
+ +
+

+ What the service stores +

+

+ A room and its contents are held on our servers, because everyone in the group has to see the + same numbers. That is: +

+
    +
  • the room’s name, currency and drawing;
  • +
  • the display names people type for themselves, which do not have to be real names;
  • +
  • each expense: its description, amount, date, who paid, and how it was split;
  • +
  • settlements recorded in the room, and the reactions people leave on an expense;
  • +
  • + a push subscription for each device that turns notifications on in a room — the + browser’s push address, its keys, and the browser’s user-agent string; +
  • +
  • + a record of edits to the room, kept so the group can see what changed rather than for us to + read. +
  • +
+

+ Whatever the group writes into a room is stored as written. An expense described “dinner + with Ana” names Ana, and there is nothing we can do about that from here — so put + into a room only what the group is happy for the group to read. +

+
+ +
+

+ What stays on your device +

+

+ Your list of recent rooms, the token that says which member of a room you are, and your app + settings live in your browser’s local storage. They are not sent to us. Clearing site data + loses them, and a room you have no link to is a room you cannot get back. +

+

Split itself sets two cookies:

+
    +
  • + ps-locale — the language you chose. One year. +
  • +
  • + device-id — a random value with no meaning outside + this site, used to keep an installed home-screen app recognised as the same anonymous device + rather than a new one. It is not tied to a name, an email or a room. +
  • +
+
+ +
+

+ Measurement +

+
+

+ We use PostHog to count how the product is used, on European infrastructure. It is + deliberately blind: automatic capture is off, session recording is off, page text is masked, + and no room link, member name, expense description or amount is ever attached to an event. + What we get is that a room was created, an expense was added, a share sheet opened — + not whose, and not for how much. +

+

+ When something breaks, Sentry receives the error. Errors only: no performance tracing and no + session replay, and room links are stripped out of the report before it is sent. +

+
+
+ +
+

+ Advertising +

+
+

+ Since 24 August 2026, peanutsplit.com carries a Google Ads tag ( + {GOOGLE_ADS_ID}). It runs on this site only, and it has one + job: to tell Google that an ad click ended in a room being created, so we can tell which + adverts are worth paying for. +

+

+ When you arrive from a Google advert, the link carries a click identifier. The tag reads it + and stores it in a Google cookie on this site (_gcl_aw) so + that a room created later in the same browser can be matched back to that click. If you did + not arrive from an advert, there is no click identifier to store. +

+

+ The tag reports no page views. When a room is created it reports one event, with no value + attached and nothing about the room — not its link, its name, its currency or its + amounts. The address it reports is rebuilt before it is sent: room links are removed, and so + is anything else in the URL except the Google click identifiers and the campaign labels that + were already public in the advert. +

+

+ Google is an independent controller of what it receives.{' '} + + Google’s advertising policy + {' '} + covers that half. You can block the cookie in your browser; the product works exactly the + same without it. +

+ {/* TODO(konrad): consent. The tag loads on first paint with no cookie banner. That is a + deliberate open question, not an oversight: PECR/EU consent for an advertising cookie is a + legal call, not an engineering one. Either rule that the current behaviour stands and this + paragraph is the notice, or say so and the tag gets gated behind a consent control before + the flight runs. */} +
+
+ +
+

+ Photographs of receipts +

+

+ If you photograph a receipt to fill in an expense, the image is sent once to a language-model + provider to be read, and the answer comes back as text. Split does not keep the photograph: + there is no column, bucket or temporary file for it, and neither the image nor anything read off + it is written to a log. The provider is required to be one that retains nothing and trains on + nothing. The feature is off entirely unless the operator has configured it. +

+
+ +
+

+ Who else is involved +

+

+ We do not sell personal data, and nothing here is used to build an advertising profile of you. + The service depends on: +

+
    +
  • Google Ads — conversion measurement, as described above;
  • +
  • PostHog — product analytics;
  • +
  • Sentry — error reports;
  • +
  • + a language-model provider — receipt photographs, held for the length of one request; +
  • +
  • + your browser vendor’s push service — it delivers a notification you asked for, + and it necessarily sees that a message was sent to your device; +
  • +
  • our hosting provider — ordinary web-server request logs, including IP addresses.
  • +
+

+ Some of these operate outside the UK. We may also disclose data where the law requires it. +

+
+ +
+

+ How long it is kept +

+
+

+ A room stays until it is deleted. Nothing expires it on a timer today, which means a room + nobody has opened in a year is still there for whoever still holds the link. Deleting an + expense or a room deletes it. +

+

+ A feedback report you choose to send is deleted after {FEEDBACK_RETENTION_DAYS} days. The + handoff token used when you add Split to an iPhone home screen expires in minutes and is + then removed. +

+ {/* TODO(konrad): the retention period for room data. The paragraph above is what the code + does, which is "forever until someone deletes it". Whether that is also what we want to + promise — and whether dormant rooms should be swept — is a product and legal decision. */} +
+
+ +
+

+ Your rights, and the awkward part +

+
+

+ Under UK data protection law you can ask for a copy of your personal data, ask us to correct + it, ask us to delete it, object to how we use it, or complain to the Information + Commissioner’s Office at{' '} + + ico.org.uk + + . We would rather you came to us first. +

+

+ Being accountless cuts both ways. We have no way to find “your” data, because + there is no identifier that connects you to a room from our side. To make a request about a + room, send us its link — that is the only thing that identifies it. Anyone else + holding the same link can make the same request, which is one more reason to treat it as a + credential. +

+

+ Everyone in a room can already see everything in it. Deleting your own name and expenses + from a shared room changes what the rest of the group sees, so tell them rather than + surprising them. +

+
+
+ +
+

+ Children +

+

+ Split is not intended for anyone under 18, and we do not knowingly collect data about children. + Contact us if you believe we have. +

+
+ +
+

+ Contact +

+
    +
  • + Email:{' '} + + support@peanut.me + +
  • +
  • Post: Squirrel Labs Ltd, Office One, 1 Coldbath Square, Farringdon, London EC1R 5HL
  • +
+

+ If this page changes we will change the date at the top. This notice is written in English; a + translation, if one is ever published, does not override it. +

+
+
+ + +
+ ) +} diff --git a/apps/web/src/components/marketing/ContentHub.tsx b/apps/web/src/components/marketing/ContentHub.tsx index 042b8ca8..363c6a44 100644 --- a/apps/web/src/components/marketing/ContentHub.tsx +++ b/apps/web/src/components/marketing/ContentHub.tsx @@ -4,6 +4,7 @@ import { Breadcrumbs } from '@/components/marketing/Breadcrumbs' import { JsonLd } from '@/components/marketing/JsonLd' import { SiteFooter } from '@/components/marketing/SiteFooter' import { LanguageLinks } from '@/components/marketing/LanguageLinks' +import { newRoomHref } from '@/components/marketing/mdx/blocks' import { buttonClassName } from '@/components/ui/button-style' import { STATIC_PAGES } from '@/data/static-pages' import { listAllDocs } from '@/lib/content' @@ -133,10 +134,12 @@ export async function ContentHub({ locale }: { locale: Locale }) { )} - {/* `/new` is app shell too — one URL, cookie decides the language. `/es-419/new` and - `/pt-br/new` are not routes, so prefixing this made the hub's only CTA a 404. */} + {/* `/new` is app shell too — one URL, and the language rides in the query rather + than the path: `/es-419/new` and `/pt-br/new` are not routes, so prefixing this + made the hub's only CTA a 404, while the cookie alone sent a Spanish reader to + an English room creator (`locale-handoff.ts`). */} {t('startRoom')} diff --git a/apps/web/src/components/marketing/SiteFooter.test.tsx b/apps/web/src/components/marketing/SiteFooter.test.tsx index 7c4eda50..7761525c 100644 --- a/apps/web/src/components/marketing/SiteFooter.test.tsx +++ b/apps/web/src/components/marketing/SiteFooter.test.tsx @@ -29,7 +29,8 @@ describe('SiteFooter source receipt', () => { const html = renderToStaticMarkup() expect(html.match(/href="\/source"/g)).toHaveLength(1) - expect(html.match(/peanut\.me/g)).toHaveLength(2) + // One, not two: privacy is Split's own page now, so Terms is the last peanut.me link here. + expect(html.match(/peanut\.me/g)).toHaveLength(1) expect(html).not.toContain('utm_') }) @@ -38,7 +39,7 @@ describe('SiteFooter source receipt', () => { const html = renderToStaticMarkup() expect(html).toContain('href="https://peanut.me/en/terms"') - expect(html).toContain('href="https://peanut.me/en/privacy"') + expect(html).toContain('href="/privacy"') expect(html).not.toContain('utm_') expect(html).not.toContain('peanut-logo') }) diff --git a/apps/web/src/components/marketing/SiteFooter.tsx b/apps/web/src/components/marketing/SiteFooter.tsx index 58a2a011..1312fc6d 100644 --- a/apps/web/src/components/marketing/SiteFooter.tsx +++ b/apps/web/src/components/marketing/SiteFooter.tsx @@ -1,5 +1,6 @@ import Link from 'next/link' import { useLocale, useTranslations } from 'next-intl' +import { newRoomHref } from '@/components/marketing/mdx/blocks' import { LocaleSwitcher } from '@/components/ui/LocaleSwitcher' import { INDEXED_LOCALES, asLocale, type Locale } from '@/i18n/locales' import { localizedPath } from '@/i18n/paths' @@ -10,12 +11,14 @@ import { publicFossReleased } from '@/lib/flags' * column stops being a directory and starts being a second copy of /blog. */ const GUIDES_SHOWN = 4 -/** The official host's Terms and Privacy live on peanut.me; Split has no legal pages of its own. +/** Terms still live on peanut.me; privacy is Split's own page, because peanut.me's policy + * describes a wallet with accounts, passkeys and identity documents, and Split has none of that + * while it does have an advertising tag peanut.me's policy never mentions. * These are notices, not promotion: no logo, no UTM, no referral code, and the counted * Peanut-reference standard exempts them the way it exempts the settlement method's URL. */ const LEGAL_LINKS = [ { key: 'termsLink', href: 'https://peanut.me/en/terms' }, - { key: 'privacyLink', href: 'https://peanut.me/en/privacy' }, + { key: 'privacyLink', href: '/privacy' }, ] as const /** @@ -58,7 +61,9 @@ export function SiteFooter({ showLocaleSwitcher = true }: { showLocaleSwitcher?:

{t('colSplit')}