Files
resolutionflow/frontend/src/components/dashboard/NextStepCard.tsx
Michael Chihlas f1be3abcc5
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled
feat: self-serve signup Phase 2 (frontend cutover) (#162)
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-07 18:42:20 +00:00

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