import { useEffect, type ReactNode } from 'react'; import { PLAN_IDS, PLANS, type VoiceAssignment } from '@wellformed/shared'; import { usePublicConfig } from '../api/hooks'; import { useI18n } from '../i18n'; import { useSession } from '../state/session'; import { useDismissSsr } from '../state/ssr'; import { useVoice } from '../state/voice'; import { workspacePath } from '../state/route'; /** * The page you land on with no account. * * Everything on it is either true of this install or a claim the demo can be * checked against — no logos, no numbers nobody counted, no testimonials. A * product whose pitch is "the SQL bar is honest" cannot open with a made-up * customer count. The limits quoted come from PLANS for the same reason: a * number typed in here would go stale the first time a plan moved. * * It is written for the person who *inherited* the spreadsheet, not the one who * built it — so the engine is described by what it refuses rather than by how it * works, and no sentence needs the words SQL, foreign key or expression to land. * The product is a spreadsheet and a relational engine at once, and the page has * to say both in that order: spreadsheet first, because that is the only thing * the visitor already knows how to use, and the rules in the same breath, never * later — refusing bad data is the whole product, and a visitor who was promised * only a spreadsheet reads their first rejection as a bug. * * Every word here is fixed except the four selling points in the middle, which * are drawn per visit from `shared/voice.ts` and recorded against how long that * visitor went on to actually use the product. The mechanism is in * `server/src/voice/`; what matters here is how little of the page it owns. * * That boundary was moved inward deliberately. An earlier version let the * headline and lede rotate too, and a page whose opening lines all had to * survive being read beside every other opening line could not be written as one * argument any more — each of them ended up saying everything and selling * nothing. Selling points do not have that problem: they are a list by nature, * four out of nine reads exactly like four out of nine, and which four is a real * question that taste cannot settle. * * What the page offers stays deliberately narrow: read the demo, or start a * workspace. */ export function MarketingScreen({ focus, ssrVoice, }: { focus?: 'pricing'; ssrVoice?: VoiceAssignment | null } = {}): ReactNode { const session = useSession(); const install = usePublicConfig(); const voice = useVoice(ssrVoice); const { t, locale } = useI18n(); useDismissSsr(); // The demo that speaks this page's language — not the browser's, which can // differ on /en and /nl. A page pinned to one language linking a demo in // another would undo the pinning one click in. const demos = install.data?.demos ?? []; const demo = demos.find((candidate) => candidate.locale === locale) ?? demos[0] ?? null; const signupsOpen = install.data?.signupsOpen ?? true; // `/pricing` is a real URL people paste at each other, so it has to arrive // at the prices rather than at the top of the page. useEffect(() => { if (focus !== 'pricing') return; const target = document.getElementById('pricing'); // Guarded: not every environment that renders this page can scroll one. target?.scrollIntoView?.({ block: 'start' }); }, [focus, install.data]); return (
Wellformed
{t('marketing.pricing')} {session.account ? ( {t('marketing.yourWorkspaces')} ) : ( <> {t('auth.signIn')} {signupsOpen && ( {t('marketing.startFree')} )} )}
{/* Read before the headline, and the only job it has is to make sure a visitor who reads one line of this page still knows what kind of tool it is. The headline is then free to be about them. */}

{t('marketing.strapline')}

{t('marketing.headline')}

{t('marketing.lede')}

{signupsOpen && ( {t('marketing.moveOver')} )} {demo && ( {t('marketing.openDemo')} )}

{signupsOpen ? t( 'marketing.freeToStart', t('marketing.limitTables', PLANS.free.tableLimit, locale), t('marketing.limitRows', PLANS.free.rowLimit, locale), ) : t('marketing.inviteOnly')}

{/* A real screenshot of a real workspace, not a drawing of one — including the dark strip, which is the actual statement the server ran for exactly these rows. An earlier version of this section hand-wrote that SQL in markup, which put eight lines of it in front of a reader who had not yet been shown a grid, and could drift from what the compiler really emits. */}
{t('marketing.proofLabel')}
{/* One capture per language, of the demo that speaks it: the proof is a screenshot, and a screenshot has a language like everything else on this page. Adding a locale means adding a capture. */} {t('marketing.proofAlt')}

{t('marketing.proofHint')}

{/* The only part of the page that varies. Four of the nine in the pool. */}
{voice.map((point) => (

{point.heading[locale]}

{point.text[locale]}

))}

{t('marketing.pricingTitle')}

{t('marketing.pricingHint')}

{PLAN_IDS.map((id) => { const plan = PLANS[id]; const blurb = t(id === 'free' ? 'marketing.plan.free.blurb' : 'marketing.plan.team.blurb'); const includes = id === 'free' ? [ t('marketing.plan.free.include1'), t('marketing.plan.free.include2'), t('marketing.plan.free.include3'), t('marketing.plan.free.include4'), ] : [ t('marketing.plan.team.include1'), t('marketing.plan.team.include2'), t('marketing.plan.team.include3', plan.rowLimit ?? 0, locale), t('marketing.plan.team.include4'), ]; return (

{plan.name}

{plan.priceEur === 0 ? ( <>€0 ) : ( <> €{plan.priceEur} {t('marketing.perMonth')} )}

{blurb}

    {includes.map((line) => (
  • {line}
  • ))}
{t('marketing.limitRows', plan.rowLimit, locale)} ·{' '} {t('marketing.limitTables', plan.tableLimit, locale)}
{signupsOpen && ( {plan.priceEur === 0 ? t('marketing.startFree') : t('marketing.startFreeUpgrade')} )}
); })}

{t('marketing.vatHint')}

{/* The last word before the footer. One button, not the hero's pair: the demo is already reachable from the hero and the footer, and a third link to it here would make the page offer two answers at the exact point it is asking for one. */}

{t('marketing.closer')}

{signupsOpen && (
{t('marketing.moveOver')}
)}
); }