From 9b517d3320e965d18a133fec8e0161919bc123e7 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 22:54:10 -0400 Subject: [PATCH] feat(onboarding): add welcome wizard scaffold + Step 1 (Your shop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the groundwork for the post-signup welcome wizard (Phase 2, Task 38). Authed users hitting /welcome are routed to the next incomplete step based on users.onboarding_step_completed + users.onboarding_dismissed; refresh resumes correctly because every navigation persists state server-side first. Backend: - Expose onboarding_step_completed (Optional[int]) and onboarding_dismissed (bool) on UserResponse so /auth/me drives client-side routing without a separate fetch. Frontend: - WelcomeRouter handles the /welcome decision table (dismissed → /, completed >=3 → /, else next step). - WelcomeStep1 renders the "Your shop" form (company name pre-filled from accounts.name, team size 1-2/3-5/6-10/11-25/26+, role Owner/Lead Tech/Tech/Other). Continue PATCHes /users/me/onboarding-step with action=complete; Skip-this-step PATCHes action=skip; Skip-the-rest POSTs /users/me/onboarding-dismiss-rest. Each action refreshes the auth store before navigating so the router resumes correctly on the next visit. - onboardingApi.updateStep + dismissRest (typed against backend OnboardingStepRequest/Response schemas). - Routes mounted inside AppLayout so EmailVerificationBanner persists above each step per spec. - 11 vitest cases covering the routing decision table + Continue / Skip / Skip-the-rest / persist-failure paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/schemas/user.py | 2 + frontend/src/api/onboarding.ts | 48 ++++ frontend/src/pages/welcome/WelcomeRouter.tsx | 31 +++ frontend/src/pages/welcome/WelcomeStep1.tsx | 248 ++++++++++++++++++ .../welcome/__tests__/WelcomeRouter.test.tsx | 125 +++++++++ .../welcome/__tests__/WelcomeStep1.test.tsx | 189 +++++++++++++ frontend/src/router.tsx | 7 + frontend/src/types/user.ts | 2 + 8 files changed, 652 insertions(+) create mode 100644 frontend/src/pages/welcome/WelcomeRouter.tsx create mode 100644 frontend/src/pages/welcome/WelcomeStep1.tsx create mode 100644 frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx create mode 100644 frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 0c3162fc..81d7c8b3 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -58,6 +58,8 @@ class UserResponse(UserBase): timezone: str = "UTC" avatar_url: Optional[str] = None email_verified_at: Optional[datetime] = None + onboarding_step_completed: Optional[int] = None + onboarding_dismissed: bool = False class Config: from_attributes = True diff --git a/frontend/src/api/onboarding.ts b/frontend/src/api/onboarding.ts index 4f54e687..f5caa8f3 100644 --- a/frontend/src/api/onboarding.ts +++ b/frontend/src/api/onboarding.ts @@ -19,3 +19,51 @@ export async function getOnboardingStatus(): Promise { export async function dismissOnboarding(): Promise { await apiClient.post('/users/onboarding-status/dismiss') } + +// --- Welcome wizard (Phase 2) --------------------------------------------- + +export type WizardStep = 1 | 2 | 3 +export type WizardAction = 'complete' | 'skip' +export type TeamSizeBucket = '1-2' | '3-5' | '6-10' | '11-25' | '26+' +export type RoleAtSignup = 'owner' | 'lead_tech' | 'tech' | 'other' +export type PrimaryPsa = 'connectwise' | 'autotask' | 'halopsa' | 'none' + +export interface OnboardingStepData { + // Step 1 + company_name?: string + team_size_bucket?: TeamSizeBucket + role_at_signup?: RoleAtSignup + // Step 2 + primary_psa?: PrimaryPsa +} + +export interface OnboardingStepRequest { + step: WizardStep + action: WizardAction + data?: OnboardingStepData +} + +export interface OnboardingStepResponse { + onboarding_step_completed: number | null + onboarding_dismissed: boolean +} + +export const onboardingApi = { + getStatus: getOnboardingStatus, + dismiss: dismissOnboarding, + /** Persist welcome-wizard progress for the current user. */ + async updateStep(payload: OnboardingStepRequest): Promise { + const response = await apiClient.patch( + '/users/me/onboarding-step', + payload, + ) + return response.data + }, + /** Skip the rest of the welcome wizard — sets users.onboarding_dismissed=TRUE. */ + async dismissRest(): Promise { + const response = await apiClient.post( + '/users/me/onboarding-dismiss-rest', + ) + return response.data + }, +} diff --git a/frontend/src/pages/welcome/WelcomeRouter.tsx b/frontend/src/pages/welcome/WelcomeRouter.tsx new file mode 100644 index 00000000..024dbe97 --- /dev/null +++ b/frontend/src/pages/welcome/WelcomeRouter.tsx @@ -0,0 +1,31 @@ +import { Navigate } from 'react-router-dom' +import { useAuthStore } from '@/store/authStore' +import { PageLoader } from '@/components/common/PageLoader' + +/** + * `/welcome` index — redirect to the next incomplete step (or `/` if done / + * dismissed). Decision table: + * + * onboarding_dismissed === true → / + * onboarding_step_completed >= 3 → / + * onboarding_step_completed === null/0 → /welcome/step-1 + * onboarding_step_completed === 1 → /welcome/step-2 + * onboarding_step_completed === 2 → /welcome/step-3 + */ +export function WelcomeRouter() { + const user = useAuthStore((s) => s.user) + + // Auth gate sits above us — but if the user object is still loading, render + // the page loader rather than racing past the redirect. + if (!user) return + + if (user.onboarding_dismissed) return + + const completed = user.onboarding_step_completed ?? 0 + if (completed >= 3) return + if (completed === 2) return + if (completed === 1) return + return +} + +export default WelcomeRouter diff --git a/frontend/src/pages/welcome/WelcomeStep1.tsx b/frontend/src/pages/welcome/WelcomeStep1.tsx new file mode 100644 index 00000000..412217e9 --- /dev/null +++ b/frontend/src/pages/welcome/WelcomeStep1.tsx @@ -0,0 +1,248 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { Loader2 } from 'lucide-react' +import { useAuthStore } from '@/store/authStore' +import { + onboardingApi, + type RoleAtSignup, + type TeamSizeBucket, +} from '@/api/onboarding' +import { cn } from '@/lib/utils' + +const TEAM_SIZE_OPTIONS: { value: TeamSizeBucket; label: string }[] = [ + { value: '1-2', label: '1–2' }, + { value: '3-5', label: '3–5' }, + { value: '6-10', label: '6–10' }, + { value: '11-25', label: '11–25' }, + { value: '26+', label: '26+' }, +] + +const ROLE_OPTIONS: { value: RoleAtSignup; label: string }[] = [ + { value: 'owner', label: 'Owner' }, + { value: 'lead_tech', label: 'Lead Tech' }, + { value: 'tech', label: 'Tech' }, + { value: 'other', label: 'Other' }, +] + +/** + * `/welcome/step-1` — first step of the welcome wizard. Captures shop context + * (company name, team size, role). Persists server-side before navigating. + */ +export function WelcomeStep1() { + const navigate = useNavigate() + const account = useAuthStore((s) => s.account) + const fetchUser = useAuthStore((s) => s.fetchUser) + + const [companyName, setCompanyName] = useState(account?.name ?? '') + const [teamSize, setTeamSize] = useState('') + const [role, setRole] = useState('') + const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null) + const [error, setError] = useState(null) + + const isBusy = submitting !== null + + const handleContinue = async (e: FormEvent) => { + e.preventDefault() + if (isBusy) return + setError(null) + setSubmitting('continue') + try { + await onboardingApi.updateStep({ + step: 1, + action: 'complete', + data: { + company_name: companyName.trim() || undefined, + team_size_bucket: teamSize || undefined, + role_at_signup: role || undefined, + }, + }) + await fetchUser() + navigate('/welcome/step-2') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + const handleSkipStep = async () => { + if (isBusy) return + setError(null) + setSubmitting('skip') + try { + await onboardingApi.updateStep({ step: 1, action: 'skip' }) + await fetchUser() + navigate('/welcome/step-2') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + const handleDismissRest = async () => { + if (isBusy) return + setError(null) + setSubmitting('dismiss') + try { + await onboardingApi.dismissRest() + await fetchUser() + navigate('/') + } catch { + setError('Could not save. Please try again.') + setSubmitting(null) + } + } + + const inputClass = cn( + 'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2', + 'text-foreground placeholder:text-muted-foreground', + 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20', + ) + + return ( +
+
+

+ Step 1 of 3 +

+

+ Your shop +

+

+ A couple of quick questions so we can tailor ResolutionFlow to your team. +

+
+ +
+
+ + setCompanyName(e.target.value)} + className={inputClass} + placeholder="Acme MSP" + data-testid="welcome-step-1-company-name" + /> +
+ +
+ + +
+ +
+ + +
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+ +
+ +
+
+ ) +} + +export default WelcomeStep1 diff --git a/frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx new file mode 100644 index 00000000..94c81f30 --- /dev/null +++ b/frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import { WelcomeRouter } from '../WelcomeRouter' +import { useAuthStore } from '@/store/authStore' +import type { User } from '@/types' + +function makeUser(overrides: Partial = {}): User { + return { + id: 'user-1', + email: 'test@example.com', + name: 'Test User', + role: 'engineer', + is_super_admin: false, + is_active: true, + must_change_password: false, + account_id: 'acct-1', + account_role: 'owner', + team_id: null, + created_at: '2026-05-01T00:00:00Z', + last_login: null, + phone: null, + job_title: null, + timezone: 'UTC', + avatar_url: null, + email_verified_at: null, + onboarding_step_completed: null, + onboarding_dismissed: false, + ...overrides, + } +} + +function renderRouter() { + return render( + + + } /> + step-1} /> + step-2} /> + step-3} /> + dashboard} /> + + , + ) +} + +describe('WelcomeRouter', () => { + beforeEach(() => { + useAuthStore.setState({ + user: null, + account: null, + subscription: null, + token: null, + isAuthenticated: false, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('redirects to step-1 on null onboarding_step_completed', async () => { + useAuthStore.setState({ + user: makeUser({ onboarding_step_completed: null }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('step-1')).toBeInTheDocument() + }) + }) + + it('redirects to step-1 when onboarding_step_completed is 0', async () => { + useAuthStore.setState({ + user: makeUser({ onboarding_step_completed: 0 }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('step-1')).toBeInTheDocument() + }) + }) + + it('redirects to step-2 when onboarding_step_completed is 1', async () => { + useAuthStore.setState({ + user: makeUser({ onboarding_step_completed: 1 }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('step-2')).toBeInTheDocument() + }) + }) + + it('redirects to step-3 when onboarding_step_completed is 2', async () => { + useAuthStore.setState({ + user: makeUser({ onboarding_step_completed: 2 }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('step-3')).toBeInTheDocument() + }) + }) + + it('redirects to / when onboarding_step_completed >= 3', async () => { + useAuthStore.setState({ + user: makeUser({ onboarding_step_completed: 3 }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) + + it('redirects to / when onboarding_dismissed is true', async () => { + useAuthStore.setState({ + user: makeUser({ + onboarding_step_completed: 1, + onboarding_dismissed: true, + }), + }) + renderRouter() + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx new file mode 100644 index 00000000..93483add --- /dev/null +++ b/frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import { WelcomeStep1 } from '../WelcomeStep1' +import { useAuthStore } from '@/store/authStore' +import { onboardingApi } from '@/api/onboarding' +import type { Account, User } from '@/types' + +vi.mock('@/api/onboarding', async () => { + const actual = await vi.importActual( + '@/api/onboarding', + ) + return { + ...actual, + onboardingApi: { + ...actual.onboardingApi, + updateStep: vi.fn(), + dismissRest: vi.fn(), + }, + } +}) + +function makeUser(overrides: Partial = {}): User { + return { + id: 'user-1', + email: 'test@example.com', + name: 'Test User', + role: 'engineer', + is_super_admin: false, + is_active: true, + must_change_password: false, + account_id: 'acct-1', + account_role: 'owner', + team_id: null, + created_at: '2026-05-01T00:00:00Z', + last_login: null, + phone: null, + job_title: null, + timezone: 'UTC', + avatar_url: null, + email_verified_at: null, + onboarding_step_completed: null, + onboarding_dismissed: false, + ...overrides, + } +} + +function makeAccount(overrides: Partial = {}): Account { + return { + id: 'acct-1', + name: 'Acme MSP', + display_code: 'ACME', + owner_id: 'user-1', + created_at: '2026-05-01T00:00:00Z', + updated_at: '2026-05-01T00:00:00Z', + ...overrides, + } +} + +function renderPage() { + return render( + + + } /> + step-2} /> + dashboard} /> + + , + ) +} + +describe('WelcomeStep1', () => { + beforeEach(() => { + useAuthStore.setState({ + user: makeUser(), + account: makeAccount(), + subscription: null, + token: null, + isAuthenticated: true, + // Stub fetchUser so it doesn't try to hit the network in jsdom. + fetchUser: vi.fn().mockResolvedValue(undefined), + }) + vi.mocked(onboardingApi.updateStep).mockResolvedValue({ + onboarding_step_completed: 1, + onboarding_dismissed: false, + }) + vi.mocked(onboardingApi.dismissRest).mockResolvedValue({ + onboarding_step_completed: null, + onboarding_dismissed: true, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('pre-fills the company name from the auth store account', () => { + renderPage() + const input = screen.getByTestId('welcome-step-1-company-name') as HTMLInputElement + expect(input.value).toBe('Acme MSP') + }) + + it('Continue persists data and navigates to /welcome/step-2', async () => { + const user = userEvent.setup() + renderPage() + + const teamSize = screen.getByTestId('welcome-step-1-team-size') as HTMLSelectElement + await user.selectOptions(teamSize, '3-5') + const role = screen.getByTestId('welcome-step-1-role') as HTMLSelectElement + await user.selectOptions(role, 'owner') + + await user.click(screen.getByTestId('welcome-step-1-continue')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 1, + action: 'complete', + data: { + company_name: 'Acme MSP', + team_size_bucket: '3-5', + role_at_signup: 'owner', + }, + }) + }) + + await waitFor(() => { + expect(screen.getByText('step-2')).toBeInTheDocument() + }) + }) + + it('Skip this step calls updateStep with action=skip and navigates to /welcome/step-2', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-1-skip')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 1, + action: 'skip', + }) + }) + + await waitFor(() => { + expect(screen.getByText('step-2')).toBeInTheDocument() + }) + }) + + it('Skip-the-rest dismisses and navigates to /', async () => { + const user = userEvent.setup() + renderPage() + + const dismiss = screen.getByTestId('welcome-step-1-dismiss-rest') + // Sanity check: it's a quiet text link, not a primary button. + expect(dismiss.className).toMatch(/text-muted-foreground/) + expect(dismiss.className).toMatch(/hover:underline/) + expect(dismiss.className).toMatch(/text-xs/) + expect(dismiss.className).not.toMatch(/bg-primary/) + + await user.click(dismiss) + + await waitFor(() => { + expect(onboardingApi.dismissRest).toHaveBeenCalled() + }) + + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) + + it('shows an error when the persist call fails and stays on the page', async () => { + vi.mocked(onboardingApi.updateStep).mockRejectedValueOnce( + new Error('boom'), + ) + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-1-continue')) + + await waitFor(() => { + expect(screen.getByTestId('welcome-step-1-error')).toBeInTheDocument() + }) + + // Should not have navigated. + expect(screen.queryByText('step-2')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 401b4969..423820a5 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -67,6 +67,9 @@ const DevBranchingPage = lazyWithRetry(() => import('@/pages/DevBranchingPage')) const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage')) const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage')) const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsPage')) +// Welcome wizard (Phase 2) +const WelcomeRouter = lazyWithRetry(() => import('@/pages/welcome/WelcomeRouter')) +const WelcomeStep1 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep1')) const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams')) const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor')) // Admin pages @@ -240,6 +243,10 @@ export const router = sentryCreateBrowserRouter([ { path: 'dev/branching', element: page(DevBranchingPage) }, { path: 'guides', element: page(GuidesHubPage) }, { path: 'guides/:slug', element: page(GuideDetailPage) }, + // Welcome wizard (Phase 2). Mounted inside AppLayout so the email- + // verification banner persists above each step. + { path: 'welcome', element: page(WelcomeRouter) }, + { path: 'welcome/step-1', element: page(WelcomeStep1) }, // Admin routes { path: 'admin', diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 8f65b34f..f708d00f 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -18,6 +18,8 @@ export interface User { timezone: string avatar_url: string | null email_verified_at: string | null + onboarding_step_completed: number | null + onboarding_dismissed: boolean } export interface UserCreate {