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>
194 lines
7.7 KiB
TypeScript
194 lines
7.7 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { Link, useNavigate } from 'react-router-dom'
|
|
import { Search, LogOut, Shield, Settings, HelpCircle } from 'lucide-react'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import { usePermissions } from '@/hooks/usePermissions'
|
|
import { BrandLogo } from '@/components/common/BrandLogo'
|
|
import { CommandPalette } from './CommandPalette'
|
|
import { NotificationsPanel } from './NotificationsPanel'
|
|
import { TrialPill } from './TrialPill'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export function TopBar() {
|
|
const navigate = useNavigate()
|
|
const { user, logout } = useAuthStore()
|
|
const { effectiveRole, isSuperAdmin } = usePermissions()
|
|
|
|
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
|
|
|
|
const menuRef = useRef<HTMLDivElement>(null)
|
|
|
|
const handleLogout = async () => {
|
|
setUserMenuOpen(false)
|
|
await logout()
|
|
navigate('/login')
|
|
}
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
setUserMenuOpen(false)
|
|
}
|
|
}
|
|
if (userMenuOpen) document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [userMenuOpen])
|
|
|
|
// Cmd+K / Ctrl+K global shortcut
|
|
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault()
|
|
setCommandPaletteOpen(prev => !prev)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
document.addEventListener('keydown', handleGlobalKeyDown)
|
|
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
|
|
}, [handleGlobalKeyDown])
|
|
|
|
const initials = user?.name
|
|
? user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
|
: user?.email?.[0]?.toUpperCase() || '?'
|
|
|
|
return (
|
|
<>
|
|
<header
|
|
className="topbar relative z-10 flex items-center gap-4 px-4 pl-14 md:pl-4"
|
|
style={{
|
|
background: 'var(--color-bg-sidebar)',
|
|
borderBottom: '1px solid var(--color-border-default)',
|
|
}}
|
|
>
|
|
{/* Logo area */}
|
|
<Link
|
|
to="/home"
|
|
className="flex items-center gap-2.5 pr-4 transition-all duration-200"
|
|
>
|
|
<BrandLogo size="sm" />
|
|
<span className="text-sm font-heading font-bold tracking-tight whitespace-nowrap text-text-heading">
|
|
ResolutionFlow
|
|
</span>
|
|
</Link>
|
|
|
|
{/* Spacer - push search to center */}
|
|
<div className="flex-1" />
|
|
|
|
{/* Search trigger — icon on mobile, full bar on desktop */}
|
|
<button
|
|
onClick={() => setCommandPaletteOpen(true)}
|
|
className="hidden sm:relative sm:block w-full text-left"
|
|
style={{ maxWidth: '480px' }}
|
|
>
|
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<div
|
|
className="w-full rounded-md py-2 pl-9 pr-14 text-[0.8125rem] text-muted-foreground cursor-pointer transition-colors"
|
|
style={{
|
|
background: 'var(--color-bg-card)',
|
|
border: '1px solid var(--color-border-default)',
|
|
}}
|
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-hover)' }}
|
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-default)' }}
|
|
>
|
|
Search flows, sessions, tags...
|
|
</div>
|
|
<span
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 font-mono text-[0.625rem] text-text-muted"
|
|
style={{ background: 'var(--color-bg-page)', border: '1px solid var(--color-border-default)' }}
|
|
>
|
|
{navigator.platform?.toLowerCase().includes('mac') ? '\u2318K' : 'Ctrl+K'}
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setCommandPaletteOpen(true)}
|
|
className="sm:hidden rounded-lg p-2 text-muted-foreground hover:text-foreground transition-colors"
|
|
title="Search"
|
|
>
|
|
<Search size={18} />
|
|
</button>
|
|
|
|
{/* Spacer - push actions to right */}
|
|
<div className="flex-1" />
|
|
|
|
{/* Billing-state pill (trial countdown / paid tier / past_due / etc.) */}
|
|
<TrialPill />
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex items-center gap-1">
|
|
<Link
|
|
to="/guides"
|
|
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
|
|
title="User Guides"
|
|
>
|
|
<HelpCircle size={18} />
|
|
</Link>
|
|
<NotificationsPanel />
|
|
|
|
{/* User avatar & menu */}
|
|
<div className="relative ml-2" ref={menuRef}>
|
|
<button
|
|
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
|
className="flex h-8 w-8 items-center justify-center rounded-[10px] text-xs font-heading font-bold text-white hover:opacity-90 transition-opacity"
|
|
style={{ background: 'linear-gradient(135deg, #3b82f6, #60a5fa)' }}
|
|
title={user?.name || user?.email || 'User'}
|
|
>
|
|
{initials}
|
|
</button>
|
|
|
|
{userMenuOpen && (
|
|
<div
|
|
className="absolute right-0 z-50 mt-2 w-56 rounded-lg p-1 shadow-xl animate-scale-in"
|
|
style={{ background: 'var(--color-bg-card)', border: '1px solid var(--color-border-default)' }}
|
|
>
|
|
<div className="px-3 py-2.5 mb-1" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
|
<p className="text-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
|
|
{effectiveRole && effectiveRole !== 'engineer' && (
|
|
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Shield size={10} />
|
|
{effectiveRole === 'super_admin' ? 'Super Admin' : effectiveRole === 'owner' ? 'Owner' : 'Viewer'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Link
|
|
to="/account"
|
|
onClick={() => setUserMenuOpen(false)}
|
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-input hover:text-foreground"
|
|
>
|
|
<Settings size={14} />
|
|
Account
|
|
</Link>
|
|
{isSuperAdmin && (
|
|
<Link
|
|
to="/admin"
|
|
onClick={() => setUserMenuOpen(false)}
|
|
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-input hover:text-foreground"
|
|
>
|
|
<Shield size={14} />
|
|
Admin Panel
|
|
</Link>
|
|
)}
|
|
<div className="mt-1 pt-1" style={{ borderTop: '1px solid var(--color-border-default)' }}>
|
|
<button
|
|
onClick={handleLogout}
|
|
className={cn(
|
|
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm',
|
|
'text-muted-foreground hover:bg-input hover:text-foreground'
|
|
)}
|
|
>
|
|
<LogOut size={14} />
|
|
Logout
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Command Palette */}
|
|
<CommandPalette open={commandPaletteOpen} onClose={() => setCommandPaletteOpen(false)} />
|
|
</>
|
|
)
|
|
}
|