# Public Landing Routing Refactor

**Date:** 2026-05-13
**Status:** Planned — pending execution
**Author:** session handoff
**Driver:** Stripe activation review — Stripe's compliance crawler cannot view `resolutionflow.com`

## Problem

The bare apex URL `https://resolutionflow.com/` serves a Vite SPA shell
(`<div id="root"></div>` and a module script — see [`frontend/index.html`](../../frontend/index.html))
and the React Router config in [`frontend/src/router.tsx`](../../frontend/src/router.tsx)
mounts `/` behind `<ProtectedRoute>`. The public marketing landing page lives
at `/landing`. For unauthenticated visitors, the flow is:

1. Browser fetches `/` → empty HTML shell.
2. JS executes, auth store hydrates as not-authenticated.
3. `ProtectedRoute` client-side `<Navigate to="/landing">`.

Stripe (and many automated compliance crawlers) fetch the apex without
executing JS, or don't reliably wait through a client-side redirect chain.
They see no business content, no terms link, no pricing — and decline review.

## Goal

Make `/` serve the public landing page directly so the apex URL renders
marketing content immediately. Move the authenticated dashboard index
(currently `QuickStartPage` at `/`) to `/home`. All other authenticated
child routes (`/trees`, `/pilot`, `/admin/*`, `/account/*`, etc.) stay
where they are — only the index page and the route grouping change.

This is the architectural fix. If Stripe's reviewer still cannot see the
site after this lands (i.e. their crawler executes zero JS), the documented
next escalation is server-side prerendering of public routes via
`vite-plugin-ssg` — captured below under Follow-ups.

## Approach

### Router restructure ([`frontend/src/router.tsx`](../../frontend/src/router.tsx))

Use a react-router *layout route* (no `path`, just an `element`) for the
authenticated tree so children carry absolute paths and don't all need
renaming:

```tsx
// Public
{ path: '/', element: page(PublicLanding), errorElement: <RouteError /> },

// Stale-bookmark redirect — keep for one release, delete in a follow-up
{ path: '/landing', element: <Navigate to="/" replace /> },

// Authenticated app — layout route
{
  element: <ProtectedRoute><AppLayout /></ProtectedRoute>,
  errorElement: <RouteError />,
  children: [
    { path: '/home', element: page(QuickStartPage) },
    { path: '/trees', element: page(TreeLibraryPage) },
    { path: '/my-trees', element: page(MyTreesPage) },
    // …all other existing children, unchanged (admin/*, account/*, pilot/*, …)
  ],
},
```

### `PublicLanding` wrapper (no-flicker authed redirect)

Authenticated users hitting `/` should not see marketing. Use a thin
router-level wrapper so `LandingPage` stays a pure marketing component
and there's no frame-flash before redirect:

```tsx
function PublicLanding() {
  const isAuthed = useAuthStore(s => s.isAuthenticated);
  return isAuthed ? <Navigate to="/home" replace /> : <LandingPage />;
}
```

### Auth gate ([`frontend/src/components/layout/ProtectedRoute.tsx:25`](../../frontend/src/components/layout/ProtectedRoute.tsx#L25))

`<Navigate to="/landing" state={{ from: location }} replace />`
→ `<Navigate to="/" state={{ from: location }} replace />`.
The `state.from` preservation stays.

### Reference updates (21 sites)

**Post-login / post-onboarding destinations**

| File | Line | Change |
|---|---|---|
| [`frontend/src/pages/OAuthCallbackPage.tsx`](../../frontend/src/pages/OAuthCallbackPage.tsx#L114) | 114 | `let dest = '/'` → `'/home'`; `'/?welcome=teammate'` → `'/home?welcome=teammate'` |
| [`frontend/src/pages/welcome/WelcomeStep1.tsx`](../../frontend/src/pages/welcome/WelcomeStep1.tsx#L88) | 88 | `navigate('/')` → `navigate('/home')` |
| [`frontend/src/pages/welcome/WelcomeStep2.tsx`](../../frontend/src/pages/welcome/WelcomeStep2.tsx#L72) | 72 | same |
| [`frontend/src/pages/welcome/WelcomeStep3.tsx`](../../frontend/src/pages/welcome/WelcomeStep3.tsx#L194) | 194 | same |
| [`frontend/src/pages/AssistantChatPage.tsx`](../../frontend/src/pages/AssistantChatPage.tsx#L2419) | 2419 | same |

**Authenticated chrome (logo, mobile nav)**

| File | Line | Change |
|---|---|---|
| [`frontend/src/components/layout/TopBar.tsx`](../../frontend/src/components/layout/TopBar.tsx#L66) | 66 | logo `to="/"` → `to="/home"` |
| [`frontend/src/components/layout/AppLayout.tsx`](../../frontend/src/components/layout/AppLayout.tsx#L60) | 60 | mobile nav `path: '/'` → `'/home'` |
| [`frontend/src/components/layout/AppLayout.tsx`](../../frontend/src/components/layout/AppLayout.tsx#L107) | 107 | logo `to="/"` → `to="/home"` |

**Dashboard onboarding (has in-progress edits — layer carefully)**

| File | Line | Change |
|---|---|---|
| [`frontend/src/components/dashboard/SetupChecklist.tsx`](../../frontend/src/components/dashboard/SetupChecklist.tsx#L54) | 54 | `path: '/'` → `'/home'` |
| [`frontend/src/components/dashboard/NextStepCard.tsx`](../../frontend/src/components/dashboard/NextStepCard.tsx#L82) | 82 | `ctaPath: '/'` → `'/home'` |

These two files already have uncommitted edits for the "Start a session"
pulse/scroll onboarding fix from earlier this session. Layer onto whatever's
there — don't overwrite.

**Public page back-links**

| File | Line | Change |
|---|---|---|
| [`frontend/src/pages/TermsPage.tsx`](../../frontend/src/pages/TermsPage.tsx#L10) | 10 | `to="/landing"` → `to="/"` |
| [`frontend/src/pages/PoliciesPage.tsx`](../../frontend/src/pages/PoliciesPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/PrivacyPage.tsx`](../../frontend/src/pages/PrivacyPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/ContactPage.tsx`](../../frontend/src/pages/ContactPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/PromotionsPage.tsx`](../../frontend/src/pages/PromotionsPage.tsx#L10) | 10 | same |
| [`frontend/src/pages/PublicTemplatesPage.tsx`](../../frontend/src/pages/PublicTemplatesPage.tsx#L171) | 171, 409 | same |

### robots.txt + sitemap.xml ([`frontend/public/`](../../frontend/public/))

Neither file exists today. Create both.

**`frontend/public/robots.txt`**

```
User-agent: *
Allow: /
Allow: /terms
Allow: /policies
Allow: /privacy
Allow: /contact
Allow: /contact-sales
Allow: /pricing
Allow: /promotions
Allow: /templates
Disallow: /home
Disallow: /trees/
Disallow: /my-trees
Disallow: /pilot/
Disallow: /admin/
Disallow: /account/
Disallow: /script-builder
Disallow: /scripts
Disallow: /sessions
Disallow: /analytics
Disallow: /escalations
Disallow: /queue
Disallow: /review-queue
Disallow: /network-diagrams
Disallow: /kb-accelerator
Disallow: /step-library
Disallow: /tickets
Disallow: /shares
Disallow: /feedback
Disallow: /welcome
Disallow: /flow-assist
Disallow: /dev/
Disallow: /flows/
Disallow: /guides

Sitemap: https://resolutionflow.com/sitemap.xml
```

**`frontend/public/sitemap.xml`** — entries for `/`, `/pricing`,
`/contact-sales`, `/contact`, `/templates`, `/terms`, `/privacy`,
`/policies`, `/promotions`. Standard `<urlset>` schema with `<loc>` and
`<lastmod>` of `2026-05-13`.

### Open Graph + Twitter cards

[`frontend/src/components/common/PageMeta.tsx`](../../frontend/src/components/common/PageMeta.tsx)
already emits `og:title/description/type/site_name` and
`twitter:card=summary/title/description`. Gaps:

1. **No `og:url`** in `PageMeta` — add a `url` prop that defaults to
   `window.location.href` (or take a canonical override).
2. **`twitter:card` is always `summary`** — when an `ogImage` is passed,
   emit `summary_large_image` instead.
3. **`LandingPage` doesn't pass `ogImage`** — currently no social preview
   image at all. Need a 1200×630 asset. Acceptable to ship a placeholder
   (logo on existing landing gradient) and flag for design polish.

### Tests

**Update**

| File | Change |
|---|---|
| [`frontend/src/components/layout/__tests__/AppLayout.test.tsx`](../../frontend/src/components/layout/__tests__/AppLayout.test.tsx) | `initialEntries={['/']}` → `['/home']` |
| [`frontend/src/pages/__tests__/LandingPage.test.tsx`](../../frontend/src/pages/__tests__/LandingPage.test.tsx) | Keep `['/']` — now correct |

**Add**

`frontend/src/components/layout/__tests__/ProtectedRoute.test.tsx` (or
extend existing) — unauthenticated visit to `/home` should:

- Redirect to `/` (not `/landing`).
- Preserve original location in `state.from` so post-login flow can return
  the user to their intended destination.

## Out of scope / non-issues verified

- **Service worker / PWA cache invalidation.** `vite.config.ts` has no
  `vite-plugin-pwa`, no `injectManifest`, no SW registration anywhere in
  `frontend/src`. The "PWA Icons" comment in `index.html` is iOS
  apple-touch-icon only. Vite's content-hashed bundles + browser HTTP cache
  handle invalidation. Flagged during review; no action needed.
- **Backend redirects / CORS / OAuth.** Grep of `backend/` shows no
  hard-coded `/landing` or root-path redirects. OAuth callbacks render
  client-side via [`OAuthCallbackPage.tsx`](../../frontend/src/pages/OAuthCallbackPage.tsx).
  No backend changes required.

## Manual follow-ups (not code changes)

- **PostHog dashboard audit.** [`frontend/src/main.tsx:20`](../../frontend/src/main.tsx#L20)
  sets `capture_pageview: true`, so `$pageview` auto-fires for every URL.
  After this ships, `$current_url ends with /` shifts meaning from
  "authenticated dashboard visit" to "anonymous marketing visit." Any
  saved PostHog insight or funnel keyed on `/` will silently mis-interpret.
  No in-code filters on `'/'` exist (grepped `lib/analytics.ts` and the
  wider tree). Sweep PostHog dashboards in the PostHog UI before merging
  this PR and update filters as needed.
- **OG image asset.** Placeholder is acceptable to unblock Stripe; design
  polish can follow.

## Follow-ups (deferred — future PRs)

- **Stripe SSR escalation.** If Stripe's reviewer still cannot see the
  site after this lands (i.e. their crawler executes zero JavaScript), the
  next step is server-side prerendering of public routes. Cheapest path:
  `vite-plugin-ssg` for static HTML output of `/`, `/pricing`, `/terms`,
  `/privacy`, `/policies`, `/contact`, `/contact-sales`, `/promotions`,
  `/templates`. Keeps the SPA architecture for the authed app. Larger
  move (only if needed): split marketing to a separate Astro/Next-static
  project at the apex and move the SPA to `app.resolutionflow.com`.
  Do not pre-optimize for this. Capture as a decision in
  [`.ai/DECISIONS.md`](../../.ai/DECISIONS.md) when this PR lands.
- **Delete `/landing` redirect alias** after one release cycle.

## Rollout / sequencing

1. Router restructure + `PublicLanding` wrapper.
2. 21 reference updates (post-login, chrome, dashboard onboarding, public
   page back-links).
3. `ProtectedRoute` redirect target flip.
4. `robots.txt`, `sitemap.xml`.
5. `PageMeta` enhancements (`og:url`, `summary_large_image` toggle).
6. OG image asset, wired into `LandingPage`.
7. Test updates + new `ProtectedRoute` test.
8. Manual: PostHog dashboard sweep.
9. `.ai/DECISIONS.md` entry noting SSR-prerender as next-escalation path.
10. Single PR, single deploy.

## Risk

Necessary but not necessarily sufficient for Stripe's crawler. If their
bot executes zero JS, even a `/`-routed `LandingPage.tsx` is invisible —
Vite still client-renders. The Follow-ups section above captures the
escalation path.
