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(() => Array.from({ length: DEFAULT_ROW_COUNT }, makeEmptyRow), ) const [submitting, setSubmitting] = useState< 'send' | 'skip' | 'dismiss' | 'continue-anyway' | null >(null) const [error, setError] = useState(null) const [hasUnresolvedFailures, setHasUnresolvedFailures] = useState(false) const isBusy = submitting !== null const updateRow = (idx: number, patch: Partial) => { 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 = {} 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() 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 (

Step 3 of 3

Invite your team

Add up to {MAX_ROWS} teammates. They'll get an email with a link to join. Leave blank to do this later.

{rows.map((row, idx) => (
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} />
{row.error && (

{row.error}

)}
))}
{error && (

{error}

)}
{hasUnresolvedFailures && ( )}
) } export default WelcomeStep3