Stripe's compliance crawler fetches the apex URL without executing JS and declined live-mode review when `https://resolutionflow.com/` returned the empty SPA shell that redirected to /landing client-side. Restructure the router so / serves LandingPage directly: - `/` → new `PublicLanding` wrapper (LandingPage for anon; Navigate to /home for authed users so there's no marketing-frame flicker). - Authed tree converted to a path-less layout route with absolute child paths. QuickStartPage moves to `/home`; all other children (`/trees`, `/pilot`, `/admin/*`, `/account/*`, etc.) keep their URLs. - `/landing` kept as a one-release stale-bookmark redirect to /. - `ProtectedRoute` unauth redirect flipped /landing → /; `state.from` preserved for post-login return. Reference updates: - Post-login / post-onboarding destinations → /home: OAuthCallbackPage (incl. `?welcome=teammate` query), WelcomeStep1/2/3 dismiss-rest, AssistantChatPage post-escalate, WelcomeRouter completion/dismiss redirects, VerifyEmailPage's three "Go to dashboard" links. - Authed chrome → /home: TopBar logo, AppLayout mobile nav + drawer logo, CommandPalette Dashboard entry. - Dashboard onboarding → /home: NextStepCard `ran_session.ctaPath`, SetupChecklist `ran_session.path`, SessionHistoryPage empty-state CTA. - Public back-links → /: TermsPage, PrivacyPage, PoliciesPage, ContactPage, PromotionsPage, PublicTemplatesPage (header + footer). SharedSessionPage's `to="/"` left as-is — now correctly lands anon visitors on the public landing. Crawlability: - New `frontend/public/robots.txt` allowlisting public pages and disallowing the authed app. - New `frontend/public/sitemap.xml` for /, /pricing, /contact-sales, /contact, /templates, /terms, /privacy, /policies, /promotions. - `PageMeta` gains an `og:url` (defaults to `window.location.href`) and flips `twitter:card` to `summary_large_image` when an `ogImage` is passed. Tests: - `AppLayout.test.tsx` updated to mount at `/home`. - New `ProtectedRoute.test.tsx` asserts unauthenticated `/home` redirects to `/` (not `/landing`) and preserves origin in `state.from`. If Stripe's crawler still cannot see the site after this (zero-JS crawler), the documented next escalation is server-side prerendering of public routes via `vite-plugin-ssg`. Out of scope here. Plan: docs/plans/2026-05-13-public-landing-routing-refactor.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
188 lines
6.2 KiB
TypeScript
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: '/home',
|
|
}
|
|
}
|
|
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
|