Onboarding — Behavior
State
OnboardJS owns the state via its OnboardingProvider. The shape (SetupWizardContext) is:
type SetupWizardContext = {
flowData: {
orgName: string;
orgType: 'physical' | 'online' | 'hybrid';
legalConsentsAccepted: boolean;
};
}Persisted to localStorage under key fitkit:setup-wizard-v2 with 7-day TTL. The current step is OnboardJS’s internal state and is restored on page refresh.
Step graph
| Step id | Component | nextStep | Notes |
|---|---|---|---|
org | OrgStep | (default = next: legal) | Required fields: orgName, orgType. |
legal | LegalStep | (default = next: done) | Required: legalConsentsAccepted=true (signaled by onAccepted callback from LegalAcceptanceForm). |
done | DoneStep | null (terminal) | Triggers onFlowComplete → handleFinish. |
No conditional/skippable branches in the current 3-step config.
Invariants
- Wizard is unreachable with existing memberships — server-side
redirect()in the page component. - Wizard 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.
- handleFinish is the atomic boundary — until it runs successfully, no org row exists. If
POST /organizationssucceeds but legal-consents oronboarding-completefails, the org persists (orphaned-ish — legal consents missing, no welcome email). - Legal acceptance writes one consent row per document type —
terms_of_use,privacy_policy,fitness_waiver.
Golden path
| Step | Action | Side effects |
|---|---|---|
| 1 | User opens /{lang}/onboarding. | Server fetches /users/me; if memberships exist, redirects. Otherwise renders OnboardingContent. |
| 2 | User types org name “Acme Fitness”, selects physical. | updateContext({ flowData: {…orgName:'Acme Fitness', orgType:'physical'}}). PostHog onboarding_step_completed event fires (TODO: verify exact event name from PostHogPlugin). |
| 3 | User clicks next → legal step. Reads + accepts terms. | LegalAcceptanceForm.onAccepted → updateContext({ flowData: {…legalConsentsAccepted:true}}). |
| 4 | User clicks next → done step. Sees summary copy with orgName. | No automatic transition; user clicks “Finish”. |
| 5 | onFlowComplete(context) → handleFinish. | Sequence: POST /organizations → (if accepted) POST /legal/consents → POST /organizations/:id/onboarding-complete → queryClient.invalidateQueries(['/users/me']) → router.push('/{lang}/dashboard/overview'). |
| 6 | Dashboard loads. | RoleRouter sees owner membership and renders staff view. Guided tour (FIT-93 Phase B) may launch overlay. |
Edge cases & error states
| Scenario | Behavior |
|---|---|
POST /organizations fails | setError(err); wizard stays on done step; user can retry. |
| Legal consents POST fails | Error swallowed by the outer try/catch and surfaced via setError; org exists without consents — user would later hit /{lang}/accept-terms gate. (TODO: verify the actual UX — currently both writes are in the same try/catch.) |
onboarding-complete fails (e.g. welcome email) | Backend returns 200 with emailSent:false; wizard sees success. |
| User backs out mid-wizard | LocalStorage retains state for 7 days. Returning resumes on the saved step. |
| User signs up with an invitation in flight | They never see onboarding — the acceptPendingInvitations flow on user.created activates the membership first, and the server-side guard redirects. |
| User clears cookies | Clerk session lost. Sign-in required. LocalStorage fitkit:setup-wizard-v2 may still exist but is unused once the user has memberships. |
Side effects
| Phase | Effects |
|---|---|
org step | Updates OnboardJS context; PostHog events. |
legal step | LegalAcceptanceForm reads the current document set via API (not in this module’s scope). Acceptance signal is local until handleFinish. |
done step handleFinish | DB: org row, owner membership, legal consents (× 3), optional class type + invites + welcome email via onboarding-complete. Clerk: organization created. R2: no logo yet. |
Permissions
| Surface | Auth |
|---|---|
/{lang}/onboarding | Bearer (Clerk session); enforced by middleware + (protected) group. |
POST /organizations | Bearer; caller becomes the owner. |
POST /legal/consents | Bearer. |
POST /:orgId/onboarding-complete | Bearer + owner role only (server-side guard inside OrganizationsService.completeOnboarding). |