Skip to content

B2C Trainer Signup Hold (Painted-Door Waitlist)

The B2C trainer self-signup is held behind a coming-soon waitlist so B2C can focus on the learner experience (parent is treated as the same learner experience and stays live). Trainer stays visible everywhere personas are shown, but selecting it opens a "coming soon, get early access" panel that captures an email instead of completing signup. The captured emails are the demand signal for deciding when to build the trainer product.

How it works

A persona in HELD_PERSONAS is visible in every persona surface but cannot complete signup; selecting it routes to the waitlist panel. The switch is one const, HELD_PERSONAS = ['trainer'] in packages/ui/src/auth-contract.ts, consumed by both apps/site and apps/web and mirrored as a held-intent rejection in the Python backend. Removing trainer from the tuple (front + back) flips it back to a normal signup persona with no other code change.

Rules:

  • The const lives in the shared contract because signup spans a public marketing app (apps/site, pre-auth) and the product app (apps/web). FeatureGate is auth- and org-scoped, so it can't gate a public or pre-auth page; a typed const both apps import is the type-safe switch with no new infra. No runtime/PostHog flag.
  • Defense in depth: signup endpoints reject any signup_intent that canonicalizes into the held set (422), so the hold isn't just a UI hide.
  • This only captures demand. It does not notify the waitlist when trainer launches (separate task), and builds no trainer product.

Flows

Happy path (Priya hits the trainer hold):

  1. Priya opens the Individuals landing page (kwilo.ai), toggles to Trainer (still shown).
  2. She clicks the trainer CTA. The PersonaWaitlist panel appears: "Trainer tools, coming soon. We're building Kwilo for trainers. Want first access?"
  3. She enters her email, clicks Notify me. Email + persona='trainer' posts to /waitlist.
  4. Panel shows the success state. A secondary link offers "Meanwhile, explore as a learner →".

Edge cases:

  • Deep link app.kwilo.ai/signup?as=trainer (old marketing link / bookmark): the signup form is replaced by the same waitlist panel.
  • Legacy ?as=teacher normalizes to trainer via canonicalizeSignupIntent, then hits the hold.
  • Duplicate email for the same persona: idempotent, success state shown, no error leak.
  • Crafted request posting signup_intent='trainer' to a signup endpoint: backend rejects (422).

API endpoints

Method Endpoint Purpose
POST /waitlist Capture { email, persona, source?, utm_* } for a held persona
POST /auth/register-individual Now rejects held signup_intent
POST /auth/phone-otp/verify Now rejects held signup_intent

Where it lives

  • Surfaces: Individuals landing trainer CTA (apps/site), /signup?as=trainer (apps/web).
  • Contract: packages/ui/src/auth-contract.ts (HELD_PERSONAS, isHeldPersona), packages/ui/src/PersonaWaitlist/.
  • Backend: apps/backend/src/api/v1/waitlist.py, apps/backend/src/models/persona_waitlist.py, held-intent mirror in signup_intent.py.