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:
@@ -90,6 +90,10 @@ async def get_onboarding_status(
|
|||||||
)
|
)
|
||||||
connected_psa = (psa_q.scalar() or 0) > 0
|
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(
|
return OnboardingStatus(
|
||||||
created_flow=created_flow,
|
created_flow=created_flow,
|
||||||
ran_session=ran_session,
|
ran_session=ran_session,
|
||||||
@@ -99,6 +103,8 @@ async def get_onboarding_status(
|
|||||||
connected_psa=connected_psa,
|
connected_psa=connected_psa,
|
||||||
is_team_user=is_team_user,
|
is_team_user=is_team_user,
|
||||||
dismissed=current_user.onboarding_dismissed,
|
dismissed=current_user.onboarding_dismissed,
|
||||||
|
email_verified=email_verified,
|
||||||
|
shop_setup_done=shop_setup_done,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ class OnboardingStatus(BaseModel):
|
|||||||
created_flow: bool
|
created_flow: bool
|
||||||
ran_session: bool
|
ran_session: bool
|
||||||
exported_session: bool
|
exported_session: bool
|
||||||
|
# Kept for backward-compat during deploy; new code paths should not branch on this.
|
||||||
tried_ai_assistant: bool
|
tried_ai_assistant: bool
|
||||||
invited_teammate: bool
|
invited_teammate: bool
|
||||||
connected_psa: bool
|
connected_psa: bool
|
||||||
is_team_user: bool
|
is_team_user: bool
|
||||||
dismissed: 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) ----------------------------------------------
|
# --- Welcome wizard (Phase 2) ----------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"""Tests for onboarding status endpoints."""
|
"""Tests for onboarding status endpoints."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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["connected_psa"] is False
|
||||||
assert data["is_team_user"] is False
|
assert data["is_team_user"] is False
|
||||||
assert data["dismissed"] 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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ export interface OnboardingStatus {
|
|||||||
created_flow: boolean
|
created_flow: boolean
|
||||||
ran_session: boolean
|
ran_session: boolean
|
||||||
exported_session: boolean
|
exported_session: boolean
|
||||||
|
/** @deprecated Phase 2 — kept for backward-compat. New UI no longer branches on this. */
|
||||||
tried_ai_assistant: boolean
|
tried_ai_assistant: boolean
|
||||||
invited_teammate: boolean
|
invited_teammate: boolean
|
||||||
connected_psa: boolean
|
connected_psa: boolean
|
||||||
is_team_user: boolean
|
is_team_user: boolean
|
||||||
dismissed: 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> {
|
export async function getOnboardingStatus(): Promise<OnboardingStatus> {
|
||||||
|
|||||||
169
frontend/src/components/dashboard/NextStepCard.tsx
Normal file
169
frontend/src/components/dashboard/NextStepCard.tsx
Normal 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
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
136
frontend/src/components/dashboard/SetupChecklist.tsx
Normal file
136
frontend/src/components/dashboard/SetupChecklist.tsx
Normal 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
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
27
frontend/src/hooks/useOnboardingStatus.ts
Normal file
27
frontend/src/hooks/useOnboardingStatus.ts
Normal 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
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { PageMeta } from '@/components/common/PageMeta'
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
|
import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
|
||||||
@@ -7,6 +8,10 @@ import { TicketQueue } from '@/components/dashboard/TicketQueue'
|
|||||||
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
||||||
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
||||||
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
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 }) {
|
function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -22,6 +27,17 @@ function SectionLabel({ children, action }: { children: React.ReactNode; action?
|
|||||||
|
|
||||||
export function QuickStartPage() {
|
export function QuickStartPage() {
|
||||||
const user = useAuthStore((s) => s.user)
|
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 now = new Date()
|
||||||
const greeting = now.getHours() < 12
|
const greeting = now.getHours() < 12
|
||||||
@@ -47,6 +63,29 @@ export function QuickStartPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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 */}
|
{/* Chat-style input */}
|
||||||
<StartSessionInput />
|
<StartSessionInput />
|
||||||
|
|
||||||
|
|||||||
141
frontend/src/pages/__tests__/QuickStartPage.test.tsx
Normal file
141
frontend/src/pages/__tests__/QuickStartPage.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user