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
22 changes: 20 additions & 2 deletions apps/web/e2e/content-skin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
340 changes: 340 additions & 0 deletions apps/web/src/app/(product-shell)/privacy/page.tsx

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions apps/web/src/components/marketing/ContentHub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -133,10 +134,12 @@ export async function ContentHub({ locale }: { locale: Locale }) {
</ul>
)}

{/* `/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`). */}
<Link
href="/new"
href={newRoomHref('/new', undefined, locale)}
className={buttonClassName({ shadowSize: '4', className: 'mt-8 justify-center text-h6' })}
>
{t('startRoom')}
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/marketing/SiteFooter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ describe('SiteFooter source receipt', () => {
const html = renderToStaticMarkup(<SiteFooter showLocaleSwitcher={false} />)

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_')
})

Expand All @@ -38,7 +39,7 @@ describe('SiteFooter source receipt', () => {
const html = renderToStaticMarkup(<SiteFooter showLocaleSwitcher={false} />)

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')
})
Expand Down
38 changes: 27 additions & 11 deletions apps/web/src/components/marketing/SiteFooter.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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

/**
Expand Down Expand Up @@ -58,7 +61,9 @@ export function SiteFooter({ showLocaleSwitcher = true }: { showLocaleSwitcher?:
<h2 className="text-h9 uppercase tracking-wide text-white">{t('colSplit')}</h2>
<ul className="mt-2 flex flex-col gap-1.5">
<li>
<Link href="/new" className={linkClass}>
{/* The page states its language in its URL; `/new` reads a cookie
it never set, so the link says it (`locale-handoff.ts`). */}
<Link href={newRoomHref('/new', undefined, locale)} className={linkClass}>
{t('createSplit')}
</Link>
</li>
Expand Down Expand Up @@ -142,14 +147,25 @@ export function SiteFooter({ showLocaleSwitcher = true }: { showLocaleSwitcher?:
<ul className="flex items-center gap-4">
{LEGAL_LINKS.map((entry) => (
<li key={entry.key}>
<a
href={entry.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-white/70 transition-colors hover:text-white"
>
{t(entry.key)}
</a>
{/* Split's own page is same-origin, so it opens in place; the
notices that still live on peanut.me open in a new tab. */}
{entry.href.startsWith('/') ? (
<Link
href={entry.href}
className="text-sm text-white/70 transition-colors hover:text-white"
>
{t(entry.key)}
</Link>
) : (
<a
href={entry.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-white/70 transition-colors hover:text-white"
>
{t(entry.key)}
</a>
)}
</li>
))}
</ul>
Expand Down
53 changes: 38 additions & 15 deletions apps/web/src/components/marketing/mdx/blocks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe('Hero hooks', () => {
it("puts split-btn on the hero's CTA link", () => {
const button = tagWith(html, 'split-btn')
expect(button).toMatch(/^<a/)
expect(button).toContain('href="/new"')
expect(button).toContain('href="/new?locale=en"')
})

/**
Expand All @@ -80,11 +80,11 @@ describe('Hero hooks', () => {
})

/**
* SEO loop A. The campaign code is a link attribute and nothing else — no text node, no locale in
* it, and nothing at all without a context, which is what keeps the generated guide corpus and
* every `renderArticle` call that passes none exactly as they were.
* SEO loop A plus the locale handoff. Both are link attributes and nothing else — no text node
* and the campaign half still needs a context, which is what keeps every `renderArticle` call
* that passes none reporting as one uncoded page.
*/
describe('the /new campaign code', () => {
describe('the /new link', () => {
const context: ContentRenderContext = {
chapter: 'trips',
seed: 7,
Expand All @@ -94,24 +94,47 @@ describe('the /new campaign code', () => {
}

it("codes the hero's CTA with the page slug", () => {
expect(hero({ context })).toContain('href="/new?campaign=content-fronting-a-group-trip"')
expect(hero({ context })).toContain('href="/new?campaign=content-fronting-a-group-trip&amp;locale=en"')
})

it('codes the CTA card the same way, from the same slug', () => {
const html = renderToStaticMarkup(<CTA text="Start a split" title="Open the room" context={context} />)
expect(html).toContain('href="/new?campaign=content-fronting-a-group-trip"')
expect(html).toContain('href="/new?campaign=content-fronting-a-group-trip&amp;locale=en"')
})

it('leaves /new bare with no context — a guide CTA is unchanged', () => {
expect(hero()).toContain('href="/new"')
expect(renderToStaticMarkup(<CTA text="Start a split" />)).toContain('href="/new"')
/** A6: `/new` reads a cookie, so a Spanish page that stated its language only in its own URL
* handed its reader to an English room creator. Every block carries the page's language. */
it('carries the page language on every block a localized landing can use', () => {
expect(hero({ context, locale: 'es-419' })).toContain(
'href="/new?campaign=content-fronting-a-group-trip&amp;locale=es-419"'
)
expect(renderToStaticMarkup(<CTA text="Crear un split" locale="es-419" context={context} />)).toContain(
'locale=es-419'
)
expect(
renderToStaticMarkup(
<RelatedPages title="Sigue leyendo">
<RelatedLink href="/new" locale="pt-br" context={context}>
Abra uma sala
</RelatedLink>
</RelatedPages>
)
).toContain('locale=pt-br')
})

/** An authored href that is not the bare `/new` is a deliberate destination; appending a second
* `?` to one that already carries a query would break it. */
it('never touches an href that is not the bare /new', () => {
it('carries the language with no context — an uncoded page still lands in its own language', () => {
expect(hero({ locale: 'pt-br' })).toContain('href="/new?locale=pt-br"')
expect(renderToStaticMarkup(<CTA text="Start a split" />)).toContain('href="/new?locale=en"')
})

/** An authored href that is not `/new` is a deliberate destination, and an authored `locale`
* or `campaign` on `/new` wins over the page's own — `ToolPage` writes its own campaign. */
it('never touches an href that is not /new, and never overwrites an authored param', () => {
expect(hero({ context, ctaHref: '/blog' })).toContain('href="/blog"')
expect(hero({ context, ctaHref: '/new?locale=es-419' })).toContain('href="/new?locale=es-419"')
expect(hero({ context, ctaHref: '/new?locale=es-419' })).toContain('locale=es-419')
expect(hero({ ctaHref: '/new?campaign=content-rent-split-calculator', locale: 'es-419' })).toContain(
'href="/new?campaign=content-rent-split-calculator&amp;locale=es-419"'
)
})

it('is a link attribute, never a word on the page', () => {
Expand Down Expand Up @@ -230,7 +253,7 @@ const HERO_ON_MAIN =
'<h1 class="mt-5 text-h3 leading-tight text-n-1">When every booking lands on your card</h1>' +
'<p class="mt-4 text-base font-medium leading-6 text-n-1">The deposit leaves your account in March.</p></div></div>' +
'<div class="mx-auto w-full max-w-xl px-5 pt-6"><a class="btn flex items-center gap-2 transition-all duration-100 ' +
'active:translate-x-[3px] active:shadow-none w-full btn-primary btn-shadow-primary-4 justify-center text-h6" href="/new">Start a split</a>' +
'active:translate-x-[3px] active:shadow-none w-full btn-primary btn-shadow-primary-4 justify-center text-h6" href="/new?locale=en">Start a split</a>' +
'<p class="mt-3 text-center text-sm text-grey-1">Takes ten seconds.</p></div></section>'

/** Drops every `split-*` token and re-joins on single spaces, applied to both sides. */
Expand Down
47 changes: 31 additions & 16 deletions apps/web/src/components/marketing/mdx/blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,32 @@ export interface ContentRenderContext {
}

/**
* The room-creation link, campaign-coded when we know which article it is on (SEO loop A).
* The room-creation link: campaign-coded when we know which article it is on (SEO loop A), and
* always carrying the page's own language.
*
* Only the bare `/new` is rewritten: an authored `ctaHref` that already carries a query is a
* deliberate destination, and appending a second `?` would break it. The result is one plain link
* attribute — no text node, deterministic per slug and identical in every locale — which is what
* lets a content-sourced room be counted without any app-side change to `/new`. Precedent:
* `SettleDrawer`'s `campaign=split`.
* The locale is not decoration. `/new` reads the language off a cookie, and an article states its
* language in its URL and never sets that cookie — so a Spanish landing handed its reader to an
* English room creator. `locale-handoff.ts` is the receiving end, and the generated guides have
* pointed at `/new?locale=…` since they shipped; the authored pages are the ones that did not.
*
* Guides never reach here with a context (their CTA is `ContentCTA`), so the generated corpus is
* untouched.
* Only a `/new` href is rewritten, and an authored query survives — `ToolPage` writes its own
* `campaign` — so the result stays one plain link attribute, deterministic per slug and language,
* which is what lets a content-sourced room be counted without any app-side change to `/new`.
* Precedent: `SettleDrawer`'s `campaign=split`.
*
* Every block an article can point at `/new` calls this: `Hero`, `CTA`, and `RelatedLink` — the
* last because who-pays-for-the-wine ends its related list with one, and a single uncoded link on
* a pilot page is a hole in the only number this loop exists to produce.
* Every block an article can point at `/new` calls this: `Hero`, `CTA`, `RelatedLink` and the
* prose `a` — the last two because who-pays-for-the-wine ends its related list with one and
* fair-split-calculator sends its reader on mid-sentence, and a single uncoded link on a paid
* page is a hole in the only number this loop exists to produce.
*/
function withCampaign(href: string, slug: string | undefined): string {
return slug && href === '/new' ? `${href}?campaign=content-${slug}` : href
export function newRoomHref(href: string, slug: string | undefined, locale: Locale): string {
const [pathname, query] = href.split('?')
if (pathname !== '/new') return href

const params = new URLSearchParams(query)
if (slug && !params.has('campaign')) params.set('campaign', `content-${slug}`)
if (!params.has('locale')) params.set('locale', locale)
return `${pathname}?${params}`
}

export function Hero({
Expand Down Expand Up @@ -115,7 +124,7 @@ export function Hero({
{cta && (
<div className={`${COLUMN} pt-6`}>
<Link
href={withCampaign(ctaHref, context?.slug)}
href={newRoomHref(ctaHref, context?.slug, locale)}
className={buttonClassName({ shadowSize: '4', className: 'split-btn justify-center text-h6' })}
>
{cta}
Expand All @@ -132,13 +141,16 @@ export function CTA({
href = '/new',
title,
body,
locale = 'en',
context,
}: {
text: string
href?: string
title?: string
body?: string
/** Not MDX-authored — bound in `localizedMdxComponents`. */
locale?: Locale
/** Not MDX-authored — bound in `localizedMdxComponents`. */
context?: ContentRenderContext
}) {
return (
Expand All @@ -147,7 +159,7 @@ export function CTA({
{title && <h2 className="split-block-title text-h5">{title}</h2>}
{body && <p className="mt-2 text-sm leading-5 text-grey-1">{body}</p>}
<Link
href={withCampaign(href, context?.slug)}
href={newRoomHref(href, context?.slug, locale)}
className={buttonClassName({ shadowSize: '4', className: 'split-btn mt-4 justify-center text-h6' })}
>
{text}
Expand Down Expand Up @@ -383,18 +395,21 @@ export function RelatedPages({ title = 'Keep reading', children }: { title?: str
export function RelatedLink({
href,
children,
locale = 'en',
context,
}: {
href: string
children: ReactNode
/** Not MDX-authored — bound in `localizedMdxComponents`. */
locale?: Locale
/** Not MDX-authored — bound in `localizedMdxComponents`. A related list is the one other place
* an article authors `/new` (who-pays-for-the-wine does), so loop A has to reach it too. */
context?: ContentRenderContext
}) {
return (
<li>
<Link
href={withCampaign(href, context?.slug)}
href={newRoomHref(href, context?.slug, locale)}
data-focus-contained
className="flex min-h-11 items-center gap-2 bg-white px-4 py-3 hover:bg-grey-3"
>
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/marketing/mdx/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
RelatedPages,
Step,
Steps,
newRoomHref,
type ContentRenderContext,
} from './blocks'
import { Calc } from './Calc'
Expand Down Expand Up @@ -179,10 +180,15 @@ export function localizedMdxComponents(
<RelatedPages title={title ?? labels.related}>{children}</RelatedPages>
),
RelatedLink: ({ href, children }: { href: string; children: ReactNode }) => (
<RelatedLink href={href} context={context}>
<RelatedLink href={href} locale={locale} context={context}>
{children}
</RelatedLink>
),
// A prose link is the one room-creation link a page can carry without a block around it
// (fair-split-calculator sends its reader on mid-sentence), so it takes the same handoff.
a: ({ href = '', ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => (
<ProseLink href={newRoomHref(href, context?.slug, locale)} {...props} />
),
// A page with a `<Hero>` gets its h1 from the hero. A capture page has no hero by
// stylebook, so its h1 is a markdown `#` — same typography, and the same ShortVersion slot
// right after it.
Expand All @@ -202,7 +208,7 @@ export function localizedMdxComponents(
// `/new` campaign code, `Share` reads the canonical too. A guide calls this with no context
// and gets exactly the behaviour it had before — an uncoded `/new` and no share block.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
CTA: (props: any) => <CTA {...props} context={context} />,
CTA: (props: any) => <CTA {...props} locale={locale} context={context} />,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Share: (props: any) => <Share {...props} context={context} />,
}
Expand Down
Loading
Loading