diff --git a/frontend/src/api/accounts.ts b/frontend/src/api/accounts.ts index 44db6b4e..7311b25d 100644 --- a/frontend/src/api/accounts.ts +++ b/frontend/src/api/accounts.ts @@ -1,6 +1,22 @@ import apiClient from './client' import type { Account, SubscriptionDetails, AccountMember, AccountInvite } from '@/types' +export interface BulkInviteRow { + email: string + role: 'engineer' | 'viewer' + expires_in_days?: number +} + +export interface BulkInviteFailure { + email: string + error: string +} + +export interface BulkInviteResponse { + created: AccountInvite[] + failed: BulkInviteFailure[] +} + export const accountsApi = { async getMyAccount(): Promise { const response = await apiClient.get('/accounts/me') @@ -39,6 +55,18 @@ export const accountsApi = { return response.data }, + /** + * Create multiple invites in one call (used by the welcome wizard step 3). + * Per-row failures land in `failed[]`; successes in `created[]`. + */ + async bulkInvite(invites: BulkInviteRow[]): Promise { + const response = await apiClient.post( + '/accounts/me/invites/bulk', + { invites }, + ) + return response.data + }, + async getInvites(): Promise { const response = await apiClient.get('/accounts/me/invites') return response.data diff --git a/frontend/src/pages/welcome/WelcomeStep2.tsx b/frontend/src/pages/welcome/WelcomeStep2.tsx new file mode 100644 index 00000000..1b803894 --- /dev/null +++ b/frontend/src/pages/welcome/WelcomeStep2.tsx @@ -0,0 +1,208 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { Loader2 } from 'lucide-react' +import { useAuthStore } from '@/store/authStore' +import { onboardingApi, type PrimaryPsa } from '@/api/onboarding' +import { cn } from '@/lib/utils' + +const PSA_OPTIONS: { value: PrimaryPsa; label: string; description: string }[] = [ + { value: 'connectwise', label: 'ConnectWise', description: 'Manage / PSA' }, + { value: 'autotask', label: 'Autotask', description: 'Datto Autotask PSA' }, + { value: 'halopsa', label: 'HaloPSA', description: 'Halo Service Solutions' }, + { value: 'none', label: 'No PSA yet', description: "We'll add one later" }, +] + +/** + * `/welcome/step-2` — second step of the welcome wizard. Captures the PSA the + * shop primarily uses. Selecting a non-`none` tile reveals a quiet "Connect + * now" link that navigates out to `/account/integrations`. The wizard's + * primary action is "Continue" — credential entry is intentionally OUT of + * the wizard (per spec). + */ +export function WelcomeStep2() { + const navigate = useNavigate() + const fetchUser = useAuthStore((s) => s.fetchUser) + + const [primaryPsa, setPrimaryPsa] = useState(null) + const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null) + const [error, setError] = useState(null) + + const isBusy = submitting !== null + const showConnectNow = primaryPsa !== null && primaryPsa !== 'none' + + const handleContinue = async () => { + if (isBusy) return + setError(null) + setSubmitting('continue') + try { + await onboardingApi.updateStep({ + step: 2, + action: 'complete', + data: primaryPsa ? { primary_psa: primaryPsa } : undefined, + }) + await fetchUser() + navigate('/welcome/step-3') + } 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: 2, action: 'skip' }) + await fetchUser() + navigate('/welcome/step-3') + } 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) + } + } + + return ( +
+
+

+ Step 2 of 3 +

+

+ Your PSA +

+

+ Pick the PSA your team uses today. We'll wire it up later — no + credentials needed yet. +

+
+ +
+
+ {PSA_OPTIONS.map((opt) => { + const selected = primaryPsa === opt.value + return ( + + ) + })} +
+ + {showConnectNow && ( +
+ + Connect now → + +
+ )} + + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+ +
+ +
+
+ ) +} + +export default WelcomeStep2 diff --git a/frontend/src/pages/welcome/WelcomeStep3.tsx b/frontend/src/pages/welcome/WelcomeStep3.tsx new file mode 100644 index 00000000..d3659037 --- /dev/null +++ b/frontend/src/pages/welcome/WelcomeStep3.tsx @@ -0,0 +1,374 @@ +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 `/?welcome=true` 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('/?welcome=true') + } + + 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('/?welcome=true') + } 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( + '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 diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx new file mode 100644 index 00000000..57b2a9bd --- /dev/null +++ b/frontend/src/pages/welcome/__tests__/WelcomeStep2.test.tsx @@ -0,0 +1,174 @@ +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 { WelcomeStep2 } from '../WelcomeStep2' +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( + '@/api/onboarding', + ) + return { + ...actual, + onboardingApi: { + ...actual.onboardingApi, + updateStep: vi.fn(), + dismissRest: vi.fn(), + }, + } +}) + +function makeUser(overrides: Partial = {}): 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: 1, + onboarding_dismissed: false, + ...overrides, + } +} + +function makeAccount(overrides: Partial = {}): 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( + + + } /> + step-3} /> + integrations} /> + dashboard} /> + + , + ) +} + +describe('WelcomeStep2', () => { + beforeEach(() => { + useAuthStore.setState({ + user: makeUser(), + account: makeAccount(), + subscription: null, + token: null, + isAuthenticated: true, + fetchUser: vi.fn().mockResolvedValue(undefined), + }) + vi.mocked(onboardingApi.updateStep).mockResolvedValue({ + onboarding_step_completed: 2, + onboarding_dismissed: false, + }) + vi.mocked(onboardingApi.dismissRest).mockResolvedValue({ + onboarding_step_completed: null, + onboarding_dismissed: true, + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('selecting PSA persists primary_psa', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-2-tile-connectwise')) + // Selecting a real PSA reveals the inline "Connect now" link. + expect(screen.getByTestId('welcome-step-2-connect-now')).toBeInTheDocument() + + await user.click(screen.getByTestId('welcome-step-2-continue')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 2, + action: 'complete', + data: { primary_psa: 'connectwise' }, + }) + }) + + await waitFor(() => { + expect(screen.getByText('step-3')).toBeInTheDocument() + }) + }) + + it('Skip advances without writing primary_psa', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-2-skip')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 2, + action: 'skip', + }) + }) + + // Confirm no `data` key on the call (skip doesn't persist primary_psa). + const call = vi.mocked(onboardingApi.updateStep).mock.calls[0]?.[0] + expect(call?.data).toBeUndefined() + + await waitFor(() => { + expect(screen.getByText('step-3')).toBeInTheDocument() + }) + }) + + it('"No PSA yet" tile does NOT show the Connect now link', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-2-tile-none')) + expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument() + }) + + it('default action is Continue (not Connect now)', () => { + renderPage() + // Continue is rendered as a primary button. + const continueBtn = screen.getByTestId('welcome-step-2-continue') + expect(continueBtn.className).toMatch(/bg-primary/) + // Connect-now is hidden until a real PSA is picked. + expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument() + }) + + it('Skip-the-rest dismisses and navigates to /', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-2-dismiss-rest')) + + await waitFor(() => { + expect(onboardingApi.dismissRest).toHaveBeenCalled() + }) + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx b/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx new file mode 100644 index 00000000..c90be2dd --- /dev/null +++ b/frontend/src/pages/welcome/__tests__/WelcomeStep3.test.tsx @@ -0,0 +1,279 @@ +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 { WelcomeStep3 } from '../WelcomeStep3' +import { useAuthStore } from '@/store/authStore' +import { onboardingApi } from '@/api/onboarding' +import { accountsApi } from '@/api/accounts' +import type { Account, User } from '@/types' + +vi.mock('@/api/onboarding', async () => { + const actual = await vi.importActual( + '@/api/onboarding', + ) + return { + ...actual, + onboardingApi: { + ...actual.onboardingApi, + updateStep: vi.fn(), + dismissRest: vi.fn(), + }, + } +}) + +vi.mock('@/api/accounts', async () => { + const actual = await vi.importActual( + '@/api/accounts', + ) + return { + ...actual, + accountsApi: { + ...actual.accountsApi, + bulkInvite: vi.fn(), + }, + } +}) + +vi.mock('@/lib/toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + promise: vi.fn(), + }, +})) + +function makeUser(overrides: Partial = {}): 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: 2, + onboarding_dismissed: false, + ...overrides, + } +} + +function makeAccount(overrides: Partial = {}): 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( + + + } /> + dashboard} /> + + , + ) +} + +describe('WelcomeStep3', () => { + beforeEach(() => { + useAuthStore.setState({ + user: makeUser(), + account: makeAccount(), + subscription: null, + token: null, + isAuthenticated: true, + fetchUser: vi.fn().mockResolvedValue(undefined), + }) + vi.mocked(onboardingApi.updateStep).mockResolvedValue({ + onboarding_step_completed: 3, + onboarding_dismissed: false, + }) + vi.mocked(onboardingApi.dismissRest).mockResolvedValue({ + onboarding_step_completed: null, + onboarding_dismissed: true, + }) + vi.mocked(accountsApi.bulkInvite).mockResolvedValue({ + created: [], + failed: [], + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('valid emails create invites and complete wizard', async () => { + const user = userEvent.setup() + vi.mocked(accountsApi.bulkInvite).mockResolvedValueOnce({ + created: [ + { + id: 'inv-1', + account_id: 'acct-1', + email: 'a@example.com', + role: 'engineer', + code: 'c1', + expires_at: null, + used_at: null, + created_at: '2026-05-06T00:00:00Z', + }, + { + id: 'inv-2', + account_id: 'acct-1', + email: 'b@example.com', + role: 'viewer', + code: 'c2', + expires_at: null, + used_at: null, + created_at: '2026-05-06T00:00:00Z', + }, + ], + failed: [], + }) + renderPage() + + await user.type(screen.getByTestId('welcome-step-3-email-0'), 'a@example.com') + await user.type(screen.getByTestId('welcome-step-3-email-1'), 'b@example.com') + await user.selectOptions(screen.getByTestId('welcome-step-3-role-1'), 'viewer') + + await user.click(screen.getByTestId('welcome-step-3-send')) + + await waitFor(() => { + expect(accountsApi.bulkInvite).toHaveBeenCalledWith([ + { email: 'a@example.com', role: 'engineer' }, + { email: 'b@example.com', role: 'viewer' }, + ]) + }) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 3, + action: 'complete', + }) + }) + + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) + + it('partial-failure shows inline error per failed email', async () => { + const user = userEvent.setup() + vi.mocked(accountsApi.bulkInvite).mockResolvedValueOnce({ + created: [ + { + id: 'inv-1', + account_id: 'acct-1', + email: 'good@example.com', + role: 'engineer', + code: 'c1', + expires_at: null, + used_at: null, + created_at: '2026-05-06T00:00:00Z', + }, + ], + failed: [ + { email: 'bad@example.com', error: 'Email already invited' }, + ], + }) + renderPage() + + await user.type(screen.getByTestId('welcome-step-3-email-0'), 'good@example.com') + await user.type(screen.getByTestId('welcome-step-3-email-1'), 'bad@example.com') + + await user.click(screen.getByTestId('welcome-step-3-send')) + + await waitFor(() => { + expect(accountsApi.bulkInvite).toHaveBeenCalled() + }) + + // The bad-email row shows the error text. + await waitFor(() => { + expect(screen.getByTestId('welcome-step-3-row-error-1')).toHaveTextContent( + /already invited/i, + ) + }) + + // Wizard did NOT auto-advance — onboarding-step is unchanged. + expect(onboardingApi.updateStep).not.toHaveBeenCalled() + expect(screen.queryByText('dashboard')).not.toBeInTheDocument() + + // "Continue anyway" is offered. + expect(screen.getByTestId('welcome-step-3-continue-anyway')).toBeInTheDocument() + }) + + it('empty + Skip advances without sending invites', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByTestId('welcome-step-3-skip')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 3, + action: 'skip', + }) + }) + + // No bulk-invite call. + expect(accountsApi.bulkInvite).not.toHaveBeenCalled() + + await waitFor(() => { + expect(screen.getByText('dashboard')).toBeInTheDocument() + }) + }) + + it('empty + Send is a no-op bulk call but still completes the step', async () => { + const user = userEvent.setup() + renderPage() + + // All rows blank — Send should skip the bulk call entirely and just + // mark the step complete. + await user.click(screen.getByTestId('welcome-step-3-send')) + + await waitFor(() => { + expect(onboardingApi.updateStep).toHaveBeenCalledWith({ + step: 3, + action: 'complete', + }) + }) + expect(accountsApi.bulkInvite).not.toHaveBeenCalled() + }) + + it('+ Add another adds a row, capped at 10', async () => { + const user = userEvent.setup() + renderPage() + + // Starts with 3 default rows. + expect(screen.getByTestId('welcome-step-3-email-0')).toBeInTheDocument() + expect(screen.getByTestId('welcome-step-3-email-1')).toBeInTheDocument() + expect(screen.getByTestId('welcome-step-3-email-2')).toBeInTheDocument() + expect(screen.queryByTestId('welcome-step-3-email-3')).not.toBeInTheDocument() + + const addBtn = screen.getByTestId('welcome-step-3-add-row') + // Click 7 more times → 10 total. + for (let i = 0; i < 7; i++) await user.click(addBtn) + expect(screen.getByTestId('welcome-step-3-email-9')).toBeInTheDocument() + // Capped — button disabled at 10. + expect(addBtn).toBeDisabled() + }) +}) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 423820a5..235ef710 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -70,6 +70,8 @@ const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsP // Welcome wizard (Phase 2) const WelcomeRouter = lazyWithRetry(() => import('@/pages/welcome/WelcomeRouter')) const WelcomeStep1 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep1')) +const WelcomeStep2 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep2')) +const WelcomeStep3 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep3')) const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams')) const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor')) // Admin pages @@ -247,6 +249,8 @@ export const router = sentryCreateBrowserRouter([ // verification banner persists above each step. { path: 'welcome', element: page(WelcomeRouter) }, { path: 'welcome/step-1', element: page(WelcomeStep1) }, + { path: 'welcome/step-2', element: page(WelcomeStep2) }, + { path: 'welcome/step-3', element: page(WelcomeStep3) }, // Admin routes { path: 'admin',