# Onboarding Wizard — Design Spec

**Date:** 2026-06-12
**Status:** Approved (design) — ready for implementation plan
**Sub-project:** 2 of 4 (i18n ✅ → **onboarding wizard** → Stripe billing → agency domain whitelisting)

## Goal

A post-signup wizard at `/onboarding` that runs once per user (gated by `user.onboardingCompletedAt`), walking them through **Langue → Forfait → Équipe** before they reach the app. It is born localized (reuses the i18n foundation) and reuses existing building blocks (`LanguageSelector`, Better Auth `inviteMember`). The paid-plan / credit-card path stays dormant until sub-project 3 (Stripe); the agency domain-whitelisting stays in sub-project 4.

## Decisions (locked)

- **Route:** `/onboarding`, a standalone authenticated route **outside** the `(app)` route group — full-screen, no sidebar.
- **Gating flag:** `user.onboardingCompletedAt` (nullable timestamp). The `(app)/layout.tsx` (already loads the session) redirects any user with `onboardingCompletedAt == null` to `/onboarding`. `/onboarding` redirects users who already completed it to `/keywords/reservoir`.
- **Steps:** `Langue → Forfait → Équipe → (complete)`. Client stepper; each step persists its own effect immediately; a final `completeOnboarding()` action sets the flag.
- **Forfait:** free is the only selectable tier now (selecting it is a no-op write-wise); paid tiers render disabled + "Bientôt". A dormant `Paiement` step slot exists for paid plans but is inactive. **No plan/billing schema added** — sub-project 3 owns it.
- **Équipe:** invite 0–N teammates by email via `authClient.organization.inviteMember({ email, role })` (role default `"staff"`, same as Settings→Team). Skippable.
- **Data added:** only `user.onboardingCompletedAt`.

## Out of scope (explicit)

- Stripe, paid-plan selection, credit-card capture, the actual `Paiement` step behavior (sub-project 3).
- Agency domain whitelisting / auto-join (sub-project 4).
- A plan/subscription/tier data model.
- Migrating other surfaces' strings (only the `Onboarding` namespace is filled here).

## Architecture

### Gating
**Avoid the cookieCache trap:** Better Auth's 5-min `session.cookieCache` means the session's `onboardingCompletedAt` can be stale right after completion → a naive gate would bounce the just-finished user back to `/onboarding` (redirect loop). So the flag is read **fresh from the DB** (one cheap PK lookup), not from the cached session.

`src/lib/onboarding/needs-onboarding.ts` exports two things:
- `needsOnboarding(value: Date | null | undefined): boolean` — pure: `return value == null`. Unit-tested in isolation.
- `hasPendingOnboarding(userId: string): Promise<boolean>` — `db.select({ c: user.onboardingCompletedAt }).from(user).where(eq(user.id, userId)).limit(1)` then `needsOnboarding(row?.c)`. Always fresh, cookieCache-proof, server-only.

`src/app/(app)/layout.tsx` (modify): after it resolves `session`, if `session?.user?.id && (await hasPendingOnboarding(session.user.id))` → `redirect("/onboarding")` (from `next/navigation`). Place the check before rendering the shell. (Identity comes from the cached session — fine; only the flag is read fresh.)

`src/app/onboarding/page.tsx` (server): resolve session; if no session → `redirect("/login")`; if `!(await hasPendingOnboarding(user.id))` → `redirect("/keywords/reservoir")`; else render `<OnboardingWizard initialLocale={...} orgName={...} />`.

`src/app/onboarding/layout.tsx` (new): minimal full-screen shell (centered card, themed, no sidebar). Wraps with whatever providers the wizard needs (it is under the root layout, so `NextIntlClientProvider` + theme already apply).

`src/components/signup-form.tsx` (modify): `AFTER_SIGNUP = "/onboarding"`. (The `(app)` gate would catch them anyway, but pointing signup directly there avoids a double redirect.)

### Wizard component
`src/components/onboarding/onboarding-wizard.tsx` (client):
- Holds `const [step, setStep] = useState<"language" | "plan" | "team">("language")`.
- Renders a progress indicator (3 steps) + the active step + Back/Continue controls.
- Step components in `src/components/onboarding/steps/`:
  - `language-step.tsx` — wraps `<LanguageSelector>` + heading/copy from the `Onboarding` namespace. "Continuer".
  - `plan-step.tsx` — plan cards. `Gratuit` selectable (advances on select/Continue); paid cards `disabled` with a "Bientôt" badge. No write.
  - `team-step.tsx` — a small repeatable email+role form; each "Inviter" calls `authClient.organization.inviteMember`. Shows invited emails. "Passer" (skip) and "Terminer" both proceed to completion.
- On finish: call `completeOnboarding()` then `window.location.href = "/keywords/reservoir"` (full nav so the server re-reads the now-set flag).

### Server action
`src/lib/onboarding/actions.ts` (`"use server"`):
```ts
export async function completeOnboarding(): Promise<void> {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session?.user?.id) throw new Error("Not authenticated");
  await db.update(user).set({ onboardingCompletedAt: new Date() }).where(eq(user.id, session.user.id));
  revalidatePath("/", "layout");
}
```
(`onboardingCompletedAt` is a fresh `Date`; the `additionalField` must allow it to be read back in the session — `input: false`, `returned: true`.)

### Data model
`src/lib/db/auth-schema.ts` `user`: add `onboardingCompletedAt: timestamp("onboarding_completed_at")` (nullable).
`src/lib/auth.ts` `user.additionalFields`: add `onboardingCompletedAt: { type: "date", required: false, input: false }` (merge with the existing `locale` additionalField).
Migration `0030` (`ALTER TABLE "user" ADD COLUMN "onboarding_completed_at" timestamp;`).

**⚠️ Journal `when`:** current max is `1781300000000` (0029). After `db:generate`, set `0030`'s `when` to **`1781310000000`** in `drizzle/meta/_journal.json`. Run `db:migrate` + `db:migrate:test`. Never `db:push`.

### i18n
All wizard copy lives in the `Onboarding` namespace of `messages/{en,fr,es,de}.json` (currently `{}`). en = source, fr = French, es/de translated (informal register). The parity test (`src/i18n/catalogs.test.ts`) currently EXCLUDES `Onboarding` from the parity check (`!k.startsWith("Onboarding")`) — **update it to INCLUDE `Onboarding`** now that the namespace is populated, so the four catalogs stay in lockstep.

## Existing pieces reused
- `LanguageSelector` (`src/components/i18n/language-selector.tsx`).
- `authClient.organization.inviteMember({ email, role })` (as used in `src/components/team-management.tsx:568`), roles `"staff" | "admin"`, default `"staff"`.
- shadcn `Card`, `Button`, `Badge`, `Input`, `Select`, `Field` primitives.

## Testing (gate: `bun run test:all`)
1. **`needsOnboarding`** (pure unit): `needsOnboarding(null)` → true; `needsOnboarding(new Date())` → false.
2. **`completeOnboarding`** (test DB, `assertTestDb()` guard): creates/loads a user, calls it, asserts `onboardingCompletedAt` is non-null in the DB; throws when unauthenticated.
3. **Catalog parity**: `Onboarding` namespace now included; en/fr/es/de parity green.

## File structure
```
src/app/onboarding/page.tsx                         (new) session guard + wizard
src/app/onboarding/layout.tsx                        (new) full-screen shell
src/components/onboarding/onboarding-wizard.tsx       (new) client stepper
src/components/onboarding/steps/language-step.tsx     (new)
src/components/onboarding/steps/plan-step.tsx         (new)
src/components/onboarding/steps/team-step.tsx         (new)
src/lib/onboarding/actions.ts                         (new) completeOnboarding
src/lib/onboarding/needs-onboarding.ts                (new) pure helper + hasPendingOnboarding (fresh DB read)
src/lib/onboarding/needs-onboarding.test.ts           (new)
src/lib/onboarding/actions.test.ts                    (new)
messages/{en,fr,es,de}.json                           (modify) fill Onboarding
src/i18n/catalogs.test.ts                             (modify) include Onboarding in parity
src/lib/db/auth-schema.ts, src/lib/auth.ts            (modify) + drizzle/0030_*
src/app/(app)/layout.tsx                              (modify) onboarding gate
src/components/signup-form.tsx                        (modify) AFTER_SIGNUP
```

## Risks / notes
- **Gate must not loop:** `/onboarding` is outside `(app)`, so its layout does NOT run the gate — no redirect loop. Verify `/onboarding` renders for an incomplete user.
- **Session freshness after completion:** solved by design — the gate and the `/onboarding` guard both read the flag fresh from the DB (`hasPendingOnboarding`), never from the cached session, so a just-completed user is not bounced. Implementer must still verify live (d3k) that finishing the wizard lands in the app and does not loop.
- **org context for invites:** the user's personal org is active post-signup (`activeOrganizationId` set), so `inviteMember` targets it. Confirm an active org exists when the team step runs.
