The original public-landing routing refactor migrated WelcomeRouter, WelcomeStep1, and WelcomeStep2 post-onboarding redirects to /home, but left four sites still pointing at the old / + query-string destinations: - WelcomeStep3 `completeWizardAndExit` (Send invites) - WelcomeStep3 `handleSkipStep` (Skip) - VerifyEmailPage post-verify auto-redirect (`setTimeout`) - VerifyEmailPage success-state "Go to dashboard" Link These all worked by accident because PublicLanding redirects authed users from / to /home — so users still landed on the dashboard, but through an unnecessary mount-and-redirect flicker, and the `?welcome=true` / `?verified=1` query markers got dropped on the way. Drop both query markers — neither is read anywhere in the codebase (grepped frontend/src; the dashboard's onboarding UX is driven by `getOnboardingStatus`, not URL state). Carrying dead URL params just invites future "is this load-bearing?" investigations. Test stubs in WelcomeStep3.test.tsx and VerifyEmailPage.test.tsx moved from `<Route path="/">` to `<Route path="/home">` so the assertions verify the new destination instead of accidentally matching the old one (the previous stubs masked the partial migration). Out of scope: AcceptInvitePage and OAuthCallbackPage still use `?welcome=teammate`, but that one carries an explicit "decoded by the dashboard in Task 41" annotation and may be wired up later, so left untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { Loader2, Plus, X } from 'lucide-react'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import { onboardingApi } from '@/api/onboarding'
|
|
import { accountsApi, type BulkInviteRow } from '@/api/accounts'
|
|
import { toast } from '@/lib/toast'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
const MAX_ROWS = 10
|
|
const DEFAULT_ROW_COUNT = 3
|
|
|
|
type RowRole = 'engineer' | 'viewer'
|
|
|
|
interface InviteRow {
|
|
email: string
|
|
role: RowRole
|
|
/**
|
|
* Server-returned per-row error (from `failed[]`). Kept on the row so
|
|
* users can fix and retry without losing the rest of their input.
|
|
*/
|
|
error?: string
|
|
}
|
|
|
|
const ROLE_OPTIONS: { value: RowRole; label: string }[] = [
|
|
{ value: 'engineer', label: 'Tech' },
|
|
{ value: 'viewer', label: 'Viewer' },
|
|
]
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
|
function makeEmptyRow(): InviteRow {
|
|
return { email: '', role: 'engineer' }
|
|
}
|
|
|
|
/**
|
|
* `/welcome/step-3` — final step of the welcome wizard. Captures up to
|
|
* `MAX_ROWS` teammate invites. On submit:
|
|
*
|
|
* 1. POST `/accounts/me/invites/bulk` with populated rows.
|
|
* 2. PATCH `/users/me/onboarding-step` `{step: 3, action: "complete"}`.
|
|
* 3. Navigate to `/home` and fire a "You're all set" toast.
|
|
*
|
|
* Partial-failure UX: rows in `failed[]` keep their input and show an
|
|
* inline error. The wizard does NOT auto-advance when there are failures —
|
|
* the user can edit and retry, OR click "Continue anyway" to mark step 3
|
|
* complete and head to the dashboard.
|
|
*
|
|
* Empty rows are filtered before submit, so empty-form + "Send" is a no-op
|
|
* that just marks the step complete. (Skip does the same with `action: skip`.)
|
|
*/
|
|
export function WelcomeStep3() {
|
|
const navigate = useNavigate()
|
|
const fetchUser = useAuthStore((s) => s.fetchUser)
|
|
|
|
const [rows, setRows] = useState<InviteRow[]>(() =>
|
|
Array.from({ length: DEFAULT_ROW_COUNT }, makeEmptyRow),
|
|
)
|
|
const [submitting, setSubmitting] = useState<
|
|
'send' | 'skip' | 'dismiss' | 'continue-anyway' | null
|
|
>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [hasUnresolvedFailures, setHasUnresolvedFailures] = useState(false)
|
|
|
|
const isBusy = submitting !== null
|
|
|
|
const updateRow = (idx: number, patch: Partial<InviteRow>) => {
|
|
setRows((prev) =>
|
|
prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)),
|
|
)
|
|
}
|
|
|
|
const removeRow = (idx: number) => {
|
|
setRows((prev) => {
|
|
if (prev.length <= 1) return [makeEmptyRow()]
|
|
return prev.filter((_, i) => i !== idx)
|
|
})
|
|
}
|
|
|
|
const addRow = () => {
|
|
setRows((prev) =>
|
|
prev.length >= MAX_ROWS ? prev : [...prev, makeEmptyRow()],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Validate populated rows. Empty-email rows are dropped silently.
|
|
* Returns either the list of valid rows OR a per-index error map.
|
|
*/
|
|
const validatePopulated = useMemo(
|
|
() => () => {
|
|
const errs: Record<number, string> = {}
|
|
const populated: { idx: number; row: BulkInviteRow }[] = []
|
|
rows.forEach((row, idx) => {
|
|
const email = row.email.trim()
|
|
if (!email) return
|
|
if (!EMAIL_RE.test(email)) {
|
|
errs[idx] = 'Invalid email'
|
|
return
|
|
}
|
|
populated.push({ idx, row: { email, role: row.role } })
|
|
})
|
|
return { errs, populated }
|
|
},
|
|
[rows],
|
|
)
|
|
|
|
const completeWizardAndExit = async () => {
|
|
await onboardingApi.updateStep({ step: 3, action: 'complete' })
|
|
await fetchUser()
|
|
toast.success("You're all set!")
|
|
navigate('/home')
|
|
}
|
|
|
|
const handleSendInvites = async () => {
|
|
if (isBusy) return
|
|
setError(null)
|
|
|
|
const { errs, populated } = validatePopulated()
|
|
if (Object.keys(errs).length > 0) {
|
|
// Surface client-side validation errors inline.
|
|
setRows((prev) =>
|
|
prev.map((row, idx) =>
|
|
errs[idx] ? { ...row, error: errs[idx] } : { ...row, error: undefined },
|
|
),
|
|
)
|
|
return
|
|
}
|
|
|
|
setSubmitting('send')
|
|
try {
|
|
let failedSet = new Map<string, string>()
|
|
if (populated.length > 0) {
|
|
const result = await accountsApi.bulkInvite(populated.map((p) => p.row))
|
|
failedSet = new Map(result.failed.map((f) => [f.email, f.error]))
|
|
}
|
|
|
|
if (failedSet.size > 0) {
|
|
// Stamp errors on the matching rows; do NOT auto-advance.
|
|
setRows((prev) =>
|
|
prev.map((row) => {
|
|
const email = row.email.trim()
|
|
const err = email ? failedSet.get(email) : undefined
|
|
return { ...row, error: err }
|
|
}),
|
|
)
|
|
setHasUnresolvedFailures(true)
|
|
setSubmitting(null)
|
|
return
|
|
}
|
|
|
|
// All-clear (or zero invites sent): mark step complete and exit.
|
|
await completeWizardAndExit()
|
|
} catch {
|
|
setError('Could not send invites. Please try again.')
|
|
setSubmitting(null)
|
|
}
|
|
}
|
|
|
|
const handleContinueAnyway = async () => {
|
|
if (isBusy) return
|
|
setError(null)
|
|
setSubmitting('continue-anyway')
|
|
try {
|
|
await completeWizardAndExit()
|
|
} 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: 3, action: 'skip' })
|
|
await fetchUser()
|
|
toast.success("You're all set!")
|
|
navigate('/home')
|
|
} 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('/home')
|
|
} catch {
|
|
setError('Could not save. Please try again.')
|
|
setSubmitting(null)
|
|
}
|
|
}
|
|
|
|
const inputClass = cn(
|
|
'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 3 of 3
|
|
</p>
|
|
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
|
|
Invite your team
|
|
</h1>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
Add up to {MAX_ROWS} teammates. They'll get an email with a link to
|
|
join. Leave blank to do this later.
|
|
</p>
|
|
</header>
|
|
|
|
<div
|
|
className="rounded-2xl border border-border bg-card p-6 space-y-4"
|
|
data-testid="welcome-step-3-form"
|
|
>
|
|
<div className="space-y-3">
|
|
{rows.map((row, idx) => (
|
|
<div key={idx} className="space-y-1">
|
|
<div className="flex items-start gap-2">
|
|
<input
|
|
type="email"
|
|
value={row.email}
|
|
onChange={(e) => updateRow(idx, { email: e.target.value, error: undefined })}
|
|
placeholder="teammate@example.com"
|
|
className={cn(inputClass, 'flex-1')}
|
|
data-testid={`welcome-step-3-email-${idx}`}
|
|
disabled={isBusy}
|
|
/>
|
|
<select
|
|
value={row.role}
|
|
onChange={(e) =>
|
|
updateRow(idx, { role: e.target.value as RowRole })
|
|
}
|
|
className={cn(inputClass, 'w-32 flex-shrink-0')}
|
|
data-testid={`welcome-step-3-role-${idx}`}
|
|
disabled={isBusy}
|
|
>
|
|
{ROLE_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeRow(idx)}
|
|
disabled={isBusy || rows.length <= 1}
|
|
data-testid={`welcome-step-3-remove-${idx}`}
|
|
aria-label="Remove row"
|
|
className={cn(
|
|
'inline-flex h-10 w-10 items-center justify-center rounded-xl',
|
|
'text-muted-foreground hover:bg-foreground/5 hover:text-foreground',
|
|
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
|
|
'disabled:opacity-30 disabled:hover:bg-transparent',
|
|
)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
{row.error && (
|
|
<p
|
|
className="pl-1 text-xs text-red-400"
|
|
data-testid={`welcome-step-3-row-error-${idx}`}
|
|
>
|
|
{row.error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={addRow}
|
|
disabled={isBusy || rows.length >= MAX_ROWS}
|
|
data-testid="welcome-step-3-add-row"
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-xl px-2 py-1 text-xs font-medium',
|
|
'text-muted-foreground hover:bg-foreground/5 hover:text-foreground',
|
|
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
|
|
'disabled:opacity-50',
|
|
)}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Add another
|
|
</button>
|
|
|
|
{error && (
|
|
<p className="text-xs text-red-400" data-testid="welcome-step-3-error">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-3 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleSendInvites}
|
|
disabled={isBusy}
|
|
data-testid="welcome-step-3-send"
|
|
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 === 'send' && (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
)}
|
|
Send invites and continue
|
|
</button>
|
|
{hasUnresolvedFailures && (
|
|
<button
|
|
type="button"
|
|
onClick={handleContinueAnyway}
|
|
disabled={isBusy}
|
|
data-testid="welcome-step-3-continue-anyway"
|
|
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 === 'continue-anyway' && (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
)}
|
|
Continue anyway
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={handleSkipStep}
|
|
disabled={isBusy}
|
|
data-testid="welcome-step-3-skip"
|
|
className={cn(
|
|
'text-sm text-muted-foreground hover:underline',
|
|
'disabled:opacity-60',
|
|
)}
|
|
>
|
|
{submitting === 'skip' ? 'Saving…' : 'Skip'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={handleDismissRest}
|
|
disabled={isBusy}
|
|
data-testid="welcome-step-3-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 WelcomeStep3
|