Files
resolutionflow/frontend/src/pages/welcome/WelcomeStep1.tsx
Michael Chihlas 05646465b8
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (pull_request) Failing after 5m32s
CI / frontend (pull_request) Failing after 5m34s
CI / backend (pull_request) Successful in 10m19s
feat(routing): serve public landing at / and move authed index to /home
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>
2026-05-14 01:58:10 -04:00

249 lines
7.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2 } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import {
onboardingApi,
type RoleAtSignup,
type TeamSizeBucket,
} from '@/api/onboarding'
import { cn } from '@/lib/utils'
const TEAM_SIZE_OPTIONS: { value: TeamSizeBucket; label: string }[] = [
{ value: '1-2', label: '12' },
{ value: '3-5', label: '35' },
{ value: '6-10', label: '610' },
{ value: '11-25', label: '1125' },
{ value: '26+', label: '26+' },
]
const ROLE_OPTIONS: { value: RoleAtSignup; label: string }[] = [
{ value: 'owner', label: 'Owner' },
{ value: 'lead_tech', label: 'Lead Tech' },
{ value: 'tech', label: 'Tech' },
{ value: 'other', label: 'Other' },
]
/**
* `/welcome/step-1` — first step of the welcome wizard. Captures shop context
* (company name, team size, role). Persists server-side before navigating.
*/
export function WelcomeStep1() {
const navigate = useNavigate()
const account = useAuthStore((s) => s.account)
const fetchUser = useAuthStore((s) => s.fetchUser)
const [companyName, setCompanyName] = useState<string>(account?.name ?? '')
const [teamSize, setTeamSize] = useState<TeamSizeBucket | ''>('')
const [role, setRole] = useState<RoleAtSignup | ''>('')
const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null)
const [error, setError] = useState<string | null>(null)
const isBusy = submitting !== null
const handleContinue = async (e: FormEvent) => {
e.preventDefault()
if (isBusy) return
setError(null)
setSubmitting('continue')
try {
await onboardingApi.updateStep({
step: 1,
action: 'complete',
data: {
company_name: companyName.trim() || undefined,
team_size_bucket: teamSize || undefined,
role_at_signup: role || undefined,
},
})
await fetchUser()
navigate('/welcome/step-2')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
const handleSkipStep = async () => {
if (isBusy) return
setError(null)
setSubmitting('skip')
try {
await onboardingApi.updateStep({ step: 1, action: 'skip' })
await fetchUser()
navigate('/welcome/step-2')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
const handleDismissRest = async () => {
if (isBusy) return
setError(null)
setSubmitting('dismiss')
try {
await onboardingApi.dismissRest()
await fetchUser()
navigate('/home')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
const inputClass = cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
'text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20',
)
return (
<div className="mx-auto w-full max-w-2xl px-4 py-10">
<header className="mb-8">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
Step 1 of 3
</p>
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
Your shop
</h1>
<p className="mt-2 text-sm text-muted-foreground">
A couple of quick questions so we can tailor ResolutionFlow to your team.
</p>
</header>
<form
onSubmit={handleContinue}
className="rounded-2xl border border-border bg-card p-6 space-y-5"
data-testid="welcome-step-1-form"
>
<div>
<label
htmlFor="company_name"
className="block text-sm font-medium text-foreground"
>
Company name
</label>
<input
id="company_name"
name="company_name"
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className={inputClass}
placeholder="Acme MSP"
data-testid="welcome-step-1-company-name"
/>
</div>
<div>
<label
htmlFor="team_size"
className="block text-sm font-medium text-foreground"
>
Team size
</label>
<select
id="team_size"
name="team_size"
value={teamSize}
onChange={(e) => setTeamSize(e.target.value as TeamSizeBucket | '')}
className={inputClass}
data-testid="welcome-step-1-team-size"
>
<option value="">Select team size</option>
{TEAM_SIZE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="role"
className="block text-sm font-medium text-foreground"
>
Your role
</label>
<select
id="role"
name="role"
value={role}
onChange={(e) => setRole(e.target.value as RoleAtSignup | '')}
className={inputClass}
data-testid="welcome-step-1-role"
>
<option value="">Select your role</option>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{error && (
<p className="text-xs text-red-400" data-testid="welcome-step-1-error">
{error}
</p>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={isBusy}
data-testid="welcome-step-1-continue"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-primary text-primary-foreground hover:bg-primary/90',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'disabled:opacity-60',
)}
>
{submitting === 'continue' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Continue
</button>
<button
type="button"
onClick={handleSkipStep}
disabled={isBusy}
data-testid="welcome-step-1-skip"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-60',
)}
>
{submitting === 'skip' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Skip this step
</button>
</div>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={handleDismissRest}
disabled={isBusy}
data-testid="welcome-step-1-dismiss-rest"
className={cn(
'text-xs text-muted-foreground hover:underline',
'disabled:opacity-60',
)}
>
{submitting === 'dismiss' ? 'Saving…' : 'Skip the rest'}
</button>
</div>
</div>
)
}
export default WelcomeStep1