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>
This commit is contained in:
@@ -5,6 +5,8 @@ interface PageMetaProps {
|
||||
description?: string
|
||||
ogImage?: string
|
||||
ogType?: string
|
||||
/** Canonical/Open Graph URL. Defaults to `window.location.href` in the browser. */
|
||||
url?: string
|
||||
}
|
||||
|
||||
const SITE_NAME = 'ResolutionFlow'
|
||||
@@ -20,8 +22,12 @@ export function PageMeta({
|
||||
description = DEFAULT_DESCRIPTION,
|
||||
ogImage,
|
||||
ogType = 'website',
|
||||
url,
|
||||
}: PageMetaProps) {
|
||||
const fullTitle = title ? `${title} | ${SITE_NAME}` : `${SITE_NAME} — ${DEFAULT_TAGLINE}`
|
||||
const resolvedUrl =
|
||||
url ?? (typeof window !== 'undefined' ? window.location.href : undefined)
|
||||
const twitterCard = ogImage ? 'summary_large_image' : 'summary'
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
@@ -33,10 +39,11 @@ export function PageMeta({
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:site_name" content={SITE_NAME} />
|
||||
{resolvedUrl && <meta property="og:url" content={resolvedUrl} />}
|
||||
{ogImage && <meta property="og:image" content={ogImage} />}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:card" content={twitterCard} />
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function pickNextStep(
|
||||
title: 'Run your first FlowPilot session',
|
||||
description: 'Paste a ticket or pick a flow to see ResolutionFlow in action.',
|
||||
ctaLabel: 'Start a session',
|
||||
ctaPath: '/',
|
||||
ctaPath: '/home',
|
||||
}
|
||||
}
|
||||
if (!status.connected_psa) {
|
||||
|
||||
@@ -51,7 +51,7 @@ export function buildChecklistItems(
|
||||
{
|
||||
key: 'ran_session',
|
||||
label: 'Run your first FlowPilot session',
|
||||
path: '/',
|
||||
path: '/home',
|
||||
done: status.ran_session,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -58,7 +58,7 @@ export function AppLayout() {
|
||||
}
|
||||
|
||||
const mobileNavItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: LayoutGrid },
|
||||
{ path: '/home', label: 'Dashboard', icon: LayoutGrid },
|
||||
{ path: '/sessions', label: 'Session History', icon: Clock },
|
||||
{ path: '/escalations', label: 'Escalations', icon: AlertTriangle },
|
||||
{ path: '/trees', label: 'Guided Flows', icon: GitBranch },
|
||||
@@ -106,7 +106,7 @@ export function AppLayout() {
|
||||
style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between px-4" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<Link to="/home" className="flex items-center gap-2.5">
|
||||
<BrandLogo size="sm" />
|
||||
<span className="text-sm font-heading font-bold text-text-heading">ResolutionFlow</span>
|
||||
</Link>
|
||||
|
||||
@@ -40,7 +40,7 @@ interface Group {
|
||||
}
|
||||
|
||||
const PAGES: PaletteItem[] = [
|
||||
{ id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/', icon: 'page' },
|
||||
{ id: 'page-dashboard', group: 'pages', title: 'Dashboard', path: '/home', icon: 'page' },
|
||||
{ id: 'page-flows', group: 'pages', title: 'All Flows', subtitle: 'Browse your flow library', path: '/trees', icon: 'page' },
|
||||
{ id: 'page-sessions', group: 'pages', title: 'Sessions', subtitle: 'View session history', path: '/sessions', icon: 'page' },
|
||||
{ id: 'page-flowpilot', group: 'pages', title: 'FlowPilot', subtitle: 'AI troubleshooting', path: '/pilot', icon: 'page' },
|
||||
|
||||
@@ -22,7 +22,7 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/landing" state={{ from: location }} replace />
|
||||
return <Navigate to="/" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
// Enforce must_change_password — redirect unless already on /change-password
|
||||
|
||||
@@ -63,7 +63,7 @@ export function TopBar() {
|
||||
>
|
||||
{/* Logo area */}
|
||||
<Link
|
||||
to="/"
|
||||
to="/home"
|
||||
className="flex items-center gap-2.5 pr-4 transition-all duration-200"
|
||||
>
|
||||
<BrandLogo size="sm" />
|
||||
|
||||
@@ -71,11 +71,11 @@ const FROZEN_NOW = new Date('2026-05-06T00:00:00Z')
|
||||
|
||||
function renderAppLayout() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<MemoryRouter initialEntries={['/home']}>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route
|
||||
index
|
||||
path="/home"
|
||||
element={<div data-testid="child-route-content">child route</div>}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'
|
||||
|
||||
import { ProtectedRoute } from '../ProtectedRoute'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
/**
|
||||
* Probe component: surfaces the current pathname and `location.state.from` so
|
||||
* the test can assert both the redirect target and that the original
|
||||
* destination is preserved for post-login return.
|
||||
*/
|
||||
function LocationProbe() {
|
||||
const loc = useLocation()
|
||||
const from =
|
||||
(loc.state as { from?: { pathname?: string } } | null)?.from?.pathname ?? ''
|
||||
return (
|
||||
<>
|
||||
<div data-testid="probe-pathname">{loc.pathname}</div>
|
||||
<div data-testid="probe-from">{from}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ProtectedRoute — unauthenticated redirect', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects unauthenticated visits to /home → / and preserves origin in state.from', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/home']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/home"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div data-testid="home-content">home</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
// The protected page should not render.
|
||||
expect(screen.queryByTestId('home-content')).not.toBeInTheDocument()
|
||||
|
||||
// We landed on / (the public landing route), not /landing.
|
||||
expect(screen.getByTestId('probe-pathname')).toHaveTextContent('/')
|
||||
expect(screen.getByTestId('probe-from')).toHaveTextContent('/home')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user