feat(onboarding): add welcome wizard scaffold + Step 1 (Your shop)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 22:54:10 -04:00
parent 7d939a4acf
commit 9b517d3320
8 changed files with 652 additions and 0 deletions

View File

@@ -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

View File

@@ -19,3 +19,51 @@ export async function getOnboardingStatus(): Promise<OnboardingStatus> {
export async function dismissOnboarding(): Promise<void> {
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<OnboardingStepResponse> {
const response = await apiClient.patch<OnboardingStepResponse>(
'/users/me/onboarding-step',
payload,
)
return response.data
},
/** Skip the rest of the welcome wizard — sets users.onboarding_dismissed=TRUE. */
async dismissRest(): Promise<OnboardingStepResponse> {
const response = await apiClient.post<OnboardingStepResponse>(
'/users/me/onboarding-dismiss-rest',
)
return response.data
},
}

View File

@@ -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 <PageLoader />
if (user.onboarding_dismissed) return <Navigate to="/" replace />
const completed = user.onboarding_step_completed ?? 0
if (completed >= 3) return <Navigate to="/" replace />
if (completed === 2) return <Navigate to="/welcome/step-3" replace />
if (completed === 1) return <Navigate to="/welcome/step-2" replace />
return <Navigate to="/welcome/step-1" replace />
}
export default WelcomeRouter

View File

@@ -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: '12' },
{ value: '3-5', label: '35' },
{ value: '6-10', label: '610' },
{ value: '11-25', label: '1125' },
{ 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<string>(account?.name ?? '')
const [teamSize, setTeamSize] = useState<TeamSizeBucket | ''>('')
const [role, setRole] = useState<RoleAtSignup | ''>('')
const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="mx-auto w-full max-w-2xl px-4 py-10">
<header className="mb-8">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
Step 1 of 3
</p>
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
Your shop
</h1>
<p className="mt-2 text-sm text-muted-foreground">
A couple of quick questions so we can tailor ResolutionFlow to your team.
</p>
</header>
<form
onSubmit={handleContinue}
className="rounded-2xl border border-border bg-card p-6 space-y-5"
data-testid="welcome-step-1-form"
>
<div>
<label
htmlFor="company_name"
className="block text-sm font-medium text-foreground"
>
Company name
</label>
<input
id="company_name"
name="company_name"
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className={inputClass}
placeholder="Acme MSP"
data-testid="welcome-step-1-company-name"
/>
</div>
<div>
<label
htmlFor="team_size"
className="block text-sm font-medium text-foreground"
>
Team size
</label>
<select
id="team_size"
name="team_size"
value={teamSize}
onChange={(e) => setTeamSize(e.target.value as TeamSizeBucket | '')}
className={inputClass}
data-testid="welcome-step-1-team-size"
>
<option value="">Select team size</option>
{TEAM_SIZE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="role"
className="block text-sm font-medium text-foreground"
>
Your role
</label>
<select
id="role"
name="role"
value={role}
onChange={(e) => setRole(e.target.value as RoleAtSignup | '')}
className={inputClass}
data-testid="welcome-step-1-role"
>
<option value="">Select your role</option>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{error && (
<p className="text-xs text-red-400" data-testid="welcome-step-1-error">
{error}
</p>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={isBusy}
data-testid="welcome-step-1-continue"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-primary text-primary-foreground hover:bg-primary/90',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'disabled:opacity-60',
)}
>
{submitting === 'continue' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Continue
</button>
<button
type="button"
onClick={handleSkipStep}
disabled={isBusy}
data-testid="welcome-step-1-skip"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-60',
)}
>
{submitting === 'skip' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Skip this step
</button>
</div>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={handleDismissRest}
disabled={isBusy}
data-testid="welcome-step-1-dismiss-rest"
className={cn(
'text-xs text-muted-foreground hover:underline',
'disabled:opacity-60',
)}
>
{submitting === 'dismiss' ? 'Saving…' : 'Skip the rest'}
</button>
</div>
</div>
)
}
export default WelcomeStep1

View File

@@ -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> = {}): 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(
<MemoryRouter initialEntries={['/welcome']}>
<Routes>
<Route path="/welcome" element={<WelcomeRouter />} />
<Route path="/welcome/step-1" element={<div>step-1</div>} />
<Route path="/welcome/step-2" element={<div>step-2</div>} />
<Route path="/welcome/step-3" element={<div>step-3</div>} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
}
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()
})
})
})

View File

@@ -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<typeof import('@/api/onboarding')>(
'@/api/onboarding',
)
return {
...actual,
onboardingApi: {
...actual.onboardingApi,
updateStep: vi.fn(),
dismissRest: vi.fn(),
},
}
})
function makeUser(overrides: Partial<User> = {}): 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> = {}): 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(
<MemoryRouter initialEntries={['/welcome/step-1']}>
<Routes>
<Route path="/welcome/step-1" element={<WelcomeStep1 />} />
<Route path="/welcome/step-2" element={<div>step-2</div>} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
}
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()
})
})

View File

@@ -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',

View File

@@ -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 {