Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

9.8 kB Ruw bestand
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>
  );
}