@pylonsync/create-pylon 0.3.372 → 0.3.374

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/package.json +1 -1
  2. package/templates/_root/AGENTS.md +4 -2
  3. package/templates/agency/AGENTS.md +4 -2
  4. package/templates/agency/app/(marketing)/layout.tsx +136 -0
  5. package/templates/{creator/app → agency/app/(marketing)}/not-found.tsx +6 -2
  6. package/templates/agency/app/error.tsx +6 -3
  7. package/templates/agency/app/layout.tsx +8 -134
  8. package/templates/agency/app/login/page.tsx +3 -2
  9. package/templates/ai-chat/AGENTS.md +23 -2
  10. package/templates/ai-chat/app/(chat)/layout.tsx +46 -0
  11. package/templates/ai-chat/app/{not-found.tsx → (chat)/not-found.tsx} +5 -2
  12. package/templates/ai-chat/app/error.tsx +6 -3
  13. package/templates/ai-chat/app/layout.tsx +6 -39
  14. package/templates/ai-chat/app/login/page.tsx +2 -1
  15. package/templates/ai-studio/AGENTS.md +23 -2
  16. package/templates/ai-studio/app/(studio)/layout.tsx +46 -0
  17. package/templates/{agency/app → ai-studio/app/(studio)}/not-found.tsx +5 -2
  18. package/templates/ai-studio/app/error.tsx +6 -3
  19. package/templates/ai-studio/app/layout.tsx +6 -39
  20. package/templates/ai-studio/app/login/page.tsx +2 -1
  21. package/templates/barebones/AGENTS.md +4 -2
  22. package/templates/chat/AGENTS.md +23 -2
  23. package/templates/consumer/AGENTS.md +4 -2
  24. package/templates/creator/AGENTS.md +4 -2
  25. package/templates/creator/app/(marketing)/layout.tsx +122 -0
  26. package/templates/{ai-studio/app → creator/app/(marketing)}/not-found.tsx +6 -2
  27. package/templates/creator/app/error.tsx +6 -3
  28. package/templates/creator/app/layout.tsx +8 -120
  29. package/templates/creator/app/login/page.tsx +3 -2
  30. package/templates/crm/AGENTS.md +4 -2
  31. package/templates/directory/AGENTS.md +4 -2
  32. package/templates/directory/app/(marketing)/layout.tsx +133 -0
  33. package/templates/directory/app/(marketing)/not-found.tsx +23 -0
  34. package/templates/directory/app/error.tsx +6 -3
  35. package/templates/directory/app/layout.tsx +8 -131
  36. package/templates/directory/app/login/page.tsx +3 -2
  37. package/templates/helpdesk/AGENTS.md +4 -2
  38. package/templates/inventory/AGENTS.md +4 -2
  39. package/templates/invoices/AGENTS.md +4 -2
  40. package/templates/local-service/AGENTS.md +4 -2
  41. package/templates/local-service/app/(marketing)/layout.tsx +119 -0
  42. package/templates/local-service/app/(marketing)/not-found.tsx +23 -0
  43. package/templates/local-service/app/{page.tsx → (marketing)/page.tsx} +2 -2
  44. package/templates/local-service/app/error.tsx +3 -1
  45. package/templates/local-service/app/layout.tsx +8 -116
  46. package/templates/local-service/app/login/page.tsx +3 -2
  47. package/templates/local-service/components/marketing.tsx +1 -1
  48. package/templates/marketplace/AGENTS.md +4 -2
  49. package/templates/projects/AGENTS.md +4 -2
  50. package/templates/restaurant/AGENTS.md +4 -2
  51. package/templates/restaurant/app/(marketing)/layout.tsx +119 -0
  52. package/templates/restaurant/app/(marketing)/not-found.tsx +23 -0
  53. package/templates/restaurant/app/{page.tsx → (marketing)/page.tsx} +2 -2
  54. package/templates/restaurant/app/error.tsx +3 -1
  55. package/templates/restaurant/app/layout.tsx +8 -116
  56. package/templates/restaurant/app/login/page.tsx +3 -2
  57. package/templates/restaurant/components/marketing.tsx +1 -1
  58. package/templates/saas/AGENTS.md +4 -2
  59. package/templates/saas/app/(marketing)/layout.tsx +411 -0
  60. package/templates/saas/app/{not-found.tsx → (marketing)/not-found.tsx} +9 -5
  61. package/templates/saas/app/dashboard/page.tsx +3 -2
  62. package/templates/saas/app/error.tsx +7 -5
  63. package/templates/saas/app/layout.tsx +8 -408
  64. package/templates/saas/app/login/page.tsx +2 -1
  65. package/templates/saas/components/dashboard-shell.tsx +2 -2
  66. package/templates/shop/AGENTS.md +4 -2
  67. package/templates/shop/app/(marketing)/layout.tsx +122 -0
  68. package/templates/shop/app/(marketing)/not-found.tsx +23 -0
  69. package/templates/shop/app/{page.tsx → (marketing)/page.tsx} +1 -1
  70. package/templates/shop/app/error.tsx +3 -1
  71. package/templates/shop/app/layout.tsx +8 -120
  72. package/templates/shop/app/login/page.tsx +3 -2
  73. package/templates/todo/AGENTS.md +4 -2
  74. package/templates/waitlist/AGENTS.md +4 -2
  75. package/templates/waitlist/app/(marketing)/layout.tsx +122 -0
  76. package/templates/waitlist/app/(marketing)/not-found.tsx +23 -0
  77. package/templates/waitlist/app/{page.tsx → (marketing)/page.tsx} +1 -1
  78. package/templates/waitlist/app/error.tsx +3 -1
  79. package/templates/waitlist/app/layout.tsx +8 -120
  80. package/templates/waitlist/app/login/page.tsx +3 -2
  81. package/templates/directory/app/not-found.tsx +0 -19
  82. package/templates/local-service/app/not-found.tsx +0 -19
  83. package/templates/restaurant/app/not-found.tsx +0 -19
  84. package/templates/shop/app/not-found.tsx +0 -19
  85. package/templates/waitlist/app/not-found.tsx +0 -19
  86. /package/templates/agency/app/{contact-form.tsx → (marketing)/contact-form.tsx} +0 -0
  87. /package/templates/agency/app/{page.tsx → (marketing)/page.tsx} +0 -0
  88. /package/templates/agency/app/{seeder.tsx → (marketing)/seeder.tsx} +0 -0
  89. /package/templates/agency/app/{work → (marketing)/work}/[slug]/page.tsx +0 -0
  90. /package/templates/agency/app/{work → (marketing)/work}/page.tsx +0 -0
  91. /package/templates/ai-chat/app/{chat-client.tsx → (chat)/chat-client.tsx} +0 -0
  92. /package/templates/ai-chat/app/{page.tsx → (chat)/page.tsx} +0 -0
  93. /package/templates/ai-studio/app/{page.tsx → (studio)/page.tsx} +0 -0
  94. /package/templates/ai-studio/app/{studio-client.tsx → (studio)/studio-client.tsx} +0 -0
  95. /package/templates/creator/app/{newsletter-signup.tsx → (marketing)/newsletter-signup.tsx} +0 -0
  96. /package/templates/creator/app/{page.tsx → (marketing)/page.tsx} +0 -0
  97. /package/templates/directory/app/{directory-browse.tsx → (marketing)/directory-browse.tsx} +0 -0
  98. /package/templates/directory/app/{page.tsx → (marketing)/page.tsx} +0 -0
  99. /package/templates/directory/app/{submit → (marketing)/submit}/page.tsx +0 -0
  100. /package/templates/directory/app/{submit-form.tsx → (marketing)/submit-form.tsx} +0 -0
  101. /package/templates/local-service/app/{booking-widget.tsx → (marketing)/booking-widget.tsx} +0 -0
  102. /package/templates/restaurant/app/{reservation-widget.tsx → (marketing)/reservation-widget.tsx} +0 -0
  103. /package/templates/saas/app/{company → (marketing)/company}/[slug]/page.tsx +0 -0
  104. /package/templates/saas/app/{compare → (marketing)/compare}/[slug]/page.tsx +0 -0
  105. /package/templates/saas/app/{page.tsx → (marketing)/page.tsx} +0 -0
  106. /package/templates/saas/app/{products → (marketing)/products}/[slug]/page.tsx +0 -0
  107. /package/templates/saas/app/{resources → (marketing)/resources}/[slug]/page.tsx +0 -0
  108. /package/templates/saas/app/{solutions → (marketing)/solutions}/[slug]/page.tsx +0 -0
  109. /package/templates/shop/app/{shop-client.tsx → (marketing)/shop-client.tsx} +0 -0
  110. /package/templates/shop/app/{success → (marketing)/success}/page.tsx +0 -0
  111. /package/templates/waitlist/app/{waitlist-hero.tsx → (marketing)/waitlist-hero.tsx} +0 -0
@@ -1,30 +1,18 @@
1
1
  import React from "react";
2
- import { Link, type PageAuth } from "@pylonsync/react";
3
2
  import { siteConfig } from "@/lib/site.config";
4
3
  import { SectionScroller } from "@/components/section-scroller";
5
4
 
6
- // A layout wraps every page. This marketing layout renders a slim nav and a
7
- // footer, both driven by lib/site.config.ts. `auth.user_id` is resolved
8
- // server-side from the session cookie before any HTML is sent, so the owner
9
- // sees "Dashboard" and everyone else sees "Sign in" — no flash, no client fetch.
10
5
  interface LayoutProps {
11
6
  children: React.ReactNode;
12
- url: string;
13
- auth: PageAuth;
14
7
  }
15
8
 
16
- export default function RootLayout({ children, url, auth }: LayoutProps) {
17
- // A guest session (minted by <EnsureGuest> for the live picker) has a
18
- // `guest_…` user id that's an anonymous visitor, NOT the signed-in owner,
19
- // so it shouldn't flip the nav to "Dashboard".
20
- const signedIn = Boolean(auth?.user_id && !auth.user_id.startsWith("guest_"));
21
- const { brand, colors } = siteConfig;
22
-
23
- // Auth + dashboard render bare (no marketing chrome). Match the path PREFIX.
24
- const path = (url ?? "").split("?")[0];
25
- const BARE_PREFIXES = ["/login", "/dashboard"];
26
- const isBare = BARE_PREFIXES.some((p) => path === p || path.startsWith(p + "/"));
27
-
9
+ // The root layout is only the document shell: <html>, <head>, <body>, and the
10
+ // site-wide theme variables. Section chrome lives in route-group layouts
11
+ // `(marketing)/layout.tsx` adds the nav + footer for the marketing pages.
12
+ // /login and /dashboard sit outside the group and render bare here: the auth
13
+ // screen and the dashboard bring their own chrome.
14
+ export default function RootLayout({ children }: LayoutProps) {
15
+ const { colors } = siteConfig;
28
16
  return (
29
17
  <html
30
18
  lang="en"
@@ -46,104 +34,8 @@ export default function RootLayout({ children, url, auth }: LayoutProps) {
46
34
  </head>
47
35
  <body className="flex min-h-screen flex-col bg-background text-foreground antialiased">
48
36
  <SectionScroller />
49
- {isBare ? (
50
- children
51
- ) : (
52
- <>
53
- <header className="sticky top-0 z-30 border-b border-zinc-200/70 bg-white/85 backdrop-blur">
54
- <div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
55
- <Link href="/" className="flex items-center gap-2">
56
- <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
57
- {brand.letter}
58
- </span>
59
- <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
60
- {brand.name}
61
- </span>
62
- </Link>
63
- <nav className="flex items-center gap-2">
64
- <a
65
- href="/#menu"
66
- className="hidden rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex"
67
- >
68
- Services
69
- </a>
70
- {signedIn ? (
71
- <Link
72
- href="/dashboard"
73
- className="inline-flex items-center rounded-full bg-zinc-900 px-3.5 py-1.5 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
74
- >
75
- Dashboard
76
- </Link>
77
- ) : (
78
- <>
79
- <Link
80
- href="/login"
81
- className="hidden rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex"
82
- >
83
- Sign in
84
- </Link>
85
- <a
86
- href="/#reserve"
87
- className="inline-flex items-center rounded-full bg-brand px-3.5 py-1.5 text-[13px] font-medium text-white transition-colors hover:opacity-90"
88
- >
89
- {siteConfig.hero.ctaLabel}
90
- </a>
91
- </>
92
- )}
93
- </nav>
94
- </div>
95
- </header>
96
-
97
- <main className="flex-1">{children}</main>
98
-
99
- <SiteFooter />
100
- </>
101
- )}
37
+ {children}
102
38
  </body>
103
39
  </html>
104
40
  );
105
41
  }
106
-
107
- function SiteFooter() {
108
- const { brand, location } = siteConfig;
109
- return (
110
- <footer className="border-t border-zinc-200/70 bg-white">
111
- <div className="mx-auto max-w-5xl px-6 py-12">
112
- <div className="flex flex-col items-start justify-between gap-6 sm:flex-row">
113
- <div className="max-w-sm">
114
- <Link href="/" className="inline-flex items-center gap-2">
115
- <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
116
- {brand.letter}
117
- </span>
118
- <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
119
- {brand.name}
120
- </span>
121
- </Link>
122
- <p className="mt-3 text-[13px] leading-relaxed text-zinc-500">
123
- {brand.footerBlurb}
124
- </p>
125
- </div>
126
- <div className="text-[13px] leading-relaxed text-zinc-500">
127
- <div className="font-medium text-zinc-900">Visit</div>
128
- <p className="mt-2 max-w-[14rem]">{location.address}</p>
129
- <p className="mt-2">{location.phone}</p>
130
- <a href={`mailto:${brand.email}`} className="mt-1 inline-block hover:text-zinc-900">
131
- {brand.email}
132
- </a>
133
- </div>
134
- </div>
135
- <div className="mt-10 flex flex-col items-start justify-between gap-3 border-t border-zinc-200/70 pt-6 text-[12px] text-zinc-400 sm:flex-row sm:items-center">
136
- <span>
137
- © {new Date().getFullYear()} {brand.copyrightName}
138
- </span>
139
- <span>
140
- Built with{" "}
141
- <a href="https://pylonsync.com" className="font-medium text-zinc-600 hover:text-zinc-900">
142
- Pylon
143
- </a>
144
- </span>
145
- </div>
146
- </div>
147
- </footer>
148
- );
149
- }
@@ -8,8 +8,9 @@ export const metadata: Metadata = {
8
8
  robots: "noindex",
9
9
  };
10
10
 
11
- // `app/login/page.tsx` → `/login`. The owner's sign-in. Rendered bare (the
12
- // layout suppresses the marketing nav/footer for /login). Already signed in?
11
+ // `app/login/page.tsx` → `/login`. The owner's sign-in. Rendered bare
12
+ // /login sits outside the `(marketing)` route group, so no marketing nav or
13
+ // footer. Already signed in?
13
14
  // Skip straight to the dashboard — `response.redirect` in the synchronous shell
14
15
  // render is a real 307 before any HTML is sent.
15
16
  export default function LoginPage({ auth, response }: PageProps) {
@@ -102,7 +102,7 @@ export function initials(name: string) {
102
102
  //
103
103
  // shape — "landscape" | "portrait" | "square" | "circle"
104
104
  // title — what photo belongs here ("A photo of your dining room")
105
- // hint — how to replace it ("Swap for an <img> in app/page.tsx")
105
+ // hint — how to replace it ("Swap for an <img> in app/(marketing)/page.tsx")
106
106
  export function ImagePlaceholder({
107
107
  shape = "landscape",
108
108
  title,
@@ -6,7 +6,7 @@ Pylon serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR from o
6
6
 
7
7
  **Unified SSR app:**
8
8
  - `app.ts`: data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
9
- - `app/`: file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
9
+ - `app/`: file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. A `(group)` directory is stripped from the URL — `app/(marketing)/about/page.tsx` still serves `/about` — so a group's `layout.tsx` gives one section its own chrome (nav, footer) without changing any path; routes outside the group render without it. `app/layout.tsx` is the document shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
10
10
  - `app/globals.css`: Tailwind v4 entrypoint (auto-compiled and injected).
11
11
  - `functions/`: server functions, one per file, `default`-exported.
12
12
  - `.pylon/`: local dev state (SQLite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
@@ -33,7 +33,9 @@ Pylon serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR from o
33
33
  - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"`; `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
34
34
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
35
35
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
36
- - **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
36
+ - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
37
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
38
+ - **`ctx.stream.write(text)` reaches only the one client holding the HTTP response; `ctx.rooms.broadcast(room, topic, data)` reaches every subscriber of the room.** Broadcast is what makes agent output survive a closed tab or reach a second device. It resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
37
39
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
38
40
  - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
39
41
 
@@ -0,0 +1,411 @@
1
+ import React from "react";
2
+ import { Link, type PageAuth } from "@pylonsync/react";
3
+ import { PRODUCTS } from "@/lib/products";
4
+ import { SOLUTIONS, RESOURCES, COMPANY, COMPARISONS } from "@/lib/site";
5
+ import { siteConfig } from "@/lib/site.config";
6
+
7
+ // `(marketing)` is a ROUTE GROUP: the parens segment is stripped from every
8
+ // URL (so `(marketing)/page.tsx` still serves `/`), and this layout wraps
9
+ // only the pages inside the group — the marketing nav up top, a fat footer
10
+ // below. Routes outside the group (/login, /signup, /dashboard) render bare
11
+ // in the root shell; the dashboard brings its own sidebar shell.
12
+ //
13
+ // A layout receives the page props plus `children`. `auth.user_id` is null for
14
+ // anonymous visitors and the signed-in user's id otherwise — resolved
15
+ // server-side from the session cookie before any HTML is sent, so the nav
16
+ // renders the right links on the first byte (no flash, no client fetch).
17
+ interface LayoutProps {
18
+ children: React.ReactNode;
19
+ auth: PageAuth;
20
+ }
21
+
22
+ // Dropdown menu data. Each item is icon + title + blurb + anchor. Swap the
23
+ // hrefs for real routes as you add pages.
24
+ type MenuItem = { icon: string; title: string; desc: string; href: string };
25
+
26
+ // Derived from the shared product list so the menu and the /products/[slug]
27
+ // pages can never drift. Each item deep-links to that product's own page.
28
+ const PRODUCT_MENU: MenuItem[] = PRODUCTS.map((p) => ({
29
+ icon: p.icon,
30
+ title: p.title,
31
+ desc: p.tagline,
32
+ href: `/products/${p.slug}`,
33
+ }));
34
+
35
+ const RESOURCES_MENU: MenuItem[] = [
36
+ { icon: "▢", title: "Docs", desc: `Set up and use ${siteConfig.brand.name}.`, href: "/resources/docs" },
37
+ { icon: "✎", title: "Guides", desc: "Playbooks and walkthroughs.", href: "/resources/guides" },
38
+ { icon: "✦", title: "Changelog", desc: `What's new in ${siteConfig.brand.name}.`, href: "/resources/changelog" },
39
+ { icon: "⌘", title: "API reference", desc: `Build on the ${siteConfig.brand.name} API.`, href: "/resources/api" },
40
+ { icon: "◈", title: "Compare", desc: `See how ${siteConfig.brand.name} stacks up.`, href: `/compare/${COMPARISONS[0].slug}` },
41
+ ];
42
+
43
+ // A hover/focus dropdown — pure CSS via `group`, so the nav stays a server
44
+ // component (no client JS). The panel opens on hover and on keyboard focus.
45
+ function NavDropdown({ label, items }: { label: string; items: MenuItem[] }) {
46
+ return (
47
+ <div className="group relative">
48
+ <button
49
+ type="button"
50
+ aria-haspopup="menu"
51
+ className="flex items-center gap-1 text-[13.5px] text-zinc-600 transition-colors hover:text-zinc-900 group-hover:text-zinc-900"
52
+ >
53
+ {label}
54
+ <Chevron />
55
+ </button>
56
+ {/* `pt-3` (padding, not margin) bridges the gap so hover doesn't drop. */}
57
+ <div className="invisible absolute left-0 top-full z-40 w-[340px] translate-y-1 pt-3 opacity-0 transition-all duration-150 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100">
58
+ <div className="rounded-2xl border border-zinc-200 bg-white p-2 shadow-[0_24px_60px_-25px_rgba(0,0,0,0.25)]">
59
+ {items.map((it) => (
60
+ <a
61
+ key={it.title}
62
+ href={it.href}
63
+ className="flex items-start gap-3 rounded-xl p-3 transition-colors hover:bg-zinc-50"
64
+ >
65
+ <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand-soft text-[13px] text-brand">
66
+ {it.icon}
67
+ </span>
68
+ <span className="min-w-0">
69
+ <span className="block text-[13.5px] font-medium text-zinc-900">
70
+ {it.title}
71
+ </span>
72
+ <span className="block text-[12.5px] leading-snug text-zinc-500">
73
+ {it.desc}
74
+ </span>
75
+ </span>
76
+ </a>
77
+ ))}
78
+ </div>
79
+ </div>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ function Chevron() {
85
+ return (
86
+ <svg
87
+ width="10"
88
+ height="10"
89
+ viewBox="0 0 10 10"
90
+ fill="none"
91
+ aria-hidden
92
+ className="text-zinc-400 transition-transform duration-200 group-hover:rotate-180"
93
+ >
94
+ <path
95
+ d="M2 3.5L5 6.5L8 3.5"
96
+ stroke="currentColor"
97
+ strokeWidth="1.5"
98
+ strokeLinecap="round"
99
+ strokeLinejoin="round"
100
+ />
101
+ </svg>
102
+ );
103
+ }
104
+
105
+ // Mobile menu — the desktop nav is `hidden md:flex`, so on phones this is the
106
+ // only way to reach Product / Resources / Pricing. Pure-CSS via native
107
+ // `<details>` (no client JS, same pattern as the FAQ + user menu); plain `<a>`
108
+ // links do a full navigation, which closes the open panel. md:hidden so it
109
+ // never shows alongside the desktop nav.
110
+ function MobileNav({ signedIn }: { signedIn: boolean }) {
111
+ return (
112
+ <details className="md:hidden">
113
+ <summary
114
+ aria-label="Open menu"
115
+ className="flex size-9 cursor-pointer select-none list-none items-center justify-center rounded-md text-zinc-700 transition-colors marker:hidden hover:bg-zinc-100 [&::-webkit-details-marker]:hidden"
116
+ >
117
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
118
+ <path d="M3 6h18M3 12h18M3 18h18" />
119
+ </svg>
120
+ </summary>
121
+ <div className="fixed inset-x-0 top-14 z-40 max-h-[calc(100vh-3.5rem)] overflow-y-auto border-b border-zinc-200 bg-white shadow-[0_24px_48px_-24px_rgba(0,0,0,0.25)]">
122
+ <div className="mx-auto max-w-5xl space-y-6 px-6 py-6">
123
+ <MobileGroup title="Product" items={PRODUCT_MENU} />
124
+ <MobileGroup title="Resources" items={RESOURCES_MENU} />
125
+ <div className="flex flex-col">
126
+ <a href="/#pricing" className="rounded-lg px-2 py-2 text-[14px] font-medium text-zinc-700 transition-colors hover:bg-zinc-50">
127
+ Pricing
128
+ </a>
129
+ <a href="/#customers" className="rounded-lg px-2 py-2 text-[14px] font-medium text-zinc-700 transition-colors hover:bg-zinc-50">
130
+ Customers
131
+ </a>
132
+ </div>
133
+ <div className="flex flex-col gap-2 border-t border-zinc-100 pt-5">
134
+ {signedIn ? (
135
+ <a href="/dashboard" className="inline-flex h-10 items-center justify-center rounded-full bg-zinc-900 text-[14px] font-medium text-white transition-colors hover:bg-zinc-700">
136
+ Open dashboard
137
+ </a>
138
+ ) : (
139
+ <>
140
+ <a href="/login" className="inline-flex h-10 items-center justify-center rounded-full border border-zinc-300 text-[14px] font-medium text-zinc-900 transition-colors hover:bg-zinc-50">
141
+ Log in
142
+ </a>
143
+ <a href="/signup" className="inline-flex h-10 items-center justify-center rounded-full bg-zinc-900 text-[14px] font-medium text-white transition-colors hover:bg-zinc-700">
144
+ Get started
145
+ </a>
146
+ </>
147
+ )}
148
+ </div>
149
+ </div>
150
+ </div>
151
+ </details>
152
+ );
153
+ }
154
+
155
+ function MobileGroup({ title, items }: { title: string; items: MenuItem[] }) {
156
+ return (
157
+ <div>
158
+ <div className="mb-1.5 px-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
159
+ {title}
160
+ </div>
161
+ <div className="flex flex-col">
162
+ {items.map((it) => (
163
+ <a
164
+ key={it.href}
165
+ href={it.href}
166
+ className="flex items-center gap-3 rounded-lg px-2 py-2 text-[14px] text-zinc-700 transition-colors hover:bg-zinc-50"
167
+ >
168
+ <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-brand-soft text-[12px] text-brand">
169
+ {it.icon}
170
+ </span>
171
+ {it.title}
172
+ </a>
173
+ ))}
174
+ </div>
175
+ </div>
176
+ );
177
+ }
178
+
179
+ // `<main>` is full-bleed — each page owns its own container so the landing
180
+ // page can run edge-to-edge while inner pages stay centered. Rebrand "Acme"
181
+ // to your product.
182
+ export default function MarketingLayout({ children, auth }: LayoutProps) {
183
+ const signedIn = Boolean(auth?.user_id);
184
+ return (
185
+ <>
186
+ <header className="sticky top-0 z-30 bg-white/80 backdrop-blur">
187
+ <div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
188
+ <div className="flex items-center gap-8">
189
+ <Link href="/" className="flex items-center gap-2">
190
+ <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
191
+ {siteConfig.brand.letter}
192
+ </span>
193
+ <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
194
+ {siteConfig.brand.name}
195
+ </span>
196
+ </Link>
197
+ <nav className="hidden items-center gap-6 md:flex">
198
+ <NavDropdown label="Product" items={PRODUCT_MENU} />
199
+ <NavDropdown label="Resources" items={RESOURCES_MENU} />
200
+ <Link
201
+ href="/#pricing"
202
+ className="text-[13.5px] text-zinc-600 transition-colors hover:text-zinc-900"
203
+ >
204
+ Pricing
205
+ </Link>
206
+ <Link
207
+ href="/#customers"
208
+ className="text-[13.5px] text-zinc-600 transition-colors hover:text-zinc-900"
209
+ >
210
+ Customers
211
+ </Link>
212
+ </nav>
213
+ </div>
214
+ <nav className="flex items-center gap-2">
215
+ <MobileNav signedIn={signedIn} />
216
+ {signedIn ? (
217
+ <Link
218
+ href="/dashboard"
219
+ className="inline-flex items-center rounded-full bg-zinc-900 px-3.5 py-1.5 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
220
+ >
221
+ Dashboard
222
+ </Link>
223
+ ) : (
224
+ <>
225
+ <Link
226
+ href="/login"
227
+ className="hidden rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900 sm:inline-flex"
228
+ >
229
+ Log in
230
+ </Link>
231
+ <Link
232
+ href="/signup"
233
+ className="inline-flex items-center rounded-full bg-zinc-900 px-3.5 py-1.5 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
234
+ >
235
+ Get started
236
+ </Link>
237
+ </>
238
+ )}
239
+ </nav>
240
+ </div>
241
+ </header>
242
+
243
+ <main className="flex-1">{children}</main>
244
+
245
+ <SiteFooter />
246
+ </>
247
+ );
248
+ }
249
+
250
+ // Footer link groups, derived from the same data the pages use so every link
251
+ // resolves to a real route. Each link carries a small icon (except Compare).
252
+ type FooterLink = { label: string; href: string; icon?: string };
253
+ type FooterGroupData = { title: string; links: FooterLink[] };
254
+
255
+ const SOLUTION_ICONS = ["◆", "◈", "▣", "◎"];
256
+ const RESOURCE_ICONS: Record<string, string> = {
257
+ docs: "▢",
258
+ guides: "✎",
259
+ changelog: "✦",
260
+ api: "⌘",
261
+ status: "◉",
262
+ };
263
+ const COMPANY_ICONS = ["◍", "≣", "◆", "✉", "▢"];
264
+
265
+ const PRODUCT_GROUP: FooterGroupData = {
266
+ title: "Product",
267
+ links: [
268
+ ...PRODUCTS.map((p) => ({
269
+ label: p.title,
270
+ href: `/products/${p.slug}`,
271
+ icon: p.icon,
272
+ })),
273
+ { label: "Pricing", href: "/#pricing", icon: "◷" },
274
+ ],
275
+ };
276
+ const SOLUTIONS_GROUP: FooterGroupData = {
277
+ title: "Solutions",
278
+ links: SOLUTIONS.map((s, i) => ({
279
+ label: s.navLabel,
280
+ href: `/solutions/${s.slug}`,
281
+ icon: SOLUTION_ICONS[i] ?? "◆",
282
+ })),
283
+ };
284
+ const RESOURCES_GROUP: FooterGroupData = {
285
+ title: "Resources",
286
+ links: RESOURCES.map((r) => ({
287
+ label: r.navLabel,
288
+ href: `/resources/${r.slug}`,
289
+ icon: RESOURCE_ICONS[r.slug] ?? "▢",
290
+ })),
291
+ };
292
+ const COMPANY_GROUP: FooterGroupData = {
293
+ title: "Company",
294
+ links: COMPANY.map((c, i) => ({
295
+ label: c.navLabel,
296
+ href: `/company/${c.slug}`,
297
+ icon: COMPANY_ICONS[i] ?? "◍",
298
+ })),
299
+ };
300
+ const COMPARE_GROUP: FooterGroupData = {
301
+ title: "Compare",
302
+ links: COMPARISONS.map((c) => ({
303
+ label: c.navLabel,
304
+ href: `/compare/${c.slug}`,
305
+ })),
306
+ };
307
+
308
+ function FooterGroup({
309
+ group,
310
+ className = "",
311
+ }: {
312
+ group: FooterGroupData;
313
+ className?: string;
314
+ }) {
315
+ return (
316
+ <div className={className}>
317
+ <div className="text-[11px] font-semibold uppercase tracking-wider text-zinc-900">
318
+ {group.title}
319
+ </div>
320
+ <div className="mt-4 flex flex-col gap-2.5">
321
+ {group.links.map((l) => (
322
+ <Link
323
+ key={l.href}
324
+ href={l.href}
325
+ className="group flex items-center gap-2 text-[13px] text-zinc-500 transition-colors hover:text-zinc-900"
326
+ >
327
+ {l.icon ? (
328
+ <span className="flex w-3.5 justify-center text-[11px] text-zinc-400 transition-colors group-hover:text-zinc-600">
329
+ {l.icon}
330
+ </span>
331
+ ) : null}
332
+ {l.label}
333
+ </Link>
334
+ ))}
335
+ </div>
336
+ </div>
337
+ );
338
+ }
339
+
340
+ function SiteFooter() {
341
+ return (
342
+ <footer className="border-t border-zinc-200/70 bg-white">
343
+ <div className="mx-auto max-w-5xl px-6 py-16">
344
+ {/* Brand block */}
345
+ <div className="max-w-xs">
346
+ <Link href="/" className="inline-flex items-center gap-2">
347
+ <span className="flex size-7 items-center justify-center rounded-lg bg-zinc-900 text-sm font-bold text-white">
348
+ {siteConfig.brand.letter}
349
+ </span>
350
+ <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
351
+ {siteConfig.brand.name}
352
+ </span>
353
+ </Link>
354
+ <p className="mt-4 text-[13px] leading-relaxed text-zinc-500">
355
+ {siteConfig.brand.footerBlurb}
356
+ </p>
357
+ <div className="mt-5 flex items-center gap-4">
358
+ {siteConfig.brand.socials.map((s) => (
359
+ <a
360
+ key={s.label}
361
+ href={s.href}
362
+ aria-label={s.label}
363
+ className="text-zinc-400 transition-colors hover:text-zinc-900"
364
+ >
365
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
366
+ <path d={s.path} />
367
+ </svg>
368
+ </a>
369
+ ))}
370
+ <a
371
+ href={`mailto:${siteConfig.brand.email}`}
372
+ aria-label="Email"
373
+ className="text-zinc-400 transition-colors hover:text-zinc-900"
374
+ >
375
+ <svg
376
+ width="16"
377
+ height="16"
378
+ viewBox="0 0 24 24"
379
+ fill="none"
380
+ stroke="currentColor"
381
+ strokeWidth="2"
382
+ >
383
+ <rect x="3" y="5" width="18" height="14" rx="2" />
384
+ <path d="m3 7 9 6 9-6" />
385
+ </svg>
386
+ </a>
387
+ </div>
388
+ </div>
389
+
390
+ {/* Link columns */}
391
+ <div className="mt-12 grid gap-x-8 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
392
+ <div>
393
+ <FooterGroup group={PRODUCT_GROUP} />
394
+ <FooterGroup group={SOLUTIONS_GROUP} className="mt-10" />
395
+ </div>
396
+ <div>
397
+ <FooterGroup group={RESOURCES_GROUP} />
398
+ <FooterGroup group={COMPANY_GROUP} className="mt-10" />
399
+ </div>
400
+ <div>
401
+ <FooterGroup group={COMPARE_GROUP} />
402
+ </div>
403
+ </div>
404
+
405
+ <div className="mt-14 border-t border-zinc-200/70 pt-6 text-[12px] text-zinc-400">
406
+ <span>© {new Date().getFullYear()} {siteConfig.brand.copyrightName}</span>
407
+ </div>
408
+ </div>
409
+ </footer>
410
+ );
411
+ }
@@ -2,14 +2,18 @@ import React from "react";
2
2
  import { Link, useRouter, type NotFoundProps } from "@pylonsync/react";
3
3
  import { Button } from "@/components/ui/button";
4
4
 
5
- // `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL (and when
6
- // a page calls `response.notFound()`). It's HYDRATED, so it's interactive:
7
- // the buttons below use the client router. Not-found boundaries receive the
8
- // standard page props (and, matching Next, no `reset`).
5
+ // `(marketing)/not-found.tsx` → rendered at HTTP 404 for any unmatched URL
6
+ // (and when a page calls `response.notFound()`). It lives inside the
7
+ // `(marketing)` group on purpose: a group segment adds no URL prefix, so this
8
+ // is still the root 404 boundary, but it wraps in the marketing layout — a
9
+ // missing URL gets the site nav + footer instead of a bare page. It's
10
+ // HYDRATED, so it's interactive: the buttons below use the client router.
11
+ // Not-found boundaries receive the standard page props (and, matching Next,
12
+ // no `reset`).
9
13
  export default function NotFound(_props: NotFoundProps) {
10
14
  const router = useRouter();
11
15
  return (
12
- <div className="space-y-6">
16
+ <div className="mx-auto w-full max-w-xl space-y-6 px-6 py-24">
13
17
  <section>
14
18
  <h1 className="text-2xl font-semibold tracking-tight">404</h1>
15
19
  <p className="mt-2 text-muted-foreground">
@@ -18,8 +18,9 @@ export const metadata: Metadata = {
18
18
  // active org (`auth.tenant_id`) + this org's rows are read during the render
19
19
  // via `serverData` + React 19 `use()` — resolved server-side and replayed on
20
20
  // hydration, so the dashboard paints with real data on the first byte (no
21
- // client fetch, no empty-state flash). The marketing nav/footer are suppressed
22
- // for /dashboard in the root layout; the shell is the only chrome here.
21
+ // client fetch, no empty-state flash). /dashboard sits outside the
22
+ // `(marketing)` route group, so the marketing nav/footer never render here;
23
+ // the shell is the only chrome.
23
24
  export default function DashboardPage({
24
25
  auth,
25
26
  response,