@pylonsync/create-pylon 0.3.373 → 0.3.375
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.
- package/package.json +1 -1
- package/templates/_root/AGENTS.md +1 -1
- package/templates/agency/AGENTS.md +1 -1
- package/templates/agency/app/(marketing)/layout.tsx +136 -0
- package/templates/{creator/app → agency/app/(marketing)}/not-found.tsx +6 -2
- package/templates/agency/app/error.tsx +6 -3
- package/templates/agency/app/layout.tsx +8 -134
- package/templates/agency/app/login/page.tsx +3 -2
- package/templates/ai-chat/AGENTS.md +1 -1
- package/templates/ai-chat/app/(chat)/layout.tsx +46 -0
- package/templates/ai-chat/app/{not-found.tsx → (chat)/not-found.tsx} +5 -2
- package/templates/ai-chat/app/error.tsx +6 -3
- package/templates/ai-chat/app/layout.tsx +6 -39
- package/templates/ai-chat/app/login/page.tsx +2 -1
- package/templates/ai-studio/AGENTS.md +1 -1
- package/templates/ai-studio/app/(studio)/layout.tsx +46 -0
- package/templates/{agency/app → ai-studio/app/(studio)}/not-found.tsx +5 -2
- package/templates/ai-studio/app/error.tsx +6 -3
- package/templates/ai-studio/app/layout.tsx +6 -39
- package/templates/ai-studio/app/login/page.tsx +2 -1
- package/templates/barebones/AGENTS.md +1 -1
- package/templates/chat/AGENTS.md +1 -1
- package/templates/consumer/AGENTS.md +1 -1
- package/templates/creator/AGENTS.md +1 -1
- package/templates/creator/app/(marketing)/layout.tsx +122 -0
- package/templates/{ai-studio/app → creator/app/(marketing)}/not-found.tsx +6 -2
- package/templates/creator/app/error.tsx +6 -3
- package/templates/creator/app/layout.tsx +8 -120
- package/templates/creator/app/login/page.tsx +3 -2
- package/templates/crm/AGENTS.md +1 -1
- package/templates/directory/AGENTS.md +1 -1
- package/templates/directory/app/(marketing)/layout.tsx +133 -0
- package/templates/directory/app/(marketing)/not-found.tsx +23 -0
- package/templates/directory/app/error.tsx +6 -3
- package/templates/directory/app/layout.tsx +8 -131
- package/templates/directory/app/login/page.tsx +3 -2
- package/templates/helpdesk/AGENTS.md +1 -1
- package/templates/inventory/AGENTS.md +1 -1
- package/templates/invoices/AGENTS.md +1 -1
- package/templates/local-service/AGENTS.md +1 -1
- package/templates/local-service/app/(marketing)/layout.tsx +119 -0
- package/templates/local-service/app/(marketing)/not-found.tsx +23 -0
- package/templates/local-service/app/{page.tsx → (marketing)/page.tsx} +2 -2
- package/templates/local-service/app/error.tsx +3 -1
- package/templates/local-service/app/layout.tsx +8 -116
- package/templates/local-service/app/login/page.tsx +3 -2
- package/templates/local-service/components/marketing.tsx +1 -1
- package/templates/marketplace/AGENTS.md +1 -1
- package/templates/projects/AGENTS.md +1 -1
- package/templates/restaurant/AGENTS.md +1 -1
- package/templates/restaurant/app/(marketing)/layout.tsx +119 -0
- package/templates/restaurant/app/(marketing)/not-found.tsx +23 -0
- package/templates/restaurant/app/{page.tsx → (marketing)/page.tsx} +2 -2
- package/templates/restaurant/app/error.tsx +3 -1
- package/templates/restaurant/app/layout.tsx +8 -116
- package/templates/restaurant/app/login/page.tsx +3 -2
- package/templates/restaurant/components/marketing.tsx +1 -1
- package/templates/saas/AGENTS.md +1 -1
- package/templates/saas/app/{auth-shell.tsx → (auth)/layout.tsx} +21 -29
- package/templates/saas/app/(auth)/login/page.tsx +33 -0
- package/templates/saas/app/(auth)/signup/page.tsx +32 -0
- package/templates/saas/app/(marketing)/layout.tsx +411 -0
- package/templates/saas/app/{not-found.tsx → (marketing)/not-found.tsx} +9 -5
- package/templates/saas/app/dashboard/billing/page.tsx +7 -20
- package/templates/saas/app/dashboard/layout.tsx +58 -0
- package/templates/saas/app/dashboard/members/page.tsx +9 -22
- package/templates/saas/app/dashboard/page.tsx +15 -31
- package/templates/saas/app/dashboard/projects/page.tsx +4 -23
- package/templates/saas/app/dashboard/settings/page.tsx +7 -19
- package/templates/saas/app/error.tsx +7 -5
- package/templates/saas/app/layout.tsx +7 -408
- package/templates/saas/components/dashboard-shell.tsx +10 -6
- package/templates/shop/AGENTS.md +1 -1
- package/templates/shop/app/(marketing)/layout.tsx +122 -0
- package/templates/shop/app/(marketing)/not-found.tsx +23 -0
- package/templates/shop/app/{page.tsx → (marketing)/page.tsx} +1 -1
- package/templates/shop/app/error.tsx +3 -1
- package/templates/shop/app/layout.tsx +8 -120
- package/templates/shop/app/login/page.tsx +3 -2
- package/templates/todo/AGENTS.md +1 -1
- package/templates/waitlist/AGENTS.md +1 -1
- package/templates/waitlist/app/(marketing)/layout.tsx +122 -0
- package/templates/waitlist/app/(marketing)/not-found.tsx +23 -0
- package/templates/waitlist/app/{page.tsx → (marketing)/page.tsx} +1 -1
- package/templates/waitlist/app/error.tsx +3 -1
- package/templates/waitlist/app/layout.tsx +8 -120
- package/templates/waitlist/app/login/page.tsx +3 -2
- package/templates/directory/app/not-found.tsx +0 -19
- package/templates/local-service/app/not-found.tsx +0 -19
- package/templates/restaurant/app/not-found.tsx +0 -19
- package/templates/saas/app/login/page.tsx +0 -27
- package/templates/saas/app/signup/page.tsx +0 -24
- package/templates/shop/app/not-found.tsx +0 -19
- package/templates/waitlist/app/not-found.tsx +0 -19
- /package/templates/agency/app/{contact-form.tsx → (marketing)/contact-form.tsx} +0 -0
- /package/templates/agency/app/{page.tsx → (marketing)/page.tsx} +0 -0
- /package/templates/agency/app/{seeder.tsx → (marketing)/seeder.tsx} +0 -0
- /package/templates/agency/app/{work → (marketing)/work}/[slug]/page.tsx +0 -0
- /package/templates/agency/app/{work → (marketing)/work}/page.tsx +0 -0
- /package/templates/ai-chat/app/{chat-client.tsx → (chat)/chat-client.tsx} +0 -0
- /package/templates/ai-chat/app/{page.tsx → (chat)/page.tsx} +0 -0
- /package/templates/ai-studio/app/{page.tsx → (studio)/page.tsx} +0 -0
- /package/templates/ai-studio/app/{studio-client.tsx → (studio)/studio-client.tsx} +0 -0
- /package/templates/creator/app/{newsletter-signup.tsx → (marketing)/newsletter-signup.tsx} +0 -0
- /package/templates/creator/app/{page.tsx → (marketing)/page.tsx} +0 -0
- /package/templates/directory/app/{directory-browse.tsx → (marketing)/directory-browse.tsx} +0 -0
- /package/templates/directory/app/{page.tsx → (marketing)/page.tsx} +0 -0
- /package/templates/directory/app/{submit → (marketing)/submit}/page.tsx +0 -0
- /package/templates/directory/app/{submit-form.tsx → (marketing)/submit-form.tsx} +0 -0
- /package/templates/local-service/app/{booking-widget.tsx → (marketing)/booking-widget.tsx} +0 -0
- /package/templates/restaurant/app/{reservation-widget.tsx → (marketing)/reservation-widget.tsx} +0 -0
- /package/templates/saas/app/{auth-form.tsx → (auth)/auth-form.tsx} +0 -0
- /package/templates/saas/app/{company → (marketing)/company}/[slug]/page.tsx +0 -0
- /package/templates/saas/app/{compare → (marketing)/compare}/[slug]/page.tsx +0 -0
- /package/templates/saas/app/{page.tsx → (marketing)/page.tsx} +0 -0
- /package/templates/saas/app/{products → (marketing)/products}/[slug]/page.tsx +0 -0
- /package/templates/saas/app/{resources → (marketing)/resources}/[slug]/page.tsx +0 -0
- /package/templates/saas/app/{solutions → (marketing)/solutions}/[slug]/page.tsx +0 -0
- /package/templates/shop/app/{shop-client.tsx → (marketing)/shop-client.tsx} +0 -0
- /package/templates/shop/app/{success → (marketing)/success}/page.tsx +0 -0
- /package/templates/waitlist/app/{waitlist-hero.tsx → (marketing)/waitlist-hero.tsx} +0 -0
|
@@ -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. The other sections bring their own layouts: `(auth)` the split-screen
|
|
11
|
+
// frame, `dashboard/` the 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
|
-
// `
|
|
6
|
-
// a page calls `response.notFound()`). It
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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">
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import React, { use } from "react";
|
|
2
2
|
import { type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
|
-
import { DashboardShell } from "@/components/dashboard-shell";
|
|
4
3
|
import { Billing, type Subscription } from "../dashboard-client";
|
|
5
4
|
|
|
6
5
|
export const metadata: Metadata = {
|
|
@@ -11,20 +10,15 @@ export const metadata: Metadata = {
|
|
|
11
10
|
// `/dashboard/billing` — the active workspace's plan + Stripe checkout/portal.
|
|
12
11
|
// The StripeSubscription row is resolved server-side (the @pylonsync/stripe
|
|
13
12
|
// read policy scopes it to the active tenant), so the plan paints with no flash;
|
|
14
|
-
// upgrade/manage open Stripe and the webhook keeps the row in sync.
|
|
13
|
+
// upgrade/manage open Stripe and the webhook keeps the row in sync. Auth gate +
|
|
14
|
+
// shell chrome come from the dashboard layout.
|
|
15
15
|
const ACTIVE = ["active", "trialing", "past_due"];
|
|
16
16
|
|
|
17
17
|
export default function BillingPage({ auth, response, serverData }: PageProps) {
|
|
18
|
-
if (!auth.user_id) {
|
|
19
|
-
response.redirect("/login");
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
18
|
if (!auth.tenant_id) {
|
|
23
19
|
response.redirect("/dashboard");
|
|
24
20
|
return null;
|
|
25
21
|
}
|
|
26
|
-
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
27
|
-
const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
|
|
28
22
|
const subs = use(serverData.list<Subscription>("StripeSubscription"));
|
|
29
23
|
const subscription =
|
|
30
24
|
subs.find(
|
|
@@ -33,17 +27,10 @@ export default function BillingPage({ auth, response, serverData }: PageProps) {
|
|
|
33
27
|
subs[0] ??
|
|
34
28
|
null;
|
|
35
29
|
return (
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
>
|
|
42
|
-
<Billing
|
|
43
|
-
tenantId={auth.tenant_id}
|
|
44
|
-
role={auth.roles?.[0] ?? ""}
|
|
45
|
-
subscription={subscription}
|
|
46
|
-
/>
|
|
47
|
-
</DashboardShell>
|
|
30
|
+
<Billing
|
|
31
|
+
tenantId={auth.tenant_id}
|
|
32
|
+
role={auth.roles?.[0] ?? ""}
|
|
33
|
+
subscription={subscription}
|
|
34
|
+
/>
|
|
48
35
|
);
|
|
49
36
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React, { use } from "react";
|
|
2
|
+
import type { PageAuth, ServerData, SsrResponse } from "@pylonsync/react";
|
|
3
|
+
import { DashboardShell, NAV } from "@/components/dashboard-shell";
|
|
4
|
+
|
|
5
|
+
// `app/dashboard/layout.tsx` wraps every /dashboard page in the sidebar +
|
|
6
|
+
// top-bar shell, so no page repeats the chrome. This is the thin-server-
|
|
7
|
+
// wrapper pattern: the layout gates auth, resolves the chrome's data
|
|
8
|
+
// server-side (user email, org name), derives the active nav item + title
|
|
9
|
+
// from the request URL, and hands the interactive shell (a `"use client"`
|
|
10
|
+
// component) its props.
|
|
11
|
+
interface LayoutProps {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
url: string;
|
|
14
|
+
auth: PageAuth;
|
|
15
|
+
response: SsrResponse;
|
|
16
|
+
serverData: ServerData;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function DashboardLayout({
|
|
20
|
+
children,
|
|
21
|
+
url,
|
|
22
|
+
auth,
|
|
23
|
+
response,
|
|
24
|
+
serverData,
|
|
25
|
+
}: LayoutProps) {
|
|
26
|
+
// The layout renders before any page below it, so gating here protects the
|
|
27
|
+
// whole /dashboard section: signed-out visitors get a real 307 to /login
|
|
28
|
+
// and no page code runs.
|
|
29
|
+
if (!auth.user_id) {
|
|
30
|
+
response.redirect("/login");
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
// No active workspace: render the page bare — /dashboard shows the
|
|
34
|
+
// full-screen ProvisionWorkspace flow, and the subpages redirect back to
|
|
35
|
+
// /dashboard themselves. The shell's org switcher has nothing to show yet.
|
|
36
|
+
if (!auth.tenant_id) {
|
|
37
|
+
return <>{children}</>;
|
|
38
|
+
}
|
|
39
|
+
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
40
|
+
const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
|
|
41
|
+
// Longest matching NAV href wins so /dashboard/projects highlights
|
|
42
|
+
// Projects, not Overview.
|
|
43
|
+
const path = (url ?? "").split("?")[0];
|
|
44
|
+
const nav =
|
|
45
|
+
NAV.filter((n) => path === n.href || path.startsWith(n.href + "/")).sort(
|
|
46
|
+
(a, b) => b.href.length - a.href.length,
|
|
47
|
+
)[0] ?? NAV[0];
|
|
48
|
+
return (
|
|
49
|
+
<DashboardShell
|
|
50
|
+
active={nav.key}
|
|
51
|
+
title={nav.label}
|
|
52
|
+
userEmail={me?.email ?? ""}
|
|
53
|
+
orgName={org?.name}
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
</DashboardShell>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from "react";
|
|
2
2
|
import { type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
|
-
import { DashboardShell } from "@/components/dashboard-shell";
|
|
4
3
|
import { Members } from "../dashboard-client";
|
|
5
4
|
|
|
6
5
|
export const metadata: Metadata = {
|
|
@@ -10,30 +9,18 @@ export const metadata: Metadata = {
|
|
|
10
9
|
|
|
11
10
|
// `/dashboard/members` — the active org's roster + invites. The roster (with
|
|
12
11
|
// real emails) is loaded client-side from the framework's org-members endpoint;
|
|
13
|
-
// invites are gated to owners/admins both here and on the server.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
response.redirect("/login");
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
12
|
+
// invites are gated to owners/admins both here and on the server. Auth gate +
|
|
13
|
+
// shell chrome come from the dashboard layout.
|
|
14
|
+
export default function MembersPage({ auth, response }: PageProps) {
|
|
19
15
|
if (!auth.tenant_id) {
|
|
20
16
|
response.redirect("/dashboard");
|
|
21
17
|
return null;
|
|
22
18
|
}
|
|
23
|
-
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
24
|
-
const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
|
|
25
19
|
return (
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
>
|
|
32
|
-
<Members
|
|
33
|
-
tenantId={auth.tenant_id}
|
|
34
|
-
currentUserId={auth.user_id}
|
|
35
|
-
role={auth.roles?.[0] ?? ""}
|
|
36
|
-
/>
|
|
37
|
-
</DashboardShell>
|
|
20
|
+
<Members
|
|
21
|
+
tenantId={auth.tenant_id}
|
|
22
|
+
currentUserId={auth.user_id!}
|
|
23
|
+
role={auth.roles?.[0] ?? ""}
|
|
24
|
+
/>
|
|
38
25
|
);
|
|
39
26
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import React, { use } from "react";
|
|
2
2
|
import { type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
|
-
import { DashboardShell } from "@/components/dashboard-shell";
|
|
4
3
|
import { ProvisionWorkspace } from "./provision-workspace";
|
|
5
4
|
import {
|
|
6
5
|
Overview,
|
|
@@ -14,28 +13,20 @@ export const metadata: Metadata = {
|
|
|
14
13
|
robots: "noindex",
|
|
15
14
|
};
|
|
16
15
|
|
|
17
|
-
// `app/dashboard/page.tsx` → `/dashboard`.
|
|
18
|
-
//
|
|
19
|
-
// via `serverData` + React 19 `use()` — resolved server-side and
|
|
20
|
-
// hydration, so the dashboard paints with real data on the first
|
|
21
|
-
// client fetch, no empty-state flash).
|
|
22
|
-
|
|
23
|
-
export default function DashboardPage({
|
|
24
|
-
auth,
|
|
25
|
-
response,
|
|
26
|
-
serverData,
|
|
27
|
-
}: PageProps) {
|
|
28
|
-
if (!auth.user_id) {
|
|
29
|
-
response.redirect("/login");
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
16
|
+
// `app/dashboard/page.tsx` → `/dashboard`. The dashboard layout owns the auth
|
|
17
|
+
// gate and the shell chrome; this page reads the active org's rows during the
|
|
18
|
+
// render via `serverData` + React 19 `use()` — resolved server-side and
|
|
19
|
+
// replayed on hydration, so the dashboard paints with real data on the first
|
|
20
|
+
// byte (no client fetch, no empty-state flash).
|
|
21
|
+
export default function DashboardPage({ auth, serverData }: PageProps) {
|
|
32
22
|
// No active workspace (signup's auto-provision failed, or the user left/
|
|
33
23
|
// deleted their last org). Every read below is tenant-scoped, so instead of
|
|
34
24
|
// an empty shell, provision one client-side and reload into a ready dashboard.
|
|
25
|
+
// The layout renders this bare (no shell) when there's no tenant.
|
|
35
26
|
if (!auth.tenant_id) {
|
|
36
27
|
return <ProvisionWorkspace />;
|
|
37
28
|
}
|
|
38
|
-
const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
|
|
29
|
+
const me = use(serverData.get<{ email?: string }>("User", auth.user_id!));
|
|
39
30
|
const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
|
|
40
31
|
const projects = use(serverData.list<Project>("Project"));
|
|
41
32
|
const members = use(serverData.list<OrgMemberRow>("OrgMember"));
|
|
@@ -50,20 +41,13 @@ export default function DashboardPage({
|
|
|
50
41
|
);
|
|
51
42
|
const plan = active ? active.plan : "free";
|
|
52
43
|
return (
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
title="Overview"
|
|
56
|
-
userEmail={me?.email ?? ""}
|
|
44
|
+
<Overview
|
|
45
|
+
tenantId={auth.tenant_id}
|
|
57
46
|
orgName={org?.name}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
projects={projects}
|
|
64
|
-
memberCount={memberCount}
|
|
65
|
-
plan={plan}
|
|
66
|
-
/>
|
|
67
|
-
</DashboardShell>
|
|
47
|
+
userEmail={me?.email}
|
|
48
|
+
projects={projects}
|
|
49
|
+
memberCount={memberCount}
|
|
50
|
+
plan={plan}
|
|
51
|
+
/>
|
|
68
52
|
);
|
|
69
53
|
}
|