Files
resolutionflow/frontend/src/pages/welcome/WelcomeStep1.tsx
Michael Chihlas f1be3abcc5
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
feat: self-serve signup Phase 2 (frontend cutover) (#162)
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-07 18:42:20 +00:00

249 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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