feat(dashboard): replace checklist with next-step card + unified list

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 23:19:58 -04:00
parent 99343ab7a9
commit 0c326d0616
12 changed files with 838 additions and 160 deletions

View File

@@ -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,
)

View File

@@ -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) ----------------------------------------------

View File

@@ -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

View File

@@ -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<OnboardingStatus> {

View File

@@ -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<TrialBannerStage> = [
'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 (
<div
className="card-interactive overflow-hidden p-4 fade-in"
data-testid="next-step-card"
style={{ animationDelay: '150ms' }}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
Next step
</p>
<h3 className="mt-1 text-base font-semibold text-foreground">{next.title}</h3>
<p className="mt-1 text-sm text-muted-foreground">{next.description}</p>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss setup prompts"
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors shrink-0"
>
<X size={16} />
</button>
</div>
<div className="mt-3">
<Link
to={next.ctaPath}
data-testid="next-step-cta"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{next.ctaLabel}
<ArrowRight size={14} />
</Link>
</div>
</div>
)
}
export default NextStepCard

View File

@@ -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<OnboardingStatus | null>(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 (
<div className="card-interactive overflow-hidden fade-in" style={{ animationDelay: '150ms' }}>
{/* Progress bar */}
<div className="h-1 w-full bg-[rgba(255,255,255,0.04)]">
<div
className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="p-4">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Getting Started
</p>
<p className="text-sm text-foreground mt-0.5">
{isAllDone ? (
<span className="text-accent-text font-semibold">You're all set!</span>
) : (
<span>
<span className="text-accent-text font-semibold">{completedCount}</span>
{' '}of {totalCount} complete
</span>
)}
</p>
</div>
<button
onClick={handleDismiss}
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
aria-label="Dismiss onboarding checklist"
>
<X size={16} />
</button>
</div>
{/* Checklist items */}
<ul className="space-y-1">
{items.map((item) => {
const done = status[item.key]
return (
<li key={item.key}>
<button
onClick={() => !done && navigate(item.path)}
disabled={done}
className={cn(
'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
done
? 'cursor-default'
: 'hover:bg-[rgba(255,255,255,0.04)]'
)}
>
{/* Checkbox */}
<span
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors',
done
? 'bg-primary border-transparent'
: 'border-border'
)}
>
{done && <Check size={12} className="text-white" />}
</span>
{/* Label */}
<span
className={cn(
'flex-1',
done
? 'text-muted-foreground line-through'
: 'text-foreground'
)}
>
{item.label}
</span>
{/* Arrow for incomplete items */}
{!done && (
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
)}
</button>
</li>
)
})}
</ul>
</div>
</div>
)
}

View File

@@ -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<TrialBannerStage> = [
'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 (
<div className="card-interactive overflow-hidden" data-testid="setup-checklist">
<div className="px-4 pt-3 pb-2">
<p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
Setup steps · {completedCount} of {totalCount}
</p>
</div>
<ul className="px-2 pb-2 space-y-1">
{items.map((item) => (
<li key={item.key}>
{item.done ? (
<div
className="w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm cursor-default"
data-testid={`checklist-item-${item.key}`}
data-done="true"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-transparent bg-primary">
<Check size={12} className="text-white" />
</span>
<span className="flex-1 text-muted-foreground line-through">
{item.label}
</span>
</div>
) : (
<Link
to={item.path}
className={cn(
'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
'hover:bg-[rgba(255,255,255,0.04)]',
)}
data-testid={`checklist-item-${item.key}`}
data-done="false"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-border" />
<span className="flex-1 text-foreground">{item.label}</span>
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
</Link>
)}
</li>
))}
</ul>
</div>
)
}
export default SetupChecklist

View File

@@ -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<typeof vi.fn>
function makeStatus(overrides: Partial<OnboardingStatus> = {}): 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(<BrowserRouter>{ui}</BrowserRouter>)
}
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(<NextStepCard />)
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(<NextStepCard />)
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(<NextStepCard />)
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(<NextStepCard />)
// 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(<NextStepCard />)
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')
})
})

View File

@@ -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<typeof vi.fn>
function makeStatus(overrides: Partial<OnboardingStatus> = {}): 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(<BrowserRouter>{ui}</BrowserRouter>)
}
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(<SetupChecklist />)
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(<SetupChecklist />)
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(<SetupChecklist />)
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()
})
})
})

View File

@@ -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<OnboardingStatus | null>(null)
useEffect(() => {
getOnboardingStatus()
.then(setStatus)
.catch(() => {
// Silently fail — never block the dashboard if the endpoint is down.
})
}, [])
return status
}
export default useOnboardingStatus

View File

@@ -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() {
</h1>
</div>
{/* Next-step card — surfaces a single onboarding nudge below the hero. */}
{onboardingVisible && (
<div className="mb-6">
<NextStepCard />
<div className="mt-2">
<button
type="button"
onClick={() => setShowAllSetupSteps((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline"
data-testid="toggle-setup-checklist"
aria-expanded={showAllSetupSteps}
>
{showAllSetupSteps ? 'Hide setup steps' : 'Show all setup steps'}
</button>
</div>
{showAllSetupSteps && (
<div className="mt-3">
<SetupChecklist />
</div>
)}
</div>
)}
{/* Chat-style input */}
<StartSessionInput />

View File

@@ -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: () => <div data-testid="mock-start-session" />,
}))
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<typeof vi.fn>
function makeStatus(overrides: Partial<OnboardingStatus> = {}): 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(
<BrowserRouter>
<QuickStartPage />
</BrowserRouter>,
)
// 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)
})
})