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:
@@ -58,6 +58,8 @@ class UserResponse(UserBase):
|
|||||||
timezone: str = "UTC"
|
timezone: str = "UTC"
|
||||||
avatar_url: Optional[str] = None
|
avatar_url: Optional[str] = None
|
||||||
email_verified_at: Optional[datetime] = None
|
email_verified_at: Optional[datetime] = None
|
||||||
|
onboarding_step_completed: Optional[int] = None
|
||||||
|
onboarding_dismissed: bool = False
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -19,3 +19,51 @@ export async function getOnboardingStatus(): Promise<OnboardingStatus> {
|
|||||||
export async function dismissOnboarding(): Promise<void> {
|
export async function dismissOnboarding(): Promise<void> {
|
||||||
await apiClient.post('/users/onboarding-status/dismiss')
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
31
frontend/src/pages/welcome/WelcomeRouter.tsx
Normal file
31
frontend/src/pages/welcome/WelcomeRouter.tsx
Normal 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
|
||||||
248
frontend/src/pages/welcome/WelcomeStep1.tsx
Normal file
248
frontend/src/pages/welcome/WelcomeStep1.tsx
Normal 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: '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<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
|
||||||
125
frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx
Normal file
125
frontend/src/pages/welcome/__tests__/WelcomeRouter.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
189
frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx
Normal file
189
frontend/src/pages/welcome/__tests__/WelcomeStep1.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -67,6 +67,9 @@ const DevBranchingPage = lazyWithRetry(() => import('@/pages/DevBranchingPage'))
|
|||||||
const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage'))
|
const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage'))
|
||||||
const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage'))
|
const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage'))
|
||||||
const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsPage'))
|
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 NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams'))
|
||||||
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))
|
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))
|
||||||
// Admin pages
|
// Admin pages
|
||||||
@@ -240,6 +243,10 @@ export const router = sentryCreateBrowserRouter([
|
|||||||
{ path: 'dev/branching', element: page(DevBranchingPage) },
|
{ path: 'dev/branching', element: page(DevBranchingPage) },
|
||||||
{ path: 'guides', element: page(GuidesHubPage) },
|
{ path: 'guides', element: page(GuidesHubPage) },
|
||||||
{ path: 'guides/:slug', element: page(GuideDetailPage) },
|
{ 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
|
// Admin routes
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface User {
|
|||||||
timezone: string
|
timezone: string
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
email_verified_at: string | null
|
email_verified_at: string | null
|
||||||
|
onboarding_step_completed: number | null
|
||||||
|
onboarding_dismissed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserCreate {
|
export interface UserCreate {
|
||||||
|
|||||||
Reference in New Issue
Block a user