Landing page

landing

The public marketing site for the app (codename Shuke / 述刻): home page with waitlist, privacy policy, terms, support/FAQ, 404, robots.txt, sitemap.xml. English is the default locale at /; Simplified Chinese lives under /zh (ADR-001 D13).

Stack: TanStack Start (React 19, Vite, TypeScript strict, file-based routes) + Tailwind v4, deployed to Cloudflare Workers via @cloudflare/vite-plugin. No analytics, no cookies, no external scripts or web fonts.

Develop

cd landing
pnpm install
pnpm dev            # http://localhost:3000, runs inside workerd with a local simulated KV

pnpm dev runs the Worker in the real Workers runtime, so the WAITLIST KV binding works locally out of the box (data is stored under .wrangler/state). No Cloudflare account needed.

Quick checks:

curl localhost:3000/api/health
curl -X POST localhost:3000/api/waitlist -H 'content-type: application/json' -d '{"email":"me@example.com","locale":"en"}'
pnpm wrangler kv key get "email:me@example.com" --binding WAITLIST --local

Build

pnpm build          # vite build (client + worker) then `wrangler types && tsc --noEmit`
pnpm preview        # serve the production build locally in workerd

CI (.github/workflows/landing.yml) runs pnpm build on every PR touching landing/.

Deploy

One-time setup:

pnpm wrangler login
pnpm wrangler kv namespace create WAITLIST
# paste the returned id into wrangler.jsonc → kv_namespaces[0].id

Then:

pnpm run deploy     # build + wrangler deploy

Read the waitlist:

pnpm wrangler kv key list --binding WAITLIST --remote

Routes

Every page exists once per locale: /<page> (en) and /zh/<page> (zh-Hans).

Path What
/, /zh Hero, 3-step explainer, before/after mock, waitlist form. On / only, if Accept-Language prefers another supported locale, a one-line hint links to it (never an automatic redirect).
/privacy, /terms (+ /zh/...) Legal skeletons (required by the paywall, ADR-001 D8). Placeholders in [brackets] are highlighted on the page.
/support (+ /zh/support) Contact + FAQ
POST /api/waitlist Shared by all locales. JSON { email, locale, website } → { status: "ok" | "duplicate" | "invalid" | "error" } (201/200/400/500). Form posts (no JS) get a 303 back to the locale's home with ?waitlist=<status>#waitlist. website is a honeypot; unknown locale falls back to en.
GET /api/health { "status": "ok" }
/robots.txt, /sitemap.xml Generated from brand.siteUrl; the sitemap lists every page per locale with xhtml:link hreflang alternates.

Any other first path segment (/en, /fr/...) is a 404, so each page has exactly one URL per locale. Every page emits <html lang>, a canonical URL, hreflang alternates for all locales plus x-default (en), and og:locale / og:locale:alternate. The header and footer link to the same page in the other language.

KV layout: key email:<lowercased email>, value { "createdAt": ISO-8601, "locale": "en" | "zh-Hans" }. Nothing else (no IP, no user agent) is stored, matching the privacy policy. A duplicate keeps the first record.

Copy and locales

  • src/content/types.ts: the Content type every locale must satisfy.
  • src/content/en.ts, src/content/zh-Hans.ts: all user-facing strings, one file per locale.
  • src/content/brand.ts: locale-independent settings (Latin name, domain, App Store link).
  • src/lib/i18n.ts: the locale registry (LOCALES, localeConfig) and path helpers. Nothing else branches on a specific locale.

Adding a locale

  1. Copy src/content/en.ts to src/content/<tag>.ts (e.g. ja.ts), export it with the Content type and translate it. Keep [bracket] placeholders recognisable.
  2. In src/lib/i18n.ts, add the tag to LOCALES and an entry to localeConfig: URL segment (e.g. "ja"), BCP-47 tag, nativeName (switch label), ogLocale, the Accept-Language primary subtags it matches, and the content.
  3. pnpm build. TypeScript fails on any missing string. Routes, <html lang>, hreflang, the sitemap, the language switch and the waitlist locale value all come from the registry.

Renaming / changing the domain

  1. src/content/brand.ts: latinName, siteUrl (drives canonical, hreflang, og:url, sitemap, robots), appStoreUrl + appComingSoon = false once the app is live.
  2. name at the top of each locale file (述刻 in zh-Hans; en uses brand.latinName).
  3. company, contactEmail and the other [bracket] placeholders in each locale's legal copy.
  4. wrangler.jsonc: name (Worker name) and, for a custom domain, uncomment routes.
  5. public/favicon.svg and src/components/Logo.tsx for the mark.