From 0c326d0616c623150bb05c447e08be4174a2e4b8 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 23:19:58 -0400 Subject: [PATCH] feat(dashboard): replace checklist with next-step card + unified list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 Task 41 — Dashboard redesign. Backend: - Extend GET /users/onboarding-status with email_verified and shop_setup_done. - tried_ai_assistant kept in payload for backward-compat during deploy. Frontend: - New NextStepCard: surfaces the highest-priority incomplete onboarding item with a primary CTA. Priority order: verify email > set up shop > run first FlowPilot session > connect PSA > invite teammate > pick a plan (gated on trial stage warning/urgent/expired). Returns null when all done OR onboarding_dismissed. - New SetupChecklist: unified single list (no SOLO/TEAM bifurcation), drops the stale tried_ai_assistant / Script Builder item, surfaces "Pick a plan" when trial stage is warning or later. - Mounted on QuickStartPage below the hero with a "Show all setup steps" toggle. The whole onboarding section auto-hides when there's nothing left to nudge on, so the dashboard goes back to clean once setup is done. - Removed the orphaned OnboardingChecklist component (was defined but never mounted). - New useOnboardingStatus hook so page + components share one fetch contract. Tests: - Backend: test_onboarding_status_includes_email_verified_and_shop_setup_done. - Frontend (Vitest): 13 new tests across NextStepCard, SetupChecklist, and QuickStartPage covering priority ordering, dismissal, the SOLO/TEAM removal, the toggle reveal, and the trial-stage gate on Pick a plan. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/endpoints/onboarding.py | 6 + backend/app/schemas/onboarding.py | 4 + backend/tests/test_onboarding.py | 41 +++++ frontend/src/api/onboarding.ts | 4 + .../src/components/dashboard/NextStepCard.tsx | 169 ++++++++++++++++++ .../dashboard/OnboardingChecklist.tsx | 160 ----------------- .../components/dashboard/SetupChecklist.tsx | 136 ++++++++++++++ .../dashboard/__tests__/NextStepCard.test.tsx | 148 +++++++++++++++ .../__tests__/SetupChecklist.test.tsx | 123 +++++++++++++ frontend/src/hooks/useOnboardingStatus.ts | 27 +++ frontend/src/pages/QuickStartPage.tsx | 39 ++++ .../pages/__tests__/QuickStartPage.test.tsx | 141 +++++++++++++++ 12 files changed, 838 insertions(+), 160 deletions(-) create mode 100644 frontend/src/components/dashboard/NextStepCard.tsx delete mode 100644 frontend/src/components/dashboard/OnboardingChecklist.tsx create mode 100644 frontend/src/components/dashboard/SetupChecklist.tsx create mode 100644 frontend/src/components/dashboard/__tests__/NextStepCard.test.tsx create mode 100644 frontend/src/components/dashboard/__tests__/SetupChecklist.test.tsx create mode 100644 frontend/src/hooks/useOnboardingStatus.ts create mode 100644 frontend/src/pages/__tests__/QuickStartPage.test.tsx diff --git a/backend/app/api/endpoints/onboarding.py b/backend/app/api/endpoints/onboarding.py index 4cecf091..d348545d 100644 --- a/backend/app/api/endpoints/onboarding.py +++ b/backend/app/api/endpoints/onboarding.py @@ -90,6 +90,10 @@ async def get_onboarding_status( ) connected_psa = (psa_q.scalar() or 0) > 0 + # New (Phase 2 — Task 41) + email_verified = current_user.email_verified_at is not None + shop_setup_done = (current_user.onboarding_step_completed or 0) >= 1 + return OnboardingStatus( created_flow=created_flow, ran_session=ran_session, @@ -99,6 +103,8 @@ async def get_onboarding_status( connected_psa=connected_psa, is_team_user=is_team_user, dismissed=current_user.onboarding_dismissed, + email_verified=email_verified, + shop_setup_done=shop_setup_done, ) diff --git a/backend/app/schemas/onboarding.py b/backend/app/schemas/onboarding.py index 303e1ceb..e6dd1329 100644 --- a/backend/app/schemas/onboarding.py +++ b/backend/app/schemas/onboarding.py @@ -7,11 +7,15 @@ class OnboardingStatus(BaseModel): created_flow: bool ran_session: bool exported_session: bool + # Kept for backward-compat during deploy; new code paths should not branch on this. tried_ai_assistant: bool invited_teammate: bool connected_psa: bool is_team_user: bool dismissed: bool + # New (Phase 2 — Task 41) — drive the unified next-step card + checklist. + email_verified: bool + shop_setup_done: bool # --- Welcome wizard (Phase 2) ---------------------------------------------- diff --git a/backend/tests/test_onboarding.py b/backend/tests/test_onboarding.py index aa4f48d8..72ea53c5 100644 --- a/backend/tests/test_onboarding.py +++ b/backend/tests/test_onboarding.py @@ -1,6 +1,11 @@ """Tests for onboarding status endpoints.""" +from datetime import datetime, timezone + import pytest +from sqlalchemy import select + +from app.models.user import User @pytest.mark.asyncio @@ -21,6 +26,42 @@ async def test_onboarding_status_fresh_user(client, auth_headers): assert data["connected_psa"] is False assert data["is_team_user"] is False assert data["dismissed"] is False + # Phase 2 fields default to false on a fresh, unverified user with no wizard progress. + assert data["email_verified"] is False + assert data["shop_setup_done"] is False + + +@pytest.mark.asyncio +async def test_onboarding_status_includes_email_verified_and_shop_setup_done( + client, auth_headers, test_user, test_db +): + """email_verified flips when email_verified_at is set; shop_setup_done flips at step >= 1.""" + # Sanity-check baseline. + response = await client.get( + "/api/v1/users/onboarding-status", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["email_verified"] is False + assert data["shop_setup_done"] is False + + # Mutate the underlying user, then re-fetch. + user_email = test_user["email"] + result = await test_db.execute(select(User).where(User.email == user_email)) + user = result.scalar_one() + user.email_verified_at = datetime.now(tz=timezone.utc) + user.onboarding_step_completed = 1 + await test_db.commit() + + response = await client.get( + "/api/v1/users/onboarding-status", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["email_verified"] is True + assert data["shop_setup_done"] is True @pytest.mark.asyncio diff --git a/frontend/src/api/onboarding.ts b/frontend/src/api/onboarding.ts index f5caa8f3..aa785bfd 100644 --- a/frontend/src/api/onboarding.ts +++ b/frontend/src/api/onboarding.ts @@ -4,11 +4,15 @@ export interface OnboardingStatus { created_flow: boolean ran_session: boolean exported_session: boolean + /** @deprecated Phase 2 — kept for backward-compat. New UI no longer branches on this. */ tried_ai_assistant: boolean invited_teammate: boolean connected_psa: boolean is_team_user: boolean dismissed: boolean + // Phase 2 (Task 41) — drive the unified next-step card + checklist. + email_verified: boolean + shop_setup_done: boolean } export async function getOnboardingStatus(): Promise { diff --git a/frontend/src/components/dashboard/NextStepCard.tsx b/frontend/src/components/dashboard/NextStepCard.tsx new file mode 100644 index 00000000..646e2865 --- /dev/null +++ b/frontend/src/components/dashboard/NextStepCard.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { ArrowRight, X } from 'lucide-react' +import { dismissOnboarding } from '@/api/onboarding' +import type { OnboardingStatus } from '@/api/onboarding' +import { useTrialBanner } from '@/hooks/useTrialBanner' +import type { TrialBannerStage } from '@/hooks/useTrialBanner' +import { useOnboardingStatus } from '@/hooks/useOnboardingStatus' + +/** + * Next-step card — surfaces the single highest-priority incomplete onboarding + * item with a primary CTA. Replaces the old multi-item `OnboardingChecklist` + * widget at the top of the dashboard. + * + * `useOnboardingStatusQuery` is exported as a tiny shared hook so the parent + * page can decide whether to render the surrounding "Show all setup steps" + * toggle without duplicating the fetch. + * + * Returns `null` when: + * - status hasn't loaded yet + * - `status.dismissed` is true + * - all items are complete + * + * Priority order (first incomplete wins): + * 1. Verify your email + * 2. Set up your shop + * 3. Run your first FlowPilot session + * 4. Connect your PSA + * 5. Invite a teammate + * 6. Pick a plan (only when trial stage is warning / urgent / expired) + */ + +export interface NextStepItem { + /** Stable id used in tests + analytics. */ + key: string + title: string + description: string + ctaLabel: string + ctaPath: string +} + +const PLAN_GATE_STAGES: ReadonlyArray = [ + 'warning', + 'urgent', + 'expired', +] + +/** + * Pure helper — picks the highest-priority incomplete item, or `null` when + * all relevant items are done. Exported for direct unit testing. + */ +export function pickNextStep( + status: OnboardingStatus, + trialStage: TrialBannerStage | null, +): NextStepItem | null { + if (!status.email_verified) { + return { + key: 'verify_email', + title: 'Verify your email', + description: 'Confirm your address to keep your account active after the grace period.', + ctaLabel: 'Verify email', + ctaPath: '/verify-email', + } + } + if (!status.shop_setup_done) { + return { + key: 'shop_setup', + title: 'Set up your shop', + description: 'Tell us a bit about your team so ResolutionFlow can tailor itself.', + ctaLabel: 'Set up shop', + ctaPath: '/welcome/step-1', + } + } + if (!status.ran_session) { + return { + key: 'ran_session', + title: 'Run your first FlowPilot session', + description: 'Paste a ticket or pick a flow to see ResolutionFlow in action.', + ctaLabel: 'Start a session', + ctaPath: '/', + } + } + if (!status.connected_psa) { + return { + key: 'connected_psa', + title: 'Connect your PSA', + description: 'Sync tickets from ConnectWise, Autotask, or HaloPSA.', + ctaLabel: 'Connect PSA', + ctaPath: '/account/integrations', + } + } + if (!status.invited_teammate) { + return { + key: 'invited_teammate', + title: 'Invite a teammate', + description: 'ResolutionFlow gets stronger when your whole team is on it.', + ctaLabel: 'Invite teammate', + ctaPath: '/account', + } + } + if (trialStage && PLAN_GATE_STAGES.includes(trialStage)) { + return { + key: 'pick_plan', + title: 'Pick a plan', + description: 'Your trial is wrapping up — pick a plan to keep using ResolutionFlow.', + ctaLabel: 'Pick a plan', + ctaPath: '/account/billing/select-plan', + } + } + return null +} + +export function NextStepCard() { + const status = useOnboardingStatus() + const [locallyDismissed, setLocallyDismissed] = useState(false) + const { stage } = useTrialBanner() + + if (!status || status.dismissed || locallyDismissed) return null + + const next = pickNextStep(status, stage) + if (!next) return null + + const handleDismiss = async () => { + setLocallyDismissed(true) + try { + await dismissOnboarding() + } catch { + // Already hidden locally — best-effort persist. + } + } + + return ( +
+
+
+

+ Next step +

+

{next.title}

+

{next.description}

+
+ +
+
+ + {next.ctaLabel} + + +
+
+ ) +} + +export default NextStepCard diff --git a/frontend/src/components/dashboard/OnboardingChecklist.tsx b/frontend/src/components/dashboard/OnboardingChecklist.tsx deleted file mode 100644 index fab062e4..00000000 --- a/frontend/src/components/dashboard/OnboardingChecklist.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useState, useEffect } from 'react' -import { useNavigate } from 'react-router-dom' -import { Check, X, ChevronRight } from 'lucide-react' -import { cn } from '@/lib/utils' -import { getOnboardingStatus, dismissOnboarding } from '@/api/onboarding' -import type { OnboardingStatus } from '@/api/onboarding' - -interface ChecklistItem { - key: keyof OnboardingStatus - label: string - path: string -} - -const SOLO_ITEMS: ChecklistItem[] = [ - { key: 'ran_session', label: 'Try troubleshooting a ticket', path: '/' }, - { key: 'exported_session', label: 'Review your session notes', path: '/sessions' }, - { key: 'created_flow', label: 'Explore guided flows', path: '/trees' }, - { key: 'tried_ai_assistant', label: 'Check out the Script Builder', path: '/script-builder' }, -] - -const TEAM_ITEMS: ChecklistItem[] = [ - { key: 'ran_session', label: 'Try troubleshooting a ticket', path: '/' }, - { key: 'exported_session', label: 'Review your session notes', path: '/sessions' }, - { key: 'invited_teammate', label: 'Invite a team member', path: '/account' }, - { key: 'created_flow', label: 'Explore guided flows', path: '/trees' }, - { key: 'connected_psa', label: 'Connect your PSA', path: '/account/integrations' }, -] - -export function OnboardingChecklist() { - const navigate = useNavigate() - const [status, setStatus] = useState(null) - const [dismissed, setDismissed] = useState(false) - const [allComplete, setAllComplete] = useState(false) - - useEffect(() => { - getOnboardingStatus() - .then(setStatus) - .catch(() => { - // Silently fail — don't show checklist if endpoint unavailable - }) - }, []) - - const items = status?.is_team_user ? TEAM_ITEMS : SOLO_ITEMS - const completedCount = status - ? items.filter((item) => status[item.key]).length - : 0 - const totalCount = items.length - const isAllDone = completedCount === totalCount && status !== null - - useEffect(() => { - if (isAllDone) { - const timer = setTimeout(() => setAllComplete(true), 2000) - return () => clearTimeout(timer) - } - }, [isAllDone]) - - // Don't render if dismissed, fully complete, or not loaded yet - if (!status || status.dismissed || dismissed || allComplete) return null - - const progressPercent = totalCount > 0 ? (completedCount / totalCount) * 100 : 0 - - const handleDismiss = async () => { - setDismissed(true) - try { - await dismissOnboarding() - } catch { - // Already hidden locally - } - } - - return ( -
- {/* Progress bar */} -
-
-
- -
- {/* Header */} -
-
-

- Getting Started -

-

- {isAllDone ? ( - You're all set! - ) : ( - - {completedCount} - {' '}of {totalCount} complete - - )} -

-
- -
- - {/* Checklist items */} -
    - {items.map((item) => { - const done = status[item.key] - return ( -
  • - -
  • - ) - })} -
-
-
- ) -} diff --git a/frontend/src/components/dashboard/SetupChecklist.tsx b/frontend/src/components/dashboard/SetupChecklist.tsx new file mode 100644 index 00000000..0f29a4e6 --- /dev/null +++ b/frontend/src/components/dashboard/SetupChecklist.tsx @@ -0,0 +1,136 @@ +import { Link } from 'react-router-dom' +import { Check, ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { OnboardingStatus } from '@/api/onboarding' +import { useTrialBanner } from '@/hooks/useTrialBanner' +import type { TrialBannerStage } from '@/hooks/useTrialBanner' +import { useOnboardingStatus } from '@/hooks/useOnboardingStatus' + +/** + * Unified setup checklist — single list (no SOLO/TEAM bifurcation). + * + * Replaces the old `OnboardingChecklist` widget. Items match `NextStepCard`'s + * priority order. The "Pick a plan" item is gated on the trial stage. + * + * Surfaced behind a "Show all setup steps" toggle on the dashboard so the + * always-visible surface is just the single next-step card. + */ + +interface ChecklistItem { + key: string + label: string + path: string + done: boolean +} + +const PLAN_GATE_STAGES: ReadonlyArray = [ + 'warning', + 'urgent', + 'expired', +] + +export function buildChecklistItems( + status: OnboardingStatus, + trialStage: TrialBannerStage | null, +): ChecklistItem[] { + const items: ChecklistItem[] = [ + { + key: 'verify_email', + label: 'Verify your email', + path: '/verify-email', + done: status.email_verified, + }, + { + key: 'shop_setup', + label: 'Set up your shop', + path: '/welcome/step-1', + done: status.shop_setup_done, + }, + { + key: 'ran_session', + label: 'Run your first FlowPilot session', + path: '/', + done: status.ran_session, + }, + { + key: 'connected_psa', + label: 'Connect your PSA', + path: '/account/integrations', + done: status.connected_psa, + }, + { + key: 'invited_teammate', + label: 'Invite a teammate', + path: '/account', + done: status.invited_teammate, + }, + ] + + if (trialStage && PLAN_GATE_STAGES.includes(trialStage)) { + items.push({ + key: 'pick_plan', + label: 'Pick a plan', + path: '/account/billing/select-plan', + done: false, + }) + } + + return items +} + +export function SetupChecklist() { + const status = useOnboardingStatus() + const { stage } = useTrialBanner() + + if (!status || status.dismissed) return null + + const items = buildChecklistItems(status, stage) + const completedCount = items.filter((i) => i.done).length + const totalCount = items.length + + return ( +
+
+

+ Setup steps · {completedCount} of {totalCount} +

+
+
    + {items.map((item) => ( +
  • + {item.done ? ( +
    + + + + + {item.label} + +
    + ) : ( + + + {item.label} + + + )} +
  • + ))} +
+
+ ) +} + +export default SetupChecklist diff --git a/frontend/src/components/dashboard/__tests__/NextStepCard.test.tsx b/frontend/src/components/dashboard/__tests__/NextStepCard.test.tsx new file mode 100644 index 00000000..c7efdfc6 --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/NextStepCard.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { NextStepCard, pickNextStep } from '../NextStepCard' +import { useBillingStore } from '@/store/billingStore' +import type { OnboardingStatus } from '@/api/onboarding' + +vi.mock('@/api/onboarding', () => { + const mockGet = vi.fn() + const mockDismiss = vi.fn() + return { + getOnboardingStatus: mockGet, + dismissOnboarding: mockDismiss, + } +}) + +import { + getOnboardingStatus as _getOnboardingStatus, +} from '@/api/onboarding' + +const getOnboardingStatus = _getOnboardingStatus as unknown as ReturnType + +function makeStatus(overrides: Partial = {}): OnboardingStatus { + return { + created_flow: false, + ran_session: false, + exported_session: false, + tried_ai_assistant: false, + invited_teammate: false, + connected_psa: false, + is_team_user: false, + dismissed: false, + email_verified: false, + shop_setup_done: false, + ...overrides, + } +} + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}) +} + +function setBillingComplimentary() { + // 'complimentary' status -> stage 'complimentary' (not in plan-gate set), so the + // "Pick a plan" item stays hidden — perfect default for unrelated tests. + useBillingStore.setState({ + subscription: { + status: 'complimentary', + plan: 'pro', + current_period_start: '2026-05-01T00:00:00Z', + current_period_end: null, + cancel_at_period_end: false, + seat_limit: null, + has_pro_entitlement: true, + is_paid: true, + }, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) +} + +describe('NextStepCard', () => { + beforeEach(() => { + getOnboardingStatus.mockReset() + setBillingComplimentary() + }) + + it('renders Verify your email when email unverified', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus({ email_verified: false })) + renderWithRouter() + await waitFor(() => { + expect(screen.getByTestId('next-step-card')).toBeInTheDocument() + }) + expect(screen.getByRole('heading', { name: /Verify your email/i })).toBeInTheDocument() + }) + + it('renders Set up your shop after email verified', async () => { + getOnboardingStatus.mockResolvedValue( + makeStatus({ email_verified: true, shop_setup_done: false }), + ) + renderWithRouter() + await waitFor(() => { + expect(screen.getByRole('heading', { name: /Set up your shop/i })).toBeInTheDocument() + }) + }) + + it('renders Run your first FlowPilot session after shop setup', async () => { + getOnboardingStatus.mockResolvedValue( + makeStatus({ + email_verified: true, + shop_setup_done: true, + ran_session: false, + }), + ) + renderWithRouter() + await waitFor(() => { + expect( + screen.getByRole('heading', { name: /Run your first FlowPilot session/i }), + ).toBeInTheDocument() + }) + }) + + it('hidden when all items done', async () => { + getOnboardingStatus.mockResolvedValue( + makeStatus({ + email_verified: true, + shop_setup_done: true, + ran_session: true, + connected_psa: true, + invited_teammate: true, + }), + ) + const { container } = renderWithRouter() + // Resolve the awaited promise. + await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled()) + expect(container.querySelector('[data-testid="next-step-card"]')).toBeNull() + }) + + it('hidden when onboarding_dismissed', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus({ dismissed: true })) + const { container } = renderWithRouter() + await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled()) + expect(container.querySelector('[data-testid="next-step-card"]')).toBeNull() + }) + + it('Pick a plan item appears when trial stage is warning or later', () => { + // Direct unit-test on the pure picker — easier than coordinating both the + // billing store + the network mock + a fake clock for stage='warning'. + const allDoneExceptPlan = makeStatus({ + email_verified: true, + shop_setup_done: true, + ran_session: true, + connected_psa: true, + invited_teammate: true, + }) + + expect(pickNextStep(allDoneExceptPlan, 'pristine')).toBeNull() + expect(pickNextStep(allDoneExceptPlan, 'paid')).toBeNull() + expect(pickNextStep(allDoneExceptPlan, 'complimentary')).toBeNull() + + expect(pickNextStep(allDoneExceptPlan, 'warning')?.key).toBe('pick_plan') + expect(pickNextStep(allDoneExceptPlan, 'urgent')?.key).toBe('pick_plan') + expect(pickNextStep(allDoneExceptPlan, 'expired')?.key).toBe('pick_plan') + }) +}) diff --git a/frontend/src/components/dashboard/__tests__/SetupChecklist.test.tsx b/frontend/src/components/dashboard/__tests__/SetupChecklist.test.tsx new file mode 100644 index 00000000..2534ce7f --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/SetupChecklist.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { SetupChecklist, buildChecklistItems } from '../SetupChecklist' +import { useBillingStore } from '@/store/billingStore' +import type { OnboardingStatus } from '@/api/onboarding' + +vi.mock('@/api/onboarding', () => { + const mockGet = vi.fn() + return { + getOnboardingStatus: mockGet, + dismissOnboarding: vi.fn(), + } +}) + +import { getOnboardingStatus as _getOnboardingStatus } from '@/api/onboarding' +const getOnboardingStatus = _getOnboardingStatus as unknown as ReturnType + +function makeStatus(overrides: Partial = {}): OnboardingStatus { + return { + created_flow: false, + ran_session: false, + exported_session: false, + tried_ai_assistant: false, + invited_teammate: false, + connected_psa: false, + is_team_user: false, + dismissed: false, + email_verified: false, + shop_setup_done: false, + ...overrides, + } +} + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}) +} + +function setBillingComplimentary() { + useBillingStore.setState({ + subscription: { + status: 'complimentary', + plan: 'pro', + current_period_start: '2026-05-01T00:00:00Z', + current_period_end: null, + cancel_at_period_end: false, + seat_limit: null, + has_pro_entitlement: true, + is_paid: true, + }, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) +} + +describe('SetupChecklist', () => { + beforeEach(() => { + getOnboardingStatus.mockReset() + setBillingComplimentary() + }) + + it('renders unified list with no SOLO/TEAM headers', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus()) + renderWithRouter() + await waitFor(() => { + expect(screen.getByTestId('setup-checklist')).toBeInTheDocument() + }) + // Single unified list — no team/solo section dividers (the old component had + // separate SOLO_ITEMS / TEAM_ITEMS branches; the new one is one flat list). + expect(screen.queryByText(/^SOLO$/)).toBeNull() + expect(screen.queryByText(/^TEAM$/)).toBeNull() + expect(screen.queryByText(/Solo users/i)).toBeNull() + expect(screen.queryByText(/Team users/i)).toBeNull() + + // Core items present. + expect(screen.getByText(/Verify your email/i)).toBeInTheDocument() + expect(screen.getByText(/Set up your shop/i)).toBeInTheDocument() + expect(screen.getByText(/Run your first FlowPilot session/i)).toBeInTheDocument() + expect(screen.getByText(/Connect your PSA/i)).toBeInTheDocument() + expect(screen.getByText(/Invite a teammate/i)).toBeInTheDocument() + }) + + it('does NOT include the stale tried_ai_assistant / Script Builder item', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus()) + renderWithRouter() + await waitFor(() => { + expect(screen.getByTestId('setup-checklist')).toBeInTheDocument() + }) + expect(screen.queryByText(/Script Builder/i)).toBeNull() + expect(screen.queryByText(/AI Assistant/i)).toBeNull() + }) + + it('hidden when onboarding_dismissed', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus({ dismissed: true })) + const { container } = renderWithRouter() + await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled()) + expect(container.querySelector('[data-testid="setup-checklist"]')).toBeNull() + }) + + describe('buildChecklistItems', () => { + it('does not include "Pick a plan" when stage is pristine', () => { + const items = buildChecklistItems(makeStatus(), 'pristine') + expect(items.find((i) => i.key === 'pick_plan')).toBeUndefined() + }) + + it('includes "Pick a plan" when stage is warning', () => { + const items = buildChecklistItems(makeStatus(), 'warning') + expect(items.find((i) => i.key === 'pick_plan')).toBeDefined() + }) + + it('includes "Pick a plan" when stage is urgent or expired', () => { + expect( + buildChecklistItems(makeStatus(), 'urgent').find((i) => i.key === 'pick_plan'), + ).toBeDefined() + expect( + buildChecklistItems(makeStatus(), 'expired').find((i) => i.key === 'pick_plan'), + ).toBeDefined() + }) + }) +}) diff --git a/frontend/src/hooks/useOnboardingStatus.ts b/frontend/src/hooks/useOnboardingStatus.ts new file mode 100644 index 00000000..3b7ff9c4 --- /dev/null +++ b/frontend/src/hooks/useOnboardingStatus.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react' +import { getOnboardingStatus } from '@/api/onboarding' +import type { OnboardingStatus } from '@/api/onboarding' + +/** + * Tiny shared hook that fetches `/users/onboarding-status` once on mount. + * + * Used by `NextStepCard`, `SetupChecklist`, and `QuickStartPage` so the toggle + * row can disappear when there's nothing to show. Each consumer has its own + * state — fetches are not deduplicated. That's fine for now; if it becomes a + * problem we can lift this into a Zustand store or react-query. + */ +export function useOnboardingStatus(): OnboardingStatus | null { + const [status, setStatus] = useState(null) + + useEffect(() => { + getOnboardingStatus() + .then(setStatus) + .catch(() => { + // Silently fail — never block the dashboard if the endpoint is down. + }) + }, []) + + return status +} + +export default useOnboardingStatus diff --git a/frontend/src/pages/QuickStartPage.tsx b/frontend/src/pages/QuickStartPage.tsx index 5c911f5a..bea8e2a5 100644 --- a/frontend/src/pages/QuickStartPage.tsx +++ b/frontend/src/pages/QuickStartPage.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { PageMeta } from '@/components/common/PageMeta' import { useAuthStore } from '@/store/authStore' import { StartSessionInput } from '@/components/dashboard/StartSessionInput' @@ -7,6 +8,10 @@ import { TicketQueue } from '@/components/dashboard/TicketQueue' import { PerformanceCards } from '@/components/dashboard/PerformanceCards' import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards' import { TeamSummary } from '@/components/dashboard/TeamSummary' +import { NextStepCard, pickNextStep } from '@/components/dashboard/NextStepCard' +import { SetupChecklist } from '@/components/dashboard/SetupChecklist' +import { useOnboardingStatus } from '@/hooks/useOnboardingStatus' +import { useTrialBanner } from '@/hooks/useTrialBanner' function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) { return ( @@ -22,6 +27,17 @@ function SectionLabel({ children, action }: { children: React.ReactNode; action? export function QuickStartPage() { const user = useAuthStore((s) => s.user) + const [showAllSetupSteps, setShowAllSetupSteps] = useState(false) + const onboardingStatus = useOnboardingStatus() + const { stage: trialStage } = useTrialBanner() + + // Onboarding section is visible when there's still something to nudge on. + // We check the same priority list NextStepCard uses so the toggle row + // disappears cleanly once everything is done OR the user dismissed. + const onboardingVisible = + onboardingStatus !== null && + !onboardingStatus.dismissed && + pickNextStep(onboardingStatus, trialStage) !== null const now = new Date() const greeting = now.getHours() < 12 @@ -47,6 +63,29 @@ export function QuickStartPage() {
+ {/* Next-step card — surfaces a single onboarding nudge below the hero. */} + {onboardingVisible && ( +
+ +
+ +
+ {showAllSetupSteps && ( +
+ +
+ )} +
+ )} + {/* Chat-style input */} diff --git a/frontend/src/pages/__tests__/QuickStartPage.test.tsx b/frontend/src/pages/__tests__/QuickStartPage.test.tsx new file mode 100644 index 00000000..90a93a7f --- /dev/null +++ b/frontend/src/pages/__tests__/QuickStartPage.test.tsx @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import type { OnboardingStatus } from '@/api/onboarding' +import { useAuthStore } from '@/store/authStore' +import { useBillingStore } from '@/store/billingStore' + +// Mock heavy dashboard children — they pull in axios + zustand stores we +// don't care about for this toggle test. +vi.mock('@/components/dashboard/StartSessionInput', () => ({ + StartSessionInput: () =>
, +})) +vi.mock('@/components/dashboard/PendingEscalations', () => ({ + PendingEscalations: () => null, +})) +vi.mock('@/components/dashboard/ActiveFlowPilotSessions', () => ({ + ActiveFlowPilotSessions: () => null, +})) +vi.mock('@/components/dashboard/TicketQueue', () => ({ + TicketQueue: () => null, +})) +vi.mock('@/components/dashboard/PerformanceCards', () => ({ + PerformanceCards: () => null, +})) +vi.mock('@/components/dashboard/KnowledgeBaseCards', () => ({ + KnowledgeBaseCards: () => null, +})) +vi.mock('@/components/dashboard/TeamSummary', () => ({ + TeamSummary: () => null, +})) + +vi.mock('@/api/onboarding', () => { + const mockGet = vi.fn() + return { + getOnboardingStatus: mockGet, + dismissOnboarding: vi.fn(), + } +}) + +import { QuickStartPage } from '../QuickStartPage' +import { getOnboardingStatus as _getOnboardingStatus } from '@/api/onboarding' + +const getOnboardingStatus = _getOnboardingStatus as unknown as ReturnType + +function makeStatus(overrides: Partial = {}): OnboardingStatus { + return { + created_flow: false, + ran_session: false, + exported_session: false, + tried_ai_assistant: false, + invited_teammate: false, + connected_psa: false, + is_team_user: false, + dismissed: false, + email_verified: true, // skip past verify so the next-step card is not the noisy thing here. + shop_setup_done: false, + ...overrides, + } +} + +describe('QuickStartPage', () => { + beforeEach(() => { + getOnboardingStatus.mockReset() + useAuthStore.setState({ + user: { + id: 'u-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: 'engineer', + 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: '2026-05-01T00:00:00Z', + }, + token: 'tok', + isAuthenticated: true, + }) + useBillingStore.setState({ + subscription: { + status: 'complimentary', + plan: 'pro', + current_period_start: '2026-05-01T00:00:00Z', + current_period_end: null, + cancel_at_period_end: false, + seat_limit: null, + has_pro_entitlement: true, + is_paid: true, + }, + planBilling: null, + planLimits: {}, + enabledFeatures: {}, + isLoading: false, + error: null, + }) + }) + + it('Show all setup steps toggle reveals unified checklist with no SOLO/TEAM headers', async () => { + getOnboardingStatus.mockResolvedValue(makeStatus()) + + render( + + + , + ) + + // Wait for initial fetch. + await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled()) + + // Checklist is hidden by default. + expect(screen.queryByTestId('setup-checklist')).toBeNull() + + // Toggle visible. + const toggle = screen.getByTestId('toggle-setup-checklist') + expect(toggle).toHaveTextContent(/Show all setup steps/i) + + fireEvent.click(toggle) + + // Checklist now rendered. (`SetupChecklist` runs its own fetch — same mock.) + await waitFor(() => { + expect(screen.getByTestId('setup-checklist')).toBeInTheDocument() + }) + + // No SOLO/TEAM section headers in the unified list. + expect(screen.queryByText(/^SOLO$/)).toBeNull() + expect(screen.queryByText(/^TEAM$/)).toBeNull() + expect(screen.queryByText(/Solo users/i)).toBeNull() + expect(screen.queryByText(/Team users/i)).toBeNull() + + // Toggle label flips after clicking. + expect(toggle).toHaveTextContent(/Hide setup steps/i) + }) +})