# 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" />