Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesOnboardingOnboarding — Behavior

Onboarding — Behavior

State

OnboardJS owns the state via its OnboardingProvider. The shape (SetupWizardContext) is:

type SetupWizardContext = { flowData: { orgName: string; deliveryModes: Array<'schedule' | 'coaching'>; programNames: Partial<Record<'schedule' | 'coaching', string>>; legalConsentsAccepted: boolean; }; }

Persisted to localStorage under key taikan:setup-sheet-v5 with 7-day TTL. The engine has a single step (SETUP_SHEET_STEP_ID = 'sheet', a component that renders nothing), so there is no “current step” to restore: a refresh restores the answers and the sheet recomputes everything else from them.

legalConsentsAccepted is the one field that is never trusted from persistence. LegalStep keeps its own accepted state, starts it at false, and mirrors it into flowData. After a reload the checkbox renders unticked, so a restored true is overwritten with false and consent is never recorded for a box nobody can see ticked.

Sections

All three sections render at once, in this order, inside one <form>.

Section idComponentSettled whenNotes
orgOrgSteporgName.trim() is non-empty AND ownerFirstName / ownerLastName are each at least 2 characters (the PATCH /users/me rule)The business name, then the owner’s first and last name. The business-name input autofocuses. The owner’s name is pre-filled from the account once the engine has started and any saved sheet has loaded (a Google sign-up carries one; email sign-ups do not), and never over a typed or restored value. Submit saves it with PATCH /users/me before POST /organizations, which also syncs it to Clerk; a retry after a partial failure does not re-send it. Organization type is not asked: the request omits type, so the column default (physical) applies, and the owner can change it in Settings → General. The only thing the value changes anywhere is that online switches off compliance forms and the default location.
trainTrainStepdeliveryModes.length > 0 AND every ticked mode has a non-empty programNames[mode]No default selection. Unticking a tile removes the mode; at least one must stay ticked for the section to settle.
legalLegalSteplegalConsentsAccepted=trueMirrors LegalAcceptanceForm’s onAcceptedChange. Unticking un-accepts.

No conditional/skippable branches. Every org sees all three sections and none can be skipped.

Missing items and marker states

getMissingSetupItems(flowData) (apps/web/src/lib/onboarding/setup-sheet-state.ts) returns what is still needed, in reading order: name, then train or programName, then legal. programName is separate from train so the footer can ask for “a name for your first program” instead of repeating “how you train” to someone who has already ticked a tile. A missing context counts as an untouched sheet.

Each section’s rail marker (StepMarker) derives from that list:

Marker stateMeaning
settledNo missing item maps to this section. The ring closes into a check; the heading gains a screen-reader-only “(done)”.
currentThe first section that is not settled.
upcomingAny later unsettled section.

The state is also exposed as data-state on setup-section-{org,train,legal}. Sections are not locked by their state: an upcoming section is fully editable.

  • The headline (setup-sheet-headline) interpolates the trimmed gym name, falling back to a generic phrase until one is typed. Long names wrap and clamp at three lines.
  • The sticky footer shows one of two things in the same slot, beside the submit button:
    • the status line (setup-sheet-status, aria-live="polite"): “Still needed:” followed by the missing items, joined with the locale’s own separators and conjunction via Intl.ListFormat. Each item is a button. When nothing is missing it reads as ready.
    • the error (setup-sheet-error, role="alert") after a failed submit. It replaces the status line.
  • Clicking a missing item scrolls its field to the centre (smooth, or instant under prefers-reduced-motion) and focuses it: name → the gym name input; programName → the name input of the first unnamed ticked mode; train and legal → the first button or input inside that section.
  • The submit button (setup-sheet-submit) is aria-disabled and drawn as ink on a quiet surface until nothing is missing; clicking it in that state moves focus to the first missing item instead of submitting. It is natively disabled only while submitting. Its label is “Open {name}” once a name is typed, a generic label before that, and a pending label with a spinner while submitting.
  • Enter in a text field submits the form natively. With something still missing, that moves focus to the first missing item; with nothing missing it creates the gym. Enter on the tiles, radio items and checkbox keeps its native behavior.
  • While submitting, the whole form is a disabled <fieldset> with aria-busy. Pending stays on through the navigation to the dashboard so the sheet cannot be submitted twice in the moment before the dashboard paints.

Invariants

  • Sheet is unreachable with existing memberships — unless an owner grant is pending: server-side redirect() in the page component. /users/me.canCreateOrganization === true (a claimable owner grant, see below) lifts the redirect so a member of gym A can open the sheet to become owner of gym B.
  • An owner grant is required to create an organization: POST /organizations answers 403 OWNER_INVITATION_REQUIRED unless the caller can claim a pending owner_invitations row. One grant creates exactly one organization; the claim, the Clerk org, the org row, the owner membership and the grant’s consumed flip happen in one DB transaction behind a SELECT … FOR UPDATE. Platform admins issue grants (Admin → Owner Invitations; runbook: docs/runbooks/owner-invitations.md).
  • No grant, no sheet: a signed-in user with no memberships and no claimable grant is routed to /{lang}/invitation-required (shows their email, a mailto:[email protected] contact link and sign-out) by the (protected) layout, RoleRouter and the onboarding page guard. Course-viewer paths (/library, /buy) keep their exemption, and an account that owns a course (/users/me ownsCourses) is sent to /{lang}/library instead: its home is the courses it bought. An owner grant takes precedence over both.
  • Sheet is unreachable without auth: (protected) route group enforces Clerk sign-in.
  • OnboardJS persistence is local-only: no server-side resumption; if the user clears localStorage they restart.
  • Nothing is written before submit: until handleSubmit runs, no org row exists. The sections only update OnboardJS context.
  • A retry never duplicates the organization: handleSubmit remembers the created org id and whether consents were recorded (createdOrgIdRef, consentsRecordedRef). A retry after a partial failure skips the calls that already succeeded. The memory is in-component, so it does not survive a page reload.
  • Restored consent is never trusted: see State above.
  • Legal acceptance writes one consent row per document type in OWNER_LEGAL_DOC_TYPES (terms_of_use today).

Golden path

StepActionSide effects
1User opens /{lang}/onboarding.Server fetches /users/me; without canCreateOrganization it redirects (no memberships → /{lang}/invitation-required, memberships → role home). Otherwise renders OnboardingContent → SetupSheet. Submit is disabled; the footer asks for a name, how you train and the terms. org is current, the others upcoming.
2User types “Acme Fitness”.updateContext on each keystroke. Headline and submit button show the name. org settles, train becomes current. PostHog onboarding_step_completed fires with step_id='org'.
3User ticks “Classes on a schedule”.The tile grows its name input. The footer now asks for “a name for your first program” instead of “how you train”.
4User types “CrossFit” into the tile’s name input.train settles (step_id='train' event), legal becomes current.
5User ticks the terms checkbox.LegalAcceptanceForm.onAcceptedChange(true) → legalConsentsAccepted=true. legal settles (step_id='legal' event). Footer reads as ready; submit enables.
6User clicks “Open Acme Fitness” (or presses Enter in a text field).handleSubmit: POST /organizations → (if accepted) POST /legal/consents → POST /organizations/:id/onboarding-complete with {deliveryModes, programNames} → onboarding_flow_completed → remove taikan:setup-sheet-v5 → queryClient.invalidateQueries(['/users/me']) → router.push('/{lang}/dashboard/overview').
7Dashboard loads.RoleRouter sees owner membership and renders staff view. The setup guide promotes the first unfinished step. Training shows exactly the sidebar items the new program(s) call for (Schedule for a schedule program, Cohorts for a coaching program). The optional /dashboard/home landing also renders the setup guide for owner/admin.

Setup guide

SetupGuide (apps/web/src/components/overview/setup-guide.tsx) continues the sheet inside the dashboard. It renders on Overview (under the page guide) and on /dashboard/home for owner/admin. All data comes from useGettingStarted (hooks/use-getting-started.ts).

Steps

Org shapeSteps, in order
Has a schedule programcreateClassType, setupSchedule, connectPayments, createPlan, publishHealthDeclaration*, inviteMember
Coaching-only or no programsetupProgram, createWorkout, connectPayments, createPlan, publishHealthDeclaration*, inviteMember

* Never for online orgs (compliance forms are off there). Not tier-gated. It sits before inviteMember because a form auto-issued on join reaches only people who join after it is published.

Payments come before plans because a paid plan cannot be created without a provider connected.

StepComplete whenLinks to
createClassTypethe org has at least one class typefirst schedule program’s ?tab=class-types (or /dashboard/programs when none exists)
setupSchedulethe org has at least one class typefirst schedule program’s ?tab=templates
setupProgramthe org has at least one program/dashboard/programs
createWorkoutthe org has at least one workout/dashboard/workouts/new
connectPaymentsa terminal can charge: payment config status = 'active' (useBilling().canCharge; false while loading). Every staff role reads the config, so an admin sees the same answer as the owner. A cash-only gym with plans bills, so the step counts and stays open until a terminal is connected/dashboard/settings
createPlanthe org has at least one plan/dashboard/plans
publishHealthDeclarationa published, unarchived health_declaration compliance template exists/dashboard/forms/new?moment=join&typeKey=health_declaration
inviteMemberat least one active membership with role member/dashboard/members

Known limitation: setupSchedule is marked complete by the presence of class types, not by scheduled sessions or templates. It settles at the same moment as createClassType, so the guide never promotes it on its own. onboarding-complete also seeds a default class type for every new schedule program, so an org created through the sheet with “Classes on a schedule” ticked arrives with both steps already settled.

Tier-locked steps

connectPayments and createPlan need the automated_billing platform feature. On a tier without it they are locked: shown below the rail in their own group under the label “The rest needs the Pro plan”, each with a dashed lock marker, a one-line upsell and an always-visible “Upgrade” link, and the row links to /dashboard/settings/billing. Locked steps are excluded from the progress count and are never promoted.

Optional payment steps

While the org bills nobody (hasBilling() false: no payment config with status = 'active' and no plan, active or archived), connectPayments and createPlan are optional: listed below the rail under “Optional, for when you start charging” (setup-guide-optional), each linking to its page, never promoted and excluded from the progress count. A gym that does not charge is not unfinished for not charging. As soon as the org bills — a terminal that can charge or a first plan — both return to the rail; connectPayments is complete only once a terminal can charge, so a gym that sells plans for cash is asked to connect one. A tier without automated_billing keeps them in the locked group instead. hasBilling() lives in apps/web/src/lib/billing.ts; the hook is useBilling() (apps/web/src/hooks/use-billing.ts).

Render states

StateWhat renders
Loading (queries in flight, or the dismissed flag not yet read from localStorage)Nothing. The flag is resolved before first paint so the card does not shift the panels below it.
Dismissed, setup unfinishedA compact “Resume setup · {completed}/{total}” link (resume-getting-started) that brings the guide back.
Dismissed, everything doneNothing.
Every actionable step doneOne line (getting-started-complete): a settled marker, “{org name} is set up”, and a Dismiss link.
OtherwiseThe card (getting-started-checklist).

The card has two halves:

  • The promoted step: exactly one, the first step that is neither locked nor complete. Heading (setup-guide-next), a one-line reason, a button (setup-guide-cta) that links to the step, and the progress text “{completed} of {total} done”.
  • The rail: every step as a link with the same StepMarker the sheet uses (settled for complete, current for the promoted one with aria-current="step", upcoming otherwise). Completed and locked rows carry a screen-reader-only suffix. Other unfinished steps stay reachable here but never compete with the promoted one.

A close button (dismiss-getting-started) hides the guide. The dismissed flag is the localStorage key taikan:getting-started-dismissed; lib/session-reset.ts clears it whenever the signed-in identity changes on the same browser.

Edge cases & error states

ScenarioBehavior
POST /organizations answers 403 OWNER_INVITATION_REQUIRED (grant revoked/expired mid-sheet, or the user never had one)Footer shows the localized onboarding.sheet.errorInvitationRequired copy (“Your invitation to open a gym is no longer valid. Contact Taikan.”). Nothing was created.
Retry after POST /organizations committed but the response was lostThe grant is consumed for this Clerk user; a retry with the same gym name returns the existing org (200). A different name answers 403 OWNER_INVITATION_REQUIRED; on reload the user has a membership and lands on the dashboard.
Double-submit race on the same grantThe second transaction blocks on the row lock, finds it consumed and answers 409 OWNER_INVITATION_ALREADY_USED; the sheet shows its inline error and a reload resolves.
Clerk org creation or the DB insert fails inside the transactionRollback leaves the grant pending; a best-effort deleteOrganization removes any Clerk org already created. The retry succeeds.
POST /organizations failsFooter shows the inline error in place of the status line; the form re-enables with every answer intact; user can retry. Nothing was created.
Legal consents POST failsSame inline error. The org exists and its id is remembered, so a retry skips POST /organizations and resumes at consents. If the user reloads instead, the memory is lost; they now have a membership, so the server guard redirects them to the dashboard, and the legal gate (/{lang}/accept-terms) collects the missing consent. onboarding-complete never ran for that org, so it has no starter program and no welcome email.
onboarding-complete request failsSame inline error. A retry skips both earlier calls and re-posts only onboarding-complete. Program creation is idempotent per delivery mode (a mode that already has a program is skipped).
Welcome email fails inside onboarding-completeBackend returns 200 with emailSent:false; the sheet sees success.
Both tiles ticked, only one namedSubmit stays disabled and the footer asks for “a name for your first program”; clicking it focuses the unnamed tile’s input. There is no fallback default surfaced in the UI (the server-side default only covers a request sent without a name, which the UI never allows).
Terms unticked after being tickedonAcceptedChange(false) → legal reopens, submit disables, the footer asks for the terms again.
Whitespace-only gym nameCounts as missing. The name is trimmed before it is sent.
Enter pressed in a text field while something is missingFocus moves to the first missing item; nothing is submitted.
User leaves mid-sheetLocalStorage retains the answers for 7 days. Returning restores name, type, modes and program names; the terms must be ticked again.
Section settled before a reloadonboarding_step_completed is not re-emitted. Only transitions seen in the current session count.
User signs up with an invitation in flightThey never see onboarding. The acceptPendingInvitations flow on user.created activates the membership first, and the server-side guard redirects.
User clears cookiesClerk session lost. Sign-in required. LocalStorage taikan:setup-sheet-v5 may still exist but is unused once the user has memberships.

Side effects

PhaseEffects
org sectionUpdates OnboardJS context; PostHog onboarding_step_completed when it settles.
train sectionUpdates OnboardJS context only (deliveryModes, programNames). No API calls until handleSubmit. PostHog event when it settles.
legal sectionLegalAcceptanceForm reads the current document set via API (not in this module’s scope). Acceptance is local until handleSubmit. PostHog event when it settles.
handleSubmitDB: org row, owner membership, owner_invitations row → consumed (with organization_id), audit organization.created, legal consents (one per type in OWNER_LEGAL_DOC_TYPES), one programs row per ticked train mode (named from programNames), class type + location + welcome email via onboarding-complete. Clerk: organization created. R2: no logo yet. PostHog: onboarding_flow_completed. LocalStorage: taikan:setup-sheet-v5 removed.
Setup guideRead-only queries (class types, plans, members, payment config, workouts, programs). Writes only the taikan:getting-started-dismissed localStorage key.

Analytics events

All events carry onboarding_variant='v5-sheet'.

EventSourceProperties
onboarding_step_completedcaptureOnboardingEvent in SetupSheet, on a section’s unsettled → settled transitionstep_id: org | train | legal
onboarding_flow_completedcaptureOnboardingEvent in handleSubmit, after the third API call succeedsnone
Engine-level events (onboarding_flow_started, onboarding_step_active, …)the OnboardJS PostHog pluginstep_id is always sheet, the single engine step

Permissions

SurfaceAuth
/{lang}/onboardingBearer (Clerk session); enforced by middleware + (protected) group.
POST /organizationsBearer; caller becomes the owner. Requires a claimable owner grant (OWNER_INVITATION_REQUIRED otherwise).
POST /legal/consentsBearer.
POST /:orgId/onboarding-completeBearer + owner role only (server-side guard inside OrganizationsService.completeOnboarding).
Setup guide on /dashboard/homeRendered for owner and admin only.
Setup guide on OverviewNo role gate of its own; follows whoever can open Overview.