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 existing bg-mkt-*/text-mkt-*/border-mkt-* class across app/marketing/**, components/marketing/**, and components/ui/PhoneInput.tsx (the variant="marketing" phone field, shared with the tenant admin) repaints automatically. Values that match a Stage 0 --color-navy-*/--color-warm-* ramp step dereference it via var(...) 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 from rounded-full to Tailwind's 8px rounded-lg step 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 a variant prop ("cream-to-navy" | "navy-to-cream" | "navy-to-footer"). The footer (below) is reached through the navy-to-footer variant (R323 — cream-to-navy fills 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 by app/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 its to state 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 the mkt-scope class on MarketingLayout's root) gets a 3px rgba(28,53,87,.35) ring on :focus-visible. Inside a navy-background section (marked with an .on-navy class, e.g. the footer), the ring swaps to rgba(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 separate docs.<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 /about shipped). Mobile keeps the house "bottom bar, never a hamburger" rule: the same three destinations as tabs plus one filled CTA segment, hidden on /signup so 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:

  1. Package tiersgetPackagePrices() in lib/services/package-prices.ts returns every non-archived package's current version with its module composition and its KES month/year prices. These are rendered by the client components/marketing/PricingTiers.tsx (which owns the Monthly/Yearly toggle).
  2. À-la-carte module linegetMarketingPrices() in lib/services/plan-prices.ts reads the separate per-module plan_prices catalog 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

  1. 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 is components/marketing/SignupForm.tsx.
  2. Submitting calls the signup() Server Action in lib/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.
  3. The email links to the confirm page, app/marketing/signup/confirm/[token]/page.tsx. Opening it is read-only.
  4. Clicking "Confirm & create my site" POSTs to app/api/signup/verify/[token]/route.ts — this is the only step that actually creates the tenant.
  5. 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):

  1. Signup kill-switchgetPlatformSettingsStrict() (from lib/platform-settings/index.ts). This read is fail-closed: a transient DB/cache error is treated as "signups disabled," never as enabled. If signupEnabled is off, the request stops here.
  2. IP rate limitlimitFailClosed() from lib/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.
  3. Turnstile CAPTCHAverifyTurnstile() in lib/infrastructure/turnstile/index.ts. This is env-gated: with TURNSTILE_SECRET_KEY unset (dev) it is skipped, but once configured it fails closed — the one infrastructure concern that inverts the usual fail-open rule.
  4. Slug validationvalidateSlug() in lib/provisioning/slug.ts normalizes 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.
  5. Verification ticketcreatePendingSignup() in lib/pending-signups/service.ts mints a single-use, 24-hour pending_signups row 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 tenants row with status = 'trial' and trial_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_assignments row pinning that package version;
  • a minimal site_settings row (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.provisioned audit 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.


/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_due tenant 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.