From f07ff1a28cba5ce09530d4c06f2da40252a9c387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Fri, 19 Jun 2026 10:20:19 +0200 Subject: [PATCH] docs: add onboarding refactor implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-06-19-onboarding-refactor.md | 1905 +++++++++++++++++ 1 file changed, 1905 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-19-onboarding-refactor.md diff --git a/docs/superpowers/plans/2026-06-19-onboarding-refactor.md b/docs/superpowers/plans/2026-06-19-onboarding-refactor.md new file mode 100644 index 00000000..cffc5be4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-onboarding-refactor.md @@ -0,0 +1,1905 @@ +# Onboarding Feature Refactor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reorganise `src/features/onboarding/` into typed sub-directories (types/, constants/, schemas/, hooks/) and replace all ad-hoc `useState(loading)` + try/finally patterns with `useMutation`/`useQuery` hooks from TanStack Query. + +**Architecture:** Each hook calls `useOnboarding()` internally — steps pass nothing to hooks, they only call hooks and render JSX. Mutations handle API call + `updateContext`. Steps handle UI side-effects (form.reset, phase transitions) via per-call `onSuccess`. + +**Tech Stack:** Next.js 15 App Router, TanStack Query v5 (`@tanstack/react-query`), `@onboardjs/react`, `next-safe-action`, TypeScript. + +## Global Constraints + +- All hooks live under `src/features/onboarding/hooks/` +- Hooks call `useOnboarding()` themselves — never accept `state`/`updateContext`/`next` as props +- `useMutation` for write operations, `useQuery` for polling/fetch +- `toast.error` in hook's `onError` — steps never duplicate error toasts +- Import types from `@/features/onboarding/types` (after Task 1 migration) +- No new UI/UX changes — refactor only +- No hooks for: `step-login` (auth-specific), `step-preferences` (no server calls), `step-invite-members` (local-only), `step-security` (context-only), `step-db-settings` (local-only), `step-defaults` (too specific) + +--- + +### Task 1: Foundation — types/, constants/, schemas/ + +**Files:** +- Create: `src/features/onboarding/types/index.ts` +- Create: `src/features/onboarding/constants/steps.ts` +- Create: `src/features/onboarding/schemas/account.schema.ts` +- Delete: `src/features/onboarding/onboarding.types.ts` (after updating all imports) +- Modify: all 14 files that import from `onboarding.types` (listed below) + +**Imports to migrate** (all `@/features/onboarding/onboarding.types` → `@/features/onboarding/types`): +``` +src/features/onboarding/onboarding-state.ts +src/features/onboarding/steps/step-preferences.tsx +src/features/onboarding/steps/step-security.tsx +src/features/onboarding/steps/step-login.tsx +src/features/onboarding/steps/step-account-info.tsx +src/features/onboarding/steps/step-agent-waiting.tsx +src/features/onboarding/steps/step-storage.tsx +src/features/onboarding/steps/step-agent-key.tsx +src/features/onboarding/steps/step-agent-create.tsx +src/features/onboarding/steps/step-notifier.tsx +src/features/onboarding/steps/step-project-create.tsx +src/features/onboarding/steps/step-invite-members.tsx +src/features/onboarding/steps/step-db-settings.tsx +src/features/onboarding/steps/step-defaults.tsx +``` + +- [ ] **Step 1: Create types/index.ts** + +Copy the full content of `onboarding.types.ts` verbatim: + +```typescript +// src/features/onboarding/types/index.ts +export type OnboardingSsoProvider = { id: string; label: string }; + +export type OnboardingMeta = { + passkeyEnabled: boolean; + hasExistingUsers: boolean; + ssoProviders: OnboardingSsoProvider[]; + defaultUserMode: boolean; + resumeStepId: string; + emailPasswordEnabled: boolean; +}; + +export type OnboardingAccountData = { + firstName: string; + lastName: string; + email: string; +}; + +export type OnboardingSecurityData = { + method: "passkey" | "two-factor" | "skipped"; +}; + +export type OnboardingPreferencesData = { + theme: "light" | "dark"; + avatarUrl?: string; +}; + +export type OnboardingOrgData = { + id: string; + name: string; +}; + +export type OnboardingMember = { + email: string; + role: "member" | "admin"; +}; + +export type OnboardingChannel = { + id: string; + provider: string; + label: string; + name: string; + config: Record; +}; + +export type OnboardingDefaultsData = { + notifierId?: string; + storageId?: string; +}; + +export type OnboardingAgent = { + id: string; + name: string; + notifierId?: string; + storageId?: string; +}; + +export type OnboardingDatabase = { + id: string; + name: string; + engine: "postgres" | "mysql" | "mongodb"; +}; + +export type OnboardingDbSettings = { + retentionDays: number; + notifierId?: string; + storageId?: string; +}; + +export type OnboardingProjectData = { + id: string; + name: string; + description: string; + databaseIds: string[]; +}; + +export type OnboardingFlowData = { + meta?: OnboardingMeta; + account?: OnboardingAccountData; + security?: OnboardingSecurityData; + preferences?: OnboardingPreferencesData; + org?: OnboardingOrgData; + members?: OnboardingMember[]; + notifiers?: OnboardingChannel[]; + storages?: OnboardingChannel[]; + defaults?: OnboardingDefaultsData; + agents?: OnboardingAgent[]; + databases?: OnboardingDatabase[]; + project?: OnboardingProjectData; + dbSettings?: Record; +}; +``` + +- [ ] **Step 2: Create constants/steps.ts** + +```typescript +// src/features/onboarding/constants/steps.ts +export const STEP_IDS = { + LOGIN: "login", + ACCOUNT_INFO: "account-info", + SECURITY: "security", + PREFERENCES: "preferences", + ORG_CREATE: "org-create", + INVITE_MEMBERS: "invite-members", + NOTIFIER: "notifier", + STORAGE: "storage", + DEFAULTS: "defaults", + AGENT_CREATE: "agent-create", + AGENT_KEY: "agent-key", + AGENT_WAITING: "agent-waiting", + PROJECT_CREATE: "project-create", + DB_SETTINGS: "db-settings", + FINISH: "finish", +} as const; + +export const STEP_ORDER: string[] = [ + STEP_IDS.LOGIN, + STEP_IDS.ACCOUNT_INFO, + STEP_IDS.SECURITY, + STEP_IDS.PREFERENCES, + STEP_IDS.ORG_CREATE, + STEP_IDS.INVITE_MEMBERS, + STEP_IDS.NOTIFIER, + STEP_IDS.STORAGE, + STEP_IDS.DEFAULTS, + STEP_IDS.AGENT_CREATE, + STEP_IDS.AGENT_KEY, + STEP_IDS.AGENT_WAITING, + STEP_IDS.PROJECT_CREATE, + STEP_IDS.DB_SETTINGS, + STEP_IDS.FINISH, +]; +``` + +- [ ] **Step 3: Create schemas/account.schema.ts** + +Extract the two schemas currently inline in `step-account-info.tsx`: + +```typescript +// src/features/onboarding/schemas/account.schema.ts +import { z } from "zod"; + +export const BaseSchema = z.object({ + firstName: z.string().min(1, "First name required"), + lastName: z.string().min(1, "Last name required"), + email: z.string().email("Invalid email"), +}); + +export const WithPasswordSchema = BaseSchema.extend({ + password: z.string().min(8, "Min. 8 characters"), +}); +``` + +- [ ] **Step 4: Update all 14 import paths** + +In each file listed above, replace: +```typescript +from "@/features/onboarding/onboarding.types" +``` +with: +```typescript +from "@/features/onboarding/types" +``` + +Also in `step-account-info.tsx`, replace the inline schema definitions with imports: +```typescript +import { BaseSchema, WithPasswordSchema } from "@/features/onboarding/schemas/account.schema"; +``` +And remove the two `const BaseSchema = ...` and `const WithPasswordSchema = ...` blocks. + +- [ ] **Step 5: Update onboarding-shell.tsx to use constants** + +Replace the hardcoded `STEP_ORDER` array with an import: + +```typescript +// Replace this: +const STEP_ORDER = [ + "login", + "account-info", + ... +]; + +// With: +import { STEP_ORDER } from "@/features/onboarding/constants/steps"; +``` + +- [ ] **Step 6: Delete onboarding.types.ts** + +```bash +rm src/features/onboarding/onboarding.types.ts +``` + +- [ ] **Step 7: Verify TypeScript compiles** + +```bash +npx tsc --noEmit +``` + +Expected: no errors. If errors, fix import paths. + +- [ ] **Step 8: Commit** + +```bash +git add src/features/onboarding/types/index.ts \ + src/features/onboarding/constants/steps.ts \ + src/features/onboarding/schemas/account.schema.ts \ + src/features/onboarding/onboarding-shell.tsx \ + src/features/onboarding/steps/ \ + src/features/onboarding/onboarding-state.ts +git commit -m "refactor(onboarding): move types, constants, schemas to sub-directories" +``` + +--- + +### Task 2: Agent hooks — useCreateAgent + useDeleteAgent + +**Files:** +- Create: `src/features/onboarding/hooks/use-create-agent.ts` +- Create: `src/features/onboarding/hooks/use-delete-agent.ts` +- Modify: `src/features/onboarding/steps/step-agent-create.tsx` + +**Interfaces:** +- Consumes: `createAgentAction` from `@/features/agents/agents.action`, `deleteAgentAction` from `@/features/agents/agent-delete.action` +- Produces: `useCreateAgent()` → `UseMutationResult`, `useDeleteAgent()` → `UseMutationResult` + +- [ ] **Step 1: Create use-create-agent.ts** + +```typescript +// src/features/onboarding/hooks/use-create-agent.ts +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { createAgentAction } from "@/features/agents/agents.action"; +import type { OnboardingAgent, OnboardingDefaultsData } from "@/features/onboarding/types"; + +export const useCreateAgent = () => { + const { state, updateContext } = useOnboarding(); + + return useMutation({ + mutationFn: async (name: string) => { + const orgId = (state?.context.flowData.org as any)?.id as string | undefined; + if (!orgId) throw new Error("Missing org ID — cannot create agent"); + + const defaults = (state?.context.flowData.defaults ?? {}) as OnboardingDefaultsData; + const result = await createAgentAction({ + organizationId: orgId, + data: { name, description: "" }, + }); + if (!result?.data?.data) { + throw new Error(result?.serverError ?? `Failed to create agent "${name}"`); + } + + const newAgent: OnboardingAgent = { + id: result.data.data.id, + name: result.data.data.name, + notifierId: defaults.notifierId, + storageId: defaults.storageId, + }; + const agents = [ + ...((state?.context.flowData.agents ?? []) as OnboardingAgent[]), + newAgent, + ]; + await updateContext({ flowData: { ...state?.context.flowData, agents } }); + return newAgent; + }, + onError: (err: Error) => toast.error(err.message), + }); +}; +``` + +- [ ] **Step 2: Create use-delete-agent.ts** + +```typescript +// src/features/onboarding/hooks/use-delete-agent.ts +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { deleteAgentAction } from "@/features/agents/agent-delete.action"; +import type { OnboardingAgent } from "@/features/onboarding/types"; + +export const useDeleteAgent = () => { + const { state, updateContext } = useOnboarding(); + + return useMutation({ + mutationFn: async (agentId: string) => { + const orgId = (state?.context.flowData.org as any)?.id as string | undefined; + const result = await deleteAgentAction({ agentId, organizationId: orgId }); + if (result?.data?.success === false) throw new Error("Failed to delete agent"); + + const agents = ( + (state?.context.flowData.agents ?? []) as OnboardingAgent[] + ).filter((a) => a.id !== agentId); + await updateContext({ flowData: { ...state?.context.flowData, agents } }); + }, + onError: (err: Error) => toast.error(err.message), + }); +}; +``` + +- [ ] **Step 3: Update step-agent-create.tsx** + +Replace the full file content: + +```typescript +// src/features/onboarding/steps/step-agent-create.tsx +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { X } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useCreateAgent } from "@/features/onboarding/hooks/use-create-agent"; +import { useDeleteAgent } from "@/features/onboarding/hooks/use-delete-agent"; +import type { OnboardingAgent } from "@/features/onboarding/types"; + +export const StepAgentCreate = () => { + const { next, state } = useOnboarding(); + const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[]; + + const [name, setName] = useState(""); + const createAgent = useCreateAgent(); + const deleteAgent = useDeleteAgent(); + + const onAdd = () => { + if (!name.trim()) return; + createAgent.mutate(name.trim(), { onSuccess: () => setName("") }); + }; + + return ( +
+
+

Create an agent

+

+ Optional — agents will use your default notifier and storage. +

+
+ + {agents.length > 0 && ( +
+ {agents.map((agent) => ( +
+ {agent.name} + +
+ ))} +
+ )} + +
+ setName(e.target.value)} + placeholder="agent-prod" + onKeyDown={(e) => e.key === "Enter" && onAdd()} + disabled={createAgent.isPending} + /> + +
+ + +
+ ); +}; +``` + +- [ ] **Step 4: Verify TypeScript** + +```bash +npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/onboarding/hooks/use-create-agent.ts \ + src/features/onboarding/hooks/use-delete-agent.ts \ + src/features/onboarding/steps/step-agent-create.tsx +git commit -m "refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks" +``` + +--- + +### Task 3: Org + Project hooks + +**Files:** +- Create: `src/features/onboarding/hooks/use-create-org.ts` +- Create: `src/features/onboarding/hooks/use-create-project.ts` +- Modify: `src/features/onboarding/steps/step-org-create.tsx` +- Modify: `src/features/onboarding/steps/step-project-create.tsx` + +**Interfaces:** +- `useCreateOrg()` → `UseMutationResult` (mutate receives org name) +- `useCreateProject()` → `UseMutationResult` + +- [ ] **Step 1: Create use-create-org.ts** + +```typescript +// src/features/onboarding/hooks/use-create-org.ts +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { + createOrganizationAction, + updateOrganizationAction, +} from "@/features/organizations/organization.action"; +import { slugify } from "@/utils/slugify"; + +export const useCreateOrg = () => { + const { state, updateContext, next } = useOnboarding(); + + return useMutation({ + mutationFn: async (name: string) => { + const trimmed = name.trim(); + if (!trimmed) throw new Error("Organisation name is required"); + const existingOrg = state?.context.flowData.org; + + if (existingOrg) { + const result = await updateOrganizationAction({ + organizationId: existingOrg.id, + data: { name: trimmed, slug: slugify(trimmed), users: [] }, + }); + if (!result?.data?.success) { + const err = result?.data as { success: false; actionError?: any }; + throw new Error(err?.actionError?.message ?? "Failed to update organisation"); + } + await updateContext({ + flowData: { ...state?.context.flowData, org: { id: existingOrg.id, name: trimmed } }, + }); + } else { + const result = await createOrganizationAction({ name: trimmed }); + if (!result?.data?.success) { + const err = result?.data as { success: false; actionError?: any }; + throw new Error(err?.actionError?.message ?? "Failed to create organisation"); + } + const org = result.data.value; + if (!org) throw new Error("Failed to create organisation"); + await updateContext({ + flowData: { ...state?.context.flowData, org: { id: org.id, name: org.name } }, + }); + } + await next(); + }, + onError: (err: Error) => toast.error(err.message), + }); +}; +``` + +- [ ] **Step 2: Update step-org-create.tsx** + +```typescript +// src/features/onboarding/steps/step-org-create.tsx +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { useCreateOrg } from "@/features/onboarding/hooks/use-create-org"; + +export const StepOrgCreate = () => { + const { state } = useOnboarding(); + const existingOrg = state?.context.flowData.org; + const isEditMode = !!existingOrg; + const [name, setName] = useState(existingOrg?.name ?? ""); + + const mutation = useCreateOrg(); + + return ( +
+
+

+ {isEditMode ? "Edit your organisation" : "Create your organisation"} +

+

+ {isEditMode ? "Rename your organisation." : "This step can't be skipped."} +

+
+
+ + setName(e.target.value)} + placeholder="Acme Inc." + /> +
+ +
+ ); +}; +``` + +- [ ] **Step 3: Create use-create-project.ts** + +```typescript +// src/features/onboarding/hooks/use-create-project.ts +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useOnboarding } from "@onboardjs/react"; +import { toast } from "sonner"; +import { + createProjectAction, + updateProjectAction, +} from "@/features/projects/projects.action"; +import type { OnboardingProjectData } from "@/features/onboarding/types"; + +type ProjectInput = { name: string; description: string; databaseIds: string[] }; + +export const useCreateProject = () => { + const { state, updateContext, next } = useOnboarding(); + + return useMutation({ + mutationFn: async ({ name, description, databaseIds }: ProjectInput) => { + const orgId = (state?.context.flowData.org as any)?.id as string | undefined; + if (!orgId) throw new Error("No organisation ID found"); + const existingProject = state?.context.flowData.project as OnboardingProjectData | undefined; + + if (existingProject?.id) { + const result = await updateProjectAction({ + data: { name, databases: databaseIds }, + organizationId: orgId, + projectId: existingProject.id, + }); + if (!result?.data?.success) { + throw new Error(result?.data?.actionError?.message ?? "Failed to update project"); + } + await updateContext({ + flowData: { + ...state?.context.flowData, + project: { id: existingProject.id, name, description, databaseIds }, + }, + }); + } else { + const result = await createProjectAction({ + data: { name, databases: databaseIds }, + organizationId: orgId, + }); + if (!result?.data?.success) { + throw new Error(result?.data?.actionError?.message ?? "Failed to create project"); + } + const project = result.data.value; + if (!project) throw new Error("Failed to create project"); + await updateContext({ + flowData: { + ...state?.context.flowData, + project: { id: project.id, name: project.name, description, databaseIds }, + }, + }); + } + await next(); + }, + onError: (err: Error) => toast.error(err.message), + }); +}; +``` + +- [ ] **Step 4: Update step-project-create.tsx** + +```typescript +// src/features/onboarding/steps/step-project-create.tsx +"use client"; + +import { useState } from "react"; +import { useOnboarding } from "@onboardjs/react"; +import { Check, Database } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { useCreateProject } from "@/features/onboarding/hooks/use-create-project"; +import type { OnboardingDatabase, OnboardingProjectData } from "@/features/onboarding/types"; + +export const StepProjectCreate = () => { + const { state } = useOnboarding(); + const existingProject = state?.context.flowData.project as OnboardingProjectData | undefined; + const databases = (state?.context.flowData.databases ?? []) as OnboardingDatabase[]; + const isUpdateMode = !!existingProject; + + const [name, setName] = useState(existingProject?.name ?? ""); + const [description, setDescription] = useState(existingProject?.description ?? ""); + const [databaseIds, setDatabaseIds] = useState(existingProject?.databaseIds ?? []); + + const mutation = useCreateProject(); + + const toggleDb = (id: string) => + setDatabaseIds((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id])); + + return ( +
+
+

+ {isUpdateMode ? "Update project" : "Create a project"} +

+

Optional — group databases under a project.

+
+
+ + setName(e.target.value)} + placeholder="My project" + /> +
+
+ +