From 5b0a8e075965e35f6083c4b9e8c6e8bbe1565e97 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 23 Sep 2026 17:38:02 +0200 Subject: [PATCH] feat(app): unified demo mode + public-demo build for Vercel - Central demo-mode helper (lib/demo-mode.ts): ?demo=1 query flag plus VITE_PUBLIC_DEMO build flag that makes every route demo for the public Vercel showcase. - Single demo fixture source (lib/demo-data.ts): projects, services, builds, databases, cron, HA, security, host monitoring, templates. Fixes cross-page ID drift that broke project navigation. - Demo support on Projects, HA, Security, Usage, Templates, Landing, Service detail; DemoRestricted gate on People/Settings/Audit/Admin. - Demo-aware PlatformShell: nav filtering, env:demo, demo account menu, ?demo=1 preserved through internal links. - TemplatesPage: selection moved into ?template= URL param for deep links; template fixtures shared via demo-data. - requestJson returns {} for unparseable 200 bodies (SPA fallback) so envelope unwraps degrade instead of dereferencing null. - Recharts ResponsiveContainer initialDimension kills -1 warnings. - vercel.json: vite framework + SPA rewrites for direct deep links. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/frontend/.gitignore | 2 + app/frontend/src/app/App.smoke.test.tsx | 2 +- app/frontend/src/app/App.tsx | 6 +- app/frontend/src/app/layout/PlatformShell.tsx | 101 ++-- .../src/features/admin/pages/AdminPage.tsx | 16 +- .../features/audit/pages/AuditLogsPage.tsx | 8 + .../src/features/builds/pages/BuildsPage.tsx | 36 +- .../src/features/landing/LandingPage.tsx | 36 +- .../features/projects/pages/ProjectsPage.tsx | 36 +- app/frontend/src/features/secondary/pages.tsx | 162 +++-- .../service/pages/ServiceDetailPage.tsx | 6 +- .../templates/pages/TemplatesPage.tsx | 353 +---------- .../workspace/pages/ProjectWorkspacePage.tsx | 3 +- app/frontend/src/lib/api-client.ts | 5 +- app/frontend/src/lib/demo-data.ts | 568 +++++++++++++++++- app/frontend/src/lib/demo-mode.ts | 17 + app/frontend/src/shared/components/Charts.tsx | 6 +- .../src/shared/components/DemoGate.tsx | 25 + app/frontend/src/shared/components/index.ts | 1 + app/frontend/vercel.json | 9 + 20 files changed, 873 insertions(+), 525 deletions(-) create mode 100644 app/frontend/.gitignore create mode 100644 app/frontend/src/lib/demo-mode.ts create mode 100644 app/frontend/src/shared/components/DemoGate.tsx create mode 100644 app/frontend/vercel.json diff --git a/app/frontend/.gitignore b/app/frontend/.gitignore new file mode 100644 index 0000000..245259b --- /dev/null +++ b/app/frontend/.gitignore @@ -0,0 +1,2 @@ +.vercel +.env* diff --git a/app/frontend/src/app/App.smoke.test.tsx b/app/frontend/src/app/App.smoke.test.tsx index 2af983d..8bb097e 100644 --- a/app/frontend/src/app/App.smoke.test.tsx +++ b/app/frontend/src/app/App.smoke.test.tsx @@ -32,7 +32,7 @@ describe('App smoke routes', () => { expect(await screen.findByRole('heading', { name: /^projects\s*_?$/i })).toBeInTheDocument(); expect(screen.getByText(/recent deployments/i)).toBeInTheDocument(); - expect(screen.getAllByText(/core-services/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/core platform/i).length).toBeGreaterThan(0); }); it('renders builds page in demo mode', async () => { diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index ef3444c..81fe82e 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -23,14 +23,10 @@ import { } from '@/features/secondary/pages'; import { SignInPage, SignUpPage } from '@/features/auth/pages'; import { useAuthSession } from '@/lib/use-auth-session'; +import { useDemoMode } from '@/lib/demo-mode'; import { getCurrentUserProfile } from '@/lib/api-client'; import { ErrorBoundary, LoadingState } from '@/shared/components'; -function useDemoMode() { - const location = useLocation(); - return new URLSearchParams(location.search).get('demo') === '1'; -} - function AuthRequired() { const location = useLocation(); const isDemoMode = useDemoMode(); diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index 241ef02..64961e3 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -37,6 +37,7 @@ import { pullUpgradeImage, } from '@/lib/api-client'; import { signOutAuthSession } from '@/lib/auth-client'; +import { isDemoSearch } from '@/lib/demo-mode'; import { useAuthSession } from '@/lib/use-auth-session'; import { useToast } from '@/shared/components'; @@ -103,8 +104,18 @@ export function PlatformShell() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { showToast } = useToast(); - const isDemoMode = new URLSearchParams(location.search).get('demo') === '1'; + const isDemoMode = isDemoSearch(location.search); const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target); + // Auth/admin-scoped pages have no demo fixtures — keep the demo nav honest. + const visibleNavSections = isDemoMode + ? navSections + .map((section) => + section.title === 'Workspace' + ? { ...section, items: section.items.filter((item) => item.href === '/docs') } + : section, + ) + .filter((section) => section.items.length > 0) + : navSections; const [theme, setTheme] = useState(() => getInitialTheme()); const [sidebarExpanded, setSidebarExpanded] = useState(() => getInitialSidebar()); const [notificationsOpen, setNotificationsOpen] = useState(false); @@ -118,7 +129,7 @@ export function PlatformShell() { staleTime: 60_000, retry: false, }); - const isAdmin = isDemoMode || Boolean(profileQuery.data?.isAdmin); + const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin); const upgradeQuery = useQuery({ queryKey: ['upgrade-status'], queryFn: getUpgradeStatus, @@ -174,8 +185,8 @@ export function PlatformShell() { const isActiveRoute = (itemHref: string) => location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`); - const userName = profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account'; - const userEmail = profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session'; + const userName = isDemoMode ? 'Demo workspace' : profileQuery.data?.name ?? sessionQuery.data?.user.name ?? 'Account'; + const userEmail = isDemoMode ? 'Sample data · read-only' : profileQuery.data?.email ?? sessionQuery.data?.user.email ?? 'Local session'; const initials = userName .split(' ') .map((part) => part[0]) @@ -220,7 +231,7 @@ export function PlatformShell() {