Files
resolutionflow/frontend/src/components/dashboard/NextStepCard.tsx
Michael Chihlas 8d79dd93b8
Some checks failed
Mirror to GitHub / mirror (push) Successful in 6s
CI / frontend (pull_request) Failing after 1m26s
CI / e2e (pull_request) Successful in 10m3s
CI / backend (pull_request) Successful in 10m10s
feat(dashboard): focus same-page Start Session input from NextStep CTA and checklist
The "Start a session" CTAs on the NextStepCard and SetupChecklist used to
Link-navigate, which left the user on the same page (the Start Session
input lives on the dashboard) without any visible response. Replace those
CTAs with a custom window-event dispatch (FOCUS_START_SESSION_EVENT) that
the StartSessionInput listens for: scroll the input into view, focus the
textarea, and pulse a ring for 900ms so the click feels intentional. The
NextStepCard also locally hides itself after firing so the user isn't
double-prompted while typing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 17:56:30 -04:00

188 lines
6.2 KiB
TypeScript

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'
import { FOCUS_START_SESSION_EVENT } from '@/components/dashboard/StartSessionInput'
/**
* 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.
*/
// eslint-disable-next-line react-refresh/only-export-components -- pure helper exported for focused unit tests
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 [locallyHidden, setLocallyHidden] = useState(false)
const { stage } = useTrialBanner()
if (!status || status.dismissed || locallyDismissed || locallyHidden) 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">
{next.key === 'ran_session' ? (
<button
type="button"
onClick={() => {
window.dispatchEvent(new Event(FOCUS_START_SESSION_EVENT))
setLocallyHidden(true)
}}
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} />
</button>
) : (
<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