Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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 (
<div className="market">
<header className="market-top">
<div className="wordmark">
Well<span>formed</span>
</div>
<div className="grow" />
<a className="linkbtn" href="/pricing">
{t('marketing.pricing')}
</a>
{session.account ? (
<a className="btn primary" href="/">
{t('marketing.yourWorkspaces')}
</a>
) : (
<>
<a className="linkbtn" href="/signin">
{t('auth.signIn')}
</a>
{signupsOpen && (
<a className="btn primary" href="/signup">
{t('marketing.startFree')}
</a>
)}
</>
)}
</header>
<section className="hero">
{/* 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. */}
<p className="strapline">{t('marketing.strapline')}</p>
<h1>{t('marketing.headline')}</h1>
<p className="lede">{t('marketing.lede')}</p>
<div className="hero-cta">
{signupsOpen && (
<a className="btn primary big" href="/signup">
{t('marketing.moveOver')}
</a>
)}
{demo && (
<a className="btn big" href={workspacePath(demo.slug)}>
{t('marketing.openDemo')}
</a>
)}
</div>
<p className="hint">
{signupsOpen
? t(
'marketing.freeToStart',
t('marketing.limitTables', PLANS.free.tableLimit, locale),
t('marketing.limitRows', PLANS.free.rowLimit, locale),
)
: t('marketing.inviteOnly')}
</p>
</section>
{/*
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.
*/}
<section className="proof">
<div className="proof-label">{t('marketing.proofLabel')}</div>
{/* 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. */}
<img
className="shot"
src={`/grid-orders.${locale}.webp`}
width={1640}
height={830}
alt={t('marketing.proofAlt')}
/>
<p className="hint">{t('marketing.proofHint')}</p>
</section>
{/* The only part of the page that varies. Four of the nine in the pool. */}
<section className="benefits">
{voice.map((point) => (
<article key={point.id}>
<h2>{point.heading[locale]}</h2>
<p>{point.text[locale]}</p>
</article>
))}
</section>
<section className="pricing" id="pricing">
<h2>{t('marketing.pricingTitle')}</h2>
<p className="hint">{t('marketing.pricingHint')}</p>
<div className="plans">
{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 (
<div className={'plan' + (id === 'team' ? ' featured' : '')} key={id}>
<h3>{plan.name}</h3>
<div className="price">
{plan.priceEur === 0 ? (
<>€0</>
) : (
<>
€{plan.priceEur}
<span className="per">{t('marketing.perMonth')}</span>
</>
)}
</div>
<p className="blurb">{blurb}</p>
<ul>
{includes.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
<div className="limits mono">
{t('marketing.limitRows', plan.rowLimit, locale)} ·{' '}
{t('marketing.limitTables', plan.tableLimit, locale)}
</div>
{signupsOpen && (
<a className={'btn' + (id === 'team' ? ' primary' : '')} href="/signup">
{plan.priceEur === 0 ? t('marketing.startFree') : t('marketing.startFreeUpgrade')}
</a>
)}
</div>
);
})}
</div>
<p className="hint">{t('marketing.vatHint')}</p>
</section>
{/*
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.
*/}
<section className="closer">
<p>{t('marketing.closer')}</p>
{signupsOpen && (
<div className="hero-cta">
<a className="btn primary big" href="/signup">
{t('marketing.moveOver')}
</a>
</div>
)}
</section>
<footer className="market-foot">
<div className="wordmark">
Well<span>formed</span>
</div>
<div className="grow" />
{demo && <a href={workspacePath(demo.slug)}>{t('marketing.demo')}</a>}
<a href="/pricing">{t('marketing.pricing')}</a>
<a href="/signin">{t('auth.signIn')}</a>
{signupsOpen && <a href="/signup">{t('marketing.startFree')}</a>}
</footer>
</div>
);
}