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>
249 lines
7.5 KiB
TypeScript
249 lines
7.5 KiB
TypeScript
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
|