Marketing Site & Public Signup
This guide describes the Wahy marketing site and the public signup flow —
the front door at the apex domain (wahylabs.com) where a prospective customer
learns about the product, picks a plan, and creates their own bookable website.
It is written for two audiences: a prospective customer who wants to understand
what signing up does, and a new engineer who needs to know where each piece
lives. Every claim below is cited to a real file; live pages are given as full
https://… links.
- Audience note: once a new owner finishes signup they land in the tenant admin CMS — that surface is documented separately in the admin guide.
- Provisioning depth (the owner-role transaction, break-glass CLI, drift guard) lives in the provisioning runbook.
- Host routing (how the apex,
admin., and per-tenant subdomains are resolved) lives in the tenancy doc.
The marketing site
The marketing site is Wahy's own brand — it is not a tenant. It is served
at the apex/www host and rewritten by the proxy into the app/marketing/**
route segment, which carries its own brand chrome (nav + footer) via
app/marketing/layout.tsx. Every page is public and indexable, and every route
is force-dynamic (host-derived, never statically cached).
Pages (each is a real route under app/marketing/):
| Page | Live URL | Source |
|---|---|---|
| Landing | https://wahylabs.com/ | app/marketing/page.tsx |
| Product | https://wahylabs.com/product | app/marketing/product/page.tsx |
| About | https://wahylabs.com/about | app/marketing/about/page.tsx |
| Pricing | https://wahylabs.com/pricing | app/marketing/pricing/page.tsx |
| Sign up | https://wahylabs.com/signup | app/marketing/signup/page.tsx |
| Confirm email | https://wahylabs.com/signup/confirm/{token} |
app/marketing/signup/confirm/[token]/page.tsx |
| Privacy Policy | https://wahylabs.com/privacy | app/marketing/privacy/page.tsx |
| Terms of Service | https://wahylabs.com/terms | app/marketing/terms/page.tsx |
The landing page (app/marketing/page.tsx, rebuilt in Navy repaint Stage 4)
opens with a hero headline that cycles through the trade nouns Wahy serves
(.mkt-cyc, six items, the first repeated as the sixth for a seamless loop)
paired with a real product-screenshot device composition (desktop admin +
phone booking view, .mkt-pair/.mkt-phone), and a HeroAddressClaim
micro-conversion input — the ONLY hero CTA besides "Create your site"; the
retired "Open the live demo" CTA never appears anywhere on the site. Below
that: a navy "Built for" trade-band naming only generic trade nouns (never
invented business names — register R223), a six-jobs feature grid (the
WhatsApp assistant, calendar sync, contact list, unified inbox, gallery, and
availability), the three-step "How it works" strip, a Lasheyeisland
case-study/proof section (a secondary "Visit the live site" link, never a
competing button, to the real tenant subdomain), a pricing teaser that shows
the cheapest monthly price, the configured free-trial length, and flags
domain & email as a paid add-on, and a closing navy CTA band reusing the
same HeroAddressClaim input. HeroAddressClaim
(components/marketing/HeroAddressClaim.tsx) shares its taken-slug
suggestion logic with SignupForm's own Step 3 via the extracted
components/marketing/slug-suggestions.ts, and hands a validated slug to
/signup?slug=… via SignupForm's initialSubdomain prop — never trusted
as pre-verified; Step 3 still re-runs the real checkSlugAvailability check
before proceeding.
The product page (app/marketing/product/page.tsx, Navy repaint Stage 5, #628) replaces the old
pre-navy /features route — a decorative mock calendar page that predated the repaint entirely and
never rendered a real product screenshot. /features now 301-redirects to /product
(proxy.ts's §3b marketing branch, before the generic apex→/marketing rewrite, mirroring R201's
www-fallback redirect) — an old bookmark or an indexed search result is told the page moved, never left
to 404. The page opens with a hero + "Jump to" pill nav (Bookings/WhatsApp/Website/Clients), then a
two-step client's-eye strip — "She finds you" (the real Lasheyeisland site) and "She books herself"
(the real booking flow), the ONLY client-facing screenshots on the page — followed by the four
owner-side feature sections the design handoff assigns to /product, then a roadmap strip styled
deliberately unlike the shipped-feature cards above it (dashed border, no fill, muted titles/body:
#565c52/#6a6f66), then a closing CTA reusing HeroAddressClaim — a single "Create your site" intent,
no reference to a live demo (/demo was never built; see the "Chrome" note below). Real navy screenshots
only, adapted where no real asset existed: the WhatsApp section's phone overlay is dropped (no navy
mobile messages-thread capture exists — R327, see product-screenshots/README.md) and the Clients dark
card ships text-only (no navy mobile contacts/chat capture exists either). The Website section's
full-bleed shot is a new capture, product-studio.png — the FIRST real navy Studio-editor screenshot
(scripts/capture-product-screenshots.ts gained a studio target, desktop-only, capturing the canvas
<iframe>'s real rendered content rather than e2e/visual/editor.spec.ts's masked chrome-only
baseline).
A dedicated 404 page (app/marketing/not-found.tsx) renders inside the same
nav/footer chrome for any unmatched apex path, via a catch-all route
(app/marketing/[...slug]/page.tsx, R330) that calls notFound()
unconditionally — a nested not-found.tsx only catches notFound() thrown
from within its own segment, and before the catch-all existed, all 8 routes
here were concrete, so nothing ever triggered it: a stale link or typo fell
through to Next's bare built-in 404 with no brand at all, in production.
The About page (app/marketing/about/page.tsx, Navy repaint Stage 6) states
the platform's origin and three concrete commitments (data export, a grace
period rather than instant deletion on a missed payment, and a flat
subscription with no cut of booking revenue) — it deliberately ships
without a named team/bio section. See that file's header comment: the
design brief calls for LinkedIn-sourced bios, which requires pulling real
biographical content this build did not source; shipping placeholder names,
roles or photos would repeat register row R223 (invented specifics about a
real person/business on a live page).
Design system (navy repaint, Stage 3 foundations)
The marketing site's chrome, colour tokens, and shared motion/shape
primitives were repainted from the retired crimson/Material-3 palette to
Ink navy (#1c3557), per the design handoff bundle referenced in
CLAUDE.md's "Navy repaint" note. This section documents the shared
foundations; Stage 4 (landing) and Stage 5 (/product) are the page rebuilds
that actually consume them for their own structure (see "The landing page"
and "The product page" above) — /pricing, /terms, and /privacy are
still only restyled by what the shared tokens/primitives changed underneath
them, unchanged in their own structure.
- Tokens (
app/globals.css) — every--color-mkt-*custom property is repainted in place: the M3-shaped names (mkt-primary,mkt-tertiary, …) are unchanged, only their hex values move, so every existingbg-mkt-*/text-mkt-*/border-mkt-*class acrossapp/marketing/**,components/marketing/**, andcomponents/ui/PhoneInput.tsx(thevariant="marketing"phone field, shared with the tenant admin) repaints automatically. Values that match a Stage 0--color-navy-*/--color-warm-*ramp step dereference it viavar(...)rather than repeating the hex. A handful of genuinely new roles with no M3 slot (--color-mkt-band-navy,--color-mkt-deep-navy,--color-mkt-gold,--color-mkt-gold-light,--color-mkt-success-on-navy) were added additively. - Shared primitives (
components/marketing/ui.ts) — button/card/shot/ field class strings. Radius moved fromrounded-fullto Tailwind's 8pxrounded-lgstep everywhere except status chips and pill-shaped tabs (unchanged, per the handoff's shape rule). WaveSeam(components/marketing/WaveSeam.tsx) — the ONLY three cream↔navy transition curves the site is allowed to use, selected via avariantprop ("cream-to-navy" | "navy-to-cream" | "navy-to-footer"). The footer (below) is reached through thenavy-to-footervariant (R323 —cream-to-navyfills navy-600, one shade lighter than the navy-900 footer band, and left a hard edge against it).GrainOverlay(components/marketing/GrainOverlay.tsx) — a fixed, non-interactive paper-grain texture rendered once byapp/marketing/layout.tsx, above the sticky nav (z-index: 60). Any future marketing modal/dropdown must render above that.- Motion (
app/globals.css,mkt--prefixed classes/keyframes) — scroll-reveal (.mkt-rv), device-mockup settle (.mkt-lift/.mkt-lift-now), the phone-float/parallax pair (.mkt-pair/.mkt-phone), the sticky-nav scroll shadow (.mkt-navlift, wrapped in@supports (animation-timeline: scroll())— without that guard, a browser lacking scroll-timeline support renders the nav permanently shadowed, since the animation has no explicit duration and resolves to itstostate instantly), the hero headline cycle (.mkt-cyc), and the "Built for" logo marquee (.mkt-lane/.mkt-lanemask). All of it lives inside@media (prefers-reduced-motion: no-preference). - Focus rings — every interactive element under
app/marketing/**(scoped via themkt-scopeclass onMarketingLayout's root) gets a3px rgba(28,53,87,.35)ring on:focus-visible. Inside a navy-background section (marked with an.on-navyclass, e.g. the footer), the ring swaps torgba(242,245,249,.6)— the existing "text on navy" token at a visibility alpha — since the default ring is navy-on-navy and effectively invisible there. - Chrome (
components/marketing/MarketingShell.tsx,components/marketing/MarketingMobileCta.tsx) — desktop nav is Product (→/product, Stage 5) / Customers (→/#proof, the homepage's case-study section) / Pricing, one CTA intent site-wide ("Create your site"), plus "Log in". The footer is a deep-navy band (--color-mkt-deep-navy) with Product/Pricing/Docs (absolute, host-derived link to the separatedocs.<apex>subdomain)/ About/Contact/Terms/Privacy — no dead links (a "Status" link was considered and dropped because no page exists to link it to; "About & team" was dropped by Stage 3 for the same reason and added back by Stage 6 once/aboutshipped). Mobile keeps the house "bottom bar, never a hamburger" rule: the same three destinations as tabs plus one filled CTA segment, hidden on/signupso it never competes with the wizard's own submit button for the same thumb-zone space.
Pricing
The pricing page (app/marketing/pricing/page.tsx) reads from two
independent catalogs and renders them honestly — it never invents tiers:
- Package tiers —
getPackagePrices()inlib/services/package-prices.tsreturns every non-archived package's current version with its module composition and its KES month/year prices. These are rendered by the clientcomponents/marketing/PricingTiers.tsx(which owns the Monthly/Yearly toggle). - À-la-carte module line —
getMarketingPrices()inlib/services/plan-prices.tsreads the separate per-moduleplan_pricescatalog and is shown as a secondary "À la carte module pricing" line. The two catalogs are additive and independent by design.
Data reality (documented, not a bug): as of today only the migration-seeded
Starter package exists (seeded in drizzle/migrations/0013_stormy_lorna_dane.sql
and scripts/seed-auth.ts; Website + Bookings + Journal). So getPackagePrices()
legitimately returns a single tier, and the page renders whatever the catalog
returns with no padding. More plans are a future addition.
Comparison table + add-ons + FAQs (Navy repaint Stage 6):
components/marketing/PricingComparison.tsx renders a feature-by-tier
table (desktop) / per-tier <details> disclosure (mobile, never a
horizontal scroller of the desktop table) built from the SAME tiers array
— one row per real billable module in lib/modules/registry.ts, plus a
row for "Your own domain & email" that is deliberately not module-gated (it
is a paid add-on on every plan, not an entitlement). Every ✓/— glyph
carries an aria-label ("Included in {tier}" / "Not included in {tier}").
components/marketing/PricingFaqs.tsx renders six keyboard-native
<details> FAQs (no client JS, no hand-rolled accordion ARIA) — two
answers ("Can I cancel anytime?", "Can I run my site in Kiswahili?") were
checked against the actual code before shipping rather than written to
sound plausible (see that file's header comment: no self-serve dashboard
cancel action exists today, and sw locale copy is still a scaffold of
en per CLAUDE.md's i18n section — both answers state the true, narrower
claim). The "domain & email is a paid add-on" phrasing is kept consistent
across all four of the plan's required places as of Stage 5: the add-ons
block, the comparison table, the homepage teaser (app/marketing/page.tsx),
and /product's Website-section tick list
(app/marketing/product/page.tsx's WEBSITE_TICKS).
The signup flow
Signup is the app's highest-risk public surface: it is unauthenticated, so anyone on the internet can reach it, and a successful run creates a real tenant. The flow is deliberately staged so that no tenant is ever created until a human has clicked a link in an email sent to the address they entered.
What the user does
- On https://wahylabs.com/signup they enter business details, a desired
subdomain (their address, e.g.
myshop.wahylabs.com), an email, a business type/vertical, and a theme. The form iscomponents/marketing/SignupForm.tsx. - Submitting calls the
signup()Server Action inlib/actions/signup.ts(this is a Server Action, not an API route). If every gate passes, it sends a verification email and shows a "check your email" state — nothing is provisioned yet. - The email links to the confirm page,
app/marketing/signup/confirm/[token]/page.tsx. Opening it is read-only. - Clicking "Confirm & create my site" POSTs to
app/api/signup/verify/[token]/route.ts— this is the only step that actually creates the tenant. - On success the user is redirected to their new site's prefilled admin
login (
/admin/login?email=…&welcome=1) and continues in the admin CMS.
Safeguards, in order
signup() runs its gates cheapest/most-decisive first (the order is a security
requirement, documented in lib/actions/signup.ts):
- Signup kill-switch —
getPlatformSettingsStrict()(fromlib/platform-settings/index.ts). This read is fail-closed: a transient DB/cache error is treated as "signups disabled," never as enabled. IfsignupEnabledis off, the request stops here. - IP rate limit —
limitFailClosed()fromlib/infrastructure/ratelimit/fail-closed.ts(3 attempts per hour per IP). Unlike most rate-limited surfaces (which fail open), this tenant-minting surface fails closed. - Turnstile CAPTCHA —
verifyTurnstile()inlib/infrastructure/turnstile/index.ts. This is env-gated: withTURNSTILE_SECRET_KEYunset (dev) it is skipped, but once configured it fails closed — the one infrastructure concern that inverts the usual fail-open rule. - Slug validation —
validateSlug()inlib/provisioning/slug.tsnormalizes and shape-checks the subdomain (3–63 chars, lowercase alphanumerics with single internal hyphens) and rejects reserved subdomains (admin,app,api,www,staging, …). A friendly, non-authoritative "is this slug already a live tenant?" pre-check follows. - Verification ticket —
createPendingSignup()inlib/pending-signups/service.tsmints a single-use, 24-hourpending_signupsrow and the raw token; the verification email is then enqueued (not best-effort — a failed enqueue fails the whole signup, since the pending row is useless without its link).
Email-verify BEFORE provisioning
The key anti-abuse design: the tenant is created only after the emailed link
is confirmed, not on the first request. signup() stores the submitted
details in a pending_signups row and emails a tokenized confirm link. Because
provisioning is gated behind clicking that link, an unverified email address can
never squat a subdomain or receive a live site.
Two hardening details in app/api/signup/verify/[token]/route.ts:
- GET is safe/idempotent. The emailed link points at the confirm page,
which only peeks at the token (via
peekPendingSignup()) — it never consumes it. An email client's link-prefetcher or a security scanner (which issue GET, never POST) therefore cannot burn the single-use token before the human clicks. - POST re-checks the kill-switch (
getPlatformSettingsStrict(), fail-closed) before consuming the token. A token minted while signups were open must not still provision a tenant if signups were disabled during its 24-hour lifetime — and because the check runs before consume, a paused hit does not burn the token (the same link works once signups reopen).
The POST handler then runs two separate owner-role steps:
consumePendingSignup(token) (single-use validate + consume), then
provisionTenant(...) (lib/provisioning/provision.ts). Failures map to
generic, non-oracle error messages — a SLUG_TAKEN collision gets its own
friendly message; everything else is generic.
What provisioning creates
provisionTenant() (lib/provisioning/provision.ts) builds a usable tenant in
one atomic owner-role transaction:
- a
tenantsrow withstatus = 'trial'andtrial_ends_at = now() + trialLengthDays(the trial length comes from platform settings); - the owner user + owner membership;
- starter entitlements from the chosen (or default Starter) package,
plus a
tenant_package_assignmentsrow pinning that package version; - a minimal
site_settingsrow (business name, theme, business type, taxonomy); - starter content (sample services / gallery / FAQs / an optional post) from the business-type preset, so the new site launches non-empty;
- an atomic
tenant.provisionedaudit event.
After the transaction commits, the verify route sends a best-effort welcome
email (enqueued in the POST branch of
app/api/signup/verify/[token]/route.ts, not in signup()) linking to the
tenant's prefilled admin login, then redirects the browser there.
For the full owner-role rationale, the break-glass CLI path, and the drift guard, see the provisioning runbook.
Legal pages & error handling (Navy repaint Stage 6)
/terms and /privacy share one presentational shell,
components/marketing/LegalPageLayout.tsx — a sticky, numbered table of
contents (desktop only) beside a measured prose column, deliberately with
no WaveSeam, grain-heavy motion, marquee, or .pair composition: the
design brief's signature moves don't belong on a page whose whole job is to
be read carefully. Prose colour/heading/link roles are overridden onto the
--color-mkt-* tokens explicitly (prose-headings:, prose-p:, prose-a:,
…) rather than Tailwind Typography's prose-neutral variant, which carries
its own gray colour scale unrelated to this surface's tokens.
app/marketing/error.tsx renders through the shared, brand-agnostic
components/errors/ErrorLayout.tsx (also used by the tenant (site), admin,
and control-plane error boundaries) with art="lost" — switched from
art="knotted" because knotted carries a small crimson accent
(ErrorLayout's own header comment: "the other four carry the Wahy accent"),
a leftover of the retired crimson brand and off-palette on this now-navy
surface. lost is documented as deliberately crimson-free specifically so it
can render correctly on any palette; it is already reused by the tenant
(site) error boundary for the same reason.
After signup
The new tenant starts on a trial and the owner works in the admin CMS. At a high level the lifecycle is:
- trial — the default state at provisioning, active until
trial_ends_at. - active — the tenant becomes active once a subscription payment succeeds (Wahy bills via M-Pesa; billing is out of scope for this guide).
- past_due — a trial that reaches its end without converting, or a renewal
charge that fails, moves to
past_due(a grace window follows). - suspended — a
past_duetenant that exhausts its grace window is suspended; a suspended tenant's public site stops serving.
The trial and billing lifecycle is driven by scheduled worker sweeps; those and the payment engine are documented alongside the billing code, outside this customer-facing guide.