Co-authored-by: Michael Chihlas <michael@resolutionflow.com> Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
171 lines
5.4 KiB
TypeScript
171 lines
5.4 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'
|
|
|
|
/**
|
|
* 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 { 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
|