@pylonsync/create-pylon 0.3.268 → 0.3.270

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 (76) hide show
  1. package/bin/create-pylon.js +11 -9
  2. package/package.json +1 -1
  3. package/templates/b2b/app/layout.tsx +1 -1
  4. package/templates/b2b/app/page.tsx +2 -2
  5. package/templates/b2b/tsconfig.json +1 -1
  6. package/templates/barebones/app/page.tsx +1 -1
  7. package/templates/barebones/tsconfig.json +1 -1
  8. package/templates/chat/app/page.tsx +1 -1
  9. package/templates/chat/tsconfig.json +1 -1
  10. package/templates/consumer/app/page.tsx +1 -1
  11. package/templates/consumer/tsconfig.json +1 -1
  12. package/templates/default/.env.example +19 -0
  13. package/templates/default/README.md +85 -0
  14. package/templates/default/app/auth-form.tsx +218 -0
  15. package/templates/default/app/auth-shell.tsx +76 -0
  16. package/templates/default/app/company/[slug]/page.tsx +28 -0
  17. package/templates/default/app/compare/[slug]/page.tsx +27 -0
  18. package/templates/default/app/dashboard/billing/page.tsx +49 -0
  19. package/templates/default/app/dashboard/dashboard-client.tsx +832 -0
  20. package/templates/default/app/dashboard/members/page.tsx +37 -0
  21. package/templates/default/app/dashboard/page.tsx +64 -0
  22. package/templates/default/app/dashboard/projects/page.tsx +37 -0
  23. package/templates/default/app/dashboard/settings/page.tsx +45 -0
  24. package/templates/{ssr → default}/app/globals.css +14 -0
  25. package/templates/default/app/layout.tsx +466 -0
  26. package/templates/default/app/login/page.tsx +27 -0
  27. package/templates/default/app/onboarding/onboarding-client.tsx +261 -0
  28. package/templates/default/app/onboarding/page.tsx +29 -0
  29. package/templates/default/app/page.tsx +653 -0
  30. package/templates/default/app/products/[slug]/page.tsx +134 -0
  31. package/templates/default/app/resources/[slug]/page.tsx +28 -0
  32. package/templates/default/app/signup/page.tsx +24 -0
  33. package/templates/default/app/sitemap.ts +40 -0
  34. package/templates/default/app/solutions/[slug]/page.tsx +28 -0
  35. package/templates/default/app.ts +194 -0
  36. package/templates/default/components/dashboard-shell.tsx +150 -0
  37. package/templates/default/components/marketing.tsx +370 -0
  38. package/templates/default/functions/_pylonStripeFindActiveSubForReference.ts +3 -0
  39. package/templates/default/functions/_pylonStripeFindByCustomerId.ts +3 -0
  40. package/templates/default/functions/_pylonStripeGetCustomerHolder.ts +3 -0
  41. package/templates/default/functions/_pylonStripeListSubsForReference.ts +3 -0
  42. package/templates/default/functions/_pylonStripeOrgMembership.ts +3 -0
  43. package/templates/default/functions/_pylonStripeSetCustomerId.ts +3 -0
  44. package/templates/default/functions/_pylonStripeUpsertSubscription.ts +3 -0
  45. package/templates/default/functions/cancelSubscription.ts +3 -0
  46. package/templates/default/functions/createBillingPortalSession.ts +3 -0
  47. package/templates/default/functions/createCheckoutSession.ts +3 -0
  48. package/templates/default/functions/restoreSubscription.ts +3 -0
  49. package/templates/default/functions/stripeWebhook.ts +3 -0
  50. package/templates/default/lib/billing.ts +46 -0
  51. package/templates/default/lib/products.ts +122 -0
  52. package/templates/default/lib/site.ts +261 -0
  53. package/templates/{ssr → default}/package.json +2 -0
  54. package/templates/{ssr → default}/tsconfig.json +2 -2
  55. package/templates/todo/app/page.tsx +1 -1
  56. package/templates/todo/tsconfig.json +1 -1
  57. package/templates/ssr/README.md +0 -56
  58. package/templates/ssr/app/auth-form.tsx +0 -142
  59. package/templates/ssr/app/dashboard/dashboard-client.tsx +0 -116
  60. package/templates/ssr/app/dashboard/page.tsx +0 -70
  61. package/templates/ssr/app/layout.tsx +0 -71
  62. package/templates/ssr/app/login/page.tsx +0 -47
  63. package/templates/ssr/app/page.tsx +0 -114
  64. package/templates/ssr/app/signup/page.tsx +0 -44
  65. package/templates/ssr/app/sitemap.ts +0 -27
  66. package/templates/ssr/app.ts +0 -94
  67. package/templates/ssr/functions/_keep.ts +0 -13
  68. /package/templates/{ssr → default}/AGENTS.md +0 -0
  69. /package/templates/{ssr → default}/app/error.tsx +0 -0
  70. /package/templates/{ssr → default}/app/not-found.tsx +0 -0
  71. /package/templates/{ssr → default}/app/robots.ts +0 -0
  72. /package/templates/{ssr → default}/components/ui/button.tsx +0 -0
  73. /package/templates/{ssr → default}/components/ui/card.tsx +0 -0
  74. /package/templates/{ssr → default}/components.json +0 -0
  75. /package/templates/{ssr → default}/gitignore +0 -0
  76. /package/templates/{ssr → default}/lib/utils.ts +0 -0
@@ -0,0 +1,466 @@
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
+
6
+ // A layout receives the page props plus `children`. `auth.user_id` is null for
7
+ // anonymous visitors and the signed-in user's id otherwise — resolved
8
+ // server-side from the session cookie before any HTML is sent, so the nav
9
+ // renders the right links on the first byte (no flash, no client fetch).
10
+ interface LayoutProps {
11
+ children: React.ReactNode;
12
+ url: string;
13
+ auth: PageAuth;
14
+ }
15
+
16
+ // Dropdown menu data. Each item is icon + title + blurb + anchor. Swap the
17
+ // hrefs for real routes as you add pages.
18
+ type MenuItem = { icon: string; title: string; desc: string; href: string };
19
+
20
+ // Derived from the shared product list so the menu and the /products/[slug]
21
+ // pages can never drift. Each item deep-links to that product's own page.
22
+ const PRODUCT_MENU: MenuItem[] = PRODUCTS.map((p) => ({
23
+ icon: p.icon,
24
+ title: p.title,
25
+ desc: p.tagline,
26
+ href: `/products/${p.slug}`,
27
+ }));
28
+
29
+ const RESOURCES_MENU: MenuItem[] = [
30
+ { icon: "▢", title: "Docs", desc: "Set up and use Acme.", href: "/resources/docs" },
31
+ { icon: "✎", title: "Guides", desc: "Playbooks and walkthroughs.", href: "/resources/guides" },
32
+ { icon: "✦", title: "Changelog", desc: "What's new in Acme.", href: "/resources/changelog" },
33
+ { icon: "⌘", title: "API reference", desc: "Build on the Acme API.", href: "/resources/api" },
34
+ { icon: "◈", title: "Compare", desc: "See how Acme stacks up.", href: `/compare/${COMPARISONS[0].slug}` },
35
+ ];
36
+
37
+ // A hover/focus dropdown — pure CSS via `group`, so the nav stays a server
38
+ // component (no client JS). The panel opens on hover and on keyboard focus.
39
+ function NavDropdown({ label, items }: { label: string; items: MenuItem[] }) {
40
+ return (
41
+ <div className="group relative">
42
+ <button
43
+ type="button"
44
+ aria-haspopup="menu"
45
+ className="flex items-center gap-1 text-[13.5px] text-zinc-600 transition-colors hover:text-zinc-900 group-hover:text-zinc-900"
46
+ >
47
+ {label}
48
+ <Chevron />
49
+ </button>
50
+ {/* `pt-3` (padding, not margin) bridges the gap so hover doesn't drop. */}
51
+ <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">
52
+ <div className="rounded-2xl border border-zinc-200 bg-white p-2 shadow-[0_24px_60px_-25px_rgba(0,0,0,0.25)]">
53
+ {items.map((it) => (
54
+ <a
55
+ key={it.title}
56
+ href={it.href}
57
+ className="flex items-start gap-3 rounded-xl p-3 transition-colors hover:bg-zinc-50"
58
+ >
59
+ <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand-soft text-[13px] text-brand">
60
+ {it.icon}
61
+ </span>
62
+ <span className="min-w-0">
63
+ <span className="block text-[13.5px] font-medium text-zinc-900">
64
+ {it.title}
65
+ </span>
66
+ <span className="block text-[12.5px] leading-snug text-zinc-500">
67
+ {it.desc}
68
+ </span>
69
+ </span>
70
+ </a>
71
+ ))}
72
+ </div>
73
+ </div>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ function Chevron() {
79
+ return (
80
+ <svg
81
+ width="10"
82
+ height="10"
83
+ viewBox="0 0 10 10"
84
+ fill="none"
85
+ aria-hidden
86
+ className="text-zinc-400 transition-transform duration-200 group-hover:rotate-180"
87
+ >
88
+ <path
89
+ d="M2 3.5L5 6.5L8 3.5"
90
+ stroke="currentColor"
91
+ strokeWidth="1.5"
92
+ strokeLinecap="round"
93
+ strokeLinejoin="round"
94
+ />
95
+ </svg>
96
+ );
97
+ }
98
+
99
+ // Mobile menu — the desktop nav is `hidden md:flex`, so on phones this is the
100
+ // only way to reach Product / Resources / Pricing. Pure-CSS via native
101
+ // `<details>` (no client JS, same pattern as the FAQ + user menu); plain `<a>`
102
+ // links do a full navigation, which closes the open panel. md:hidden so it
103
+ // never shows alongside the desktop nav.
104
+ function MobileNav({ signedIn }: { signedIn: boolean }) {
105
+ return (
106
+ <details className="md:hidden">
107
+ <summary
108
+ aria-label="Open menu"
109
+ 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"
110
+ >
111
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
112
+ <path d="M3 6h18M3 12h18M3 18h18" />
113
+ </svg>
114
+ </summary>
115
+ <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)]">
116
+ <div className="mx-auto max-w-5xl space-y-6 px-6 py-6">
117
+ <MobileGroup title="Product" items={PRODUCT_MENU} />
118
+ <MobileGroup title="Resources" items={RESOURCES_MENU} />
119
+ <div className="flex flex-col">
120
+ <a href="/#pricing" className="rounded-lg px-2 py-2 text-[14px] font-medium text-zinc-700 transition-colors hover:bg-zinc-50">
121
+ Pricing
122
+ </a>
123
+ <a href="/#customers" className="rounded-lg px-2 py-2 text-[14px] font-medium text-zinc-700 transition-colors hover:bg-zinc-50">
124
+ Customers
125
+ </a>
126
+ </div>
127
+ <div className="flex flex-col gap-2 border-t border-zinc-100 pt-5">
128
+ {signedIn ? (
129
+ <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">
130
+ Open dashboard
131
+ </a>
132
+ ) : (
133
+ <>
134
+ <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">
135
+ Log in
136
+ </a>
137
+ <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">
138
+ Get started
139
+ </a>
140
+ </>
141
+ )}
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </details>
146
+ );
147
+ }
148
+
149
+ function MobileGroup({ title, items }: { title: string; items: MenuItem[] }) {
150
+ return (
151
+ <div>
152
+ <div className="mb-1.5 px-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
153
+ {title}
154
+ </div>
155
+ <div className="flex flex-col">
156
+ {items.map((it) => (
157
+ <a
158
+ key={it.href}
159
+ href={it.href}
160
+ className="flex items-center gap-3 rounded-lg px-2 py-2 text-[14px] text-zinc-700 transition-colors hover:bg-zinc-50"
161
+ >
162
+ <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-brand-soft text-[12px] text-brand">
163
+ {it.icon}
164
+ </span>
165
+ {it.title}
166
+ </a>
167
+ ))}
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ // The root layout wraps every page: a marketing nav up top, a fat footer below.
174
+ // `<main>` is full-bleed — each page owns its own container so the landing page
175
+ // can run edge-to-edge while the app pages stay centered. Rebrand "Acme" to
176
+ // your product.
177
+ export default function RootLayout({ children, url, auth }: LayoutProps) {
178
+ const signedIn = Boolean(auth?.user_id);
179
+ // These render bare — no marketing nav/footer: the auth screens and the
180
+ // dashboard (which brings its own sidebar shell). Match on the path PREFIX
181
+ // (not a substring) so a future marketing slug that happens to contain one
182
+ // of these words — e.g. /products/dashboard-tools — keeps its chrome.
183
+ const path = (url ?? "").split("?")[0];
184
+ const BARE_PREFIXES = ["/login", "/signup", "/onboarding", "/dashboard"];
185
+ const isBare = BARE_PREFIXES.some((p) => path === p || path.startsWith(p + "/"));
186
+ return (
187
+ <html lang="en">
188
+ <head>
189
+ <meta charSet="utf-8" />
190
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
191
+ {/* No <title> here on purpose — each page's exported `metadata` /
192
+ `generateMetadata` sets it. A hardcoded title in the layout would
193
+ render first and win over the page's, so every tab would read
194
+ "Acme". */}
195
+ {/* Inter — the marketing pages look best in a clean grotesk. Swap for
196
+ your own font or drop this link to fall back to the system stack. */}
197
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
198
+ <link
199
+ rel="preconnect"
200
+ href="https://fonts.gstatic.com"
201
+ crossOrigin="anonymous"
202
+ />
203
+ <link
204
+ rel="stylesheet"
205
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
206
+ />
207
+ {/* Tailwind is compiled by Pylon from app/globals.css and the
208
+ stylesheet link is injected here automatically. */}
209
+ </head>
210
+ <body className="flex min-h-screen flex-col bg-background text-foreground antialiased">
211
+ {isBare ? (
212
+ children
213
+ ) : (
214
+ <>
215
+ <header className="sticky top-0 z-30 bg-white/80 backdrop-blur">
216
+ <div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
217
+ <div className="flex items-center gap-8">
218
+ <Link href="/" className="flex items-center gap-2">
219
+ <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
220
+ A
221
+ </span>
222
+ <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
223
+ Acme
224
+ </span>
225
+ </Link>
226
+ <nav className="hidden items-center gap-6 md:flex">
227
+ <NavDropdown label="Product" items={PRODUCT_MENU} />
228
+ <NavDropdown label="Resources" items={RESOURCES_MENU} />
229
+ <Link
230
+ href="/#pricing"
231
+ className="text-[13.5px] text-zinc-600 transition-colors hover:text-zinc-900"
232
+ >
233
+ Pricing
234
+ </Link>
235
+ <Link
236
+ href="/#customers"
237
+ className="text-[13.5px] text-zinc-600 transition-colors hover:text-zinc-900"
238
+ >
239
+ Customers
240
+ </Link>
241
+ </nav>
242
+ </div>
243
+ <nav className="flex items-center gap-2">
244
+ <MobileNav signedIn={signedIn} />
245
+ {signedIn ? (
246
+ <Link
247
+ href="/dashboard"
248
+ 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"
249
+ >
250
+ Dashboard
251
+ </Link>
252
+ ) : (
253
+ <>
254
+ <Link
255
+ href="/login"
256
+ 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"
257
+ >
258
+ Log in
259
+ </Link>
260
+ <Link
261
+ href="/signup"
262
+ 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"
263
+ >
264
+ Get started
265
+ </Link>
266
+ </>
267
+ )}
268
+ </nav>
269
+ </div>
270
+ </header>
271
+
272
+ <main className="flex-1">{children}</main>
273
+
274
+ <SiteFooter />
275
+ </>
276
+ )}
277
+ </body>
278
+ </html>
279
+ );
280
+ }
281
+
282
+ // Footer link groups, derived from the same data the pages use so every link
283
+ // resolves to a real route. Each link carries a small icon (except Compare).
284
+ type FooterLink = { label: string; href: string; icon?: string };
285
+ type FooterGroupData = { title: string; links: FooterLink[] };
286
+
287
+ const SOLUTION_ICONS = ["◆", "◈", "▣", "◎"];
288
+ const RESOURCE_ICONS: Record<string, string> = {
289
+ docs: "▢",
290
+ guides: "✎",
291
+ changelog: "✦",
292
+ api: "⌘",
293
+ status: "◉",
294
+ };
295
+ const COMPANY_ICONS = ["◍", "≣", "◆", "✉", "▢"];
296
+
297
+ const PRODUCT_GROUP: FooterGroupData = {
298
+ title: "Product",
299
+ links: [
300
+ ...PRODUCTS.map((p) => ({
301
+ label: p.title,
302
+ href: `/products/${p.slug}`,
303
+ icon: p.icon,
304
+ })),
305
+ { label: "Pricing", href: "/#pricing", icon: "◷" },
306
+ ],
307
+ };
308
+ const SOLUTIONS_GROUP: FooterGroupData = {
309
+ title: "Solutions",
310
+ links: SOLUTIONS.map((s, i) => ({
311
+ label: s.navLabel,
312
+ href: `/solutions/${s.slug}`,
313
+ icon: SOLUTION_ICONS[i] ?? "◆",
314
+ })),
315
+ };
316
+ const RESOURCES_GROUP: FooterGroupData = {
317
+ title: "Resources",
318
+ links: RESOURCES.map((r) => ({
319
+ label: r.navLabel,
320
+ href: `/resources/${r.slug}`,
321
+ icon: RESOURCE_ICONS[r.slug] ?? "▢",
322
+ })),
323
+ };
324
+ const COMPANY_GROUP: FooterGroupData = {
325
+ title: "Company",
326
+ links: COMPANY.map((c, i) => ({
327
+ label: c.navLabel,
328
+ href: `/company/${c.slug}`,
329
+ icon: COMPANY_ICONS[i] ?? "◍",
330
+ })),
331
+ };
332
+ const COMPARE_GROUP: FooterGroupData = {
333
+ title: "Compare",
334
+ links: COMPARISONS.map((c) => ({
335
+ label: c.navLabel,
336
+ href: `/compare/${c.slug}`,
337
+ })),
338
+ };
339
+
340
+ function FooterGroup({
341
+ group,
342
+ className = "",
343
+ }: {
344
+ group: FooterGroupData;
345
+ className?: string;
346
+ }) {
347
+ return (
348
+ <div className={className}>
349
+ <div className="text-[11px] font-semibold uppercase tracking-wider text-zinc-900">
350
+ {group.title}
351
+ </div>
352
+ <div className="mt-4 flex flex-col gap-2.5">
353
+ {group.links.map((l) => (
354
+ <Link
355
+ key={l.href}
356
+ href={l.href}
357
+ className="group flex items-center gap-2 text-[13px] text-zinc-500 transition-colors hover:text-zinc-900"
358
+ >
359
+ {l.icon ? (
360
+ <span className="flex w-3.5 justify-center text-[11px] text-zinc-400 transition-colors group-hover:text-zinc-600">
361
+ {l.icon}
362
+ </span>
363
+ ) : null}
364
+ {l.label}
365
+ </Link>
366
+ ))}
367
+ </div>
368
+ </div>
369
+ );
370
+ }
371
+
372
+ const SOCIALS = [
373
+ {
374
+ label: "X",
375
+ href: "https://x.com/pylonsync",
376
+ path: "M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z",
377
+ },
378
+ {
379
+ label: "GitHub",
380
+ href: "https://github.com/pylonsync/pylon",
381
+ path: "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12",
382
+ },
383
+ ];
384
+
385
+ function SiteFooter() {
386
+ return (
387
+ <footer className="border-t border-zinc-200/70 bg-white">
388
+ <div className="mx-auto max-w-5xl px-6 py-16">
389
+ {/* Brand block */}
390
+ <div className="max-w-xs">
391
+ <Link href="/" className="inline-flex items-center gap-2">
392
+ <span className="flex size-7 items-center justify-center rounded-lg bg-zinc-900 text-sm font-bold text-white">
393
+ A
394
+ </span>
395
+ <span className="text-[15px] font-semibold tracking-tight text-zinc-900">
396
+ Acme
397
+ </span>
398
+ </Link>
399
+ <p className="mt-4 text-[13px] leading-relaxed text-zinc-500">
400
+ The workspace where your team plans, builds, and ships together —
401
+ projects, docs, and automation in one place.
402
+ </p>
403
+ <div className="mt-5 flex items-center gap-4">
404
+ {SOCIALS.map((s) => (
405
+ <a
406
+ key={s.label}
407
+ href={s.href}
408
+ aria-label={s.label}
409
+ className="text-zinc-400 transition-colors hover:text-zinc-900"
410
+ >
411
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
412
+ <path d={s.path} />
413
+ </svg>
414
+ </a>
415
+ ))}
416
+ <a
417
+ href="mailto:hello@acme.example"
418
+ aria-label="Email"
419
+ className="text-zinc-400 transition-colors hover:text-zinc-900"
420
+ >
421
+ <svg
422
+ width="16"
423
+ height="16"
424
+ viewBox="0 0 24 24"
425
+ fill="none"
426
+ stroke="currentColor"
427
+ strokeWidth="2"
428
+ >
429
+ <rect x="3" y="5" width="18" height="14" rx="2" />
430
+ <path d="m3 7 9 6 9-6" />
431
+ </svg>
432
+ </a>
433
+ </div>
434
+ </div>
435
+
436
+ {/* Link columns */}
437
+ <div className="mt-12 grid gap-x-8 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
438
+ <div>
439
+ <FooterGroup group={PRODUCT_GROUP} />
440
+ <FooterGroup group={SOLUTIONS_GROUP} className="mt-10" />
441
+ </div>
442
+ <div>
443
+ <FooterGroup group={RESOURCES_GROUP} />
444
+ <FooterGroup group={COMPANY_GROUP} className="mt-10" />
445
+ </div>
446
+ <div>
447
+ <FooterGroup group={COMPARE_GROUP} />
448
+ </div>
449
+ </div>
450
+
451
+ <div className="mt-14 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">
452
+ <span>© {new Date().getFullYear()} Acme, Inc.</span>
453
+ <span>
454
+ Built with{" "}
455
+ <a
456
+ href="https://pylonsync.com"
457
+ className="font-medium text-zinc-600 hover:text-zinc-900"
458
+ >
459
+ Pylon
460
+ </a>
461
+ </span>
462
+ </div>
463
+ </div>
464
+ </footer>
465
+ );
466
+ }
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { AuthShell } from "../auth-shell";
4
+ import { AuthForm } from "../auth-form";
5
+
6
+ export const metadata: Metadata = {
7
+ title: "Sign in — Acme",
8
+ robots: "noindex",
9
+ };
10
+
11
+ // `app/login/page.tsx` → `/login`. Split-screen auth (form left, brand right).
12
+ // The layout renders auth routes with no marketing nav or footer.
13
+ export default function LoginPage({ auth, response }: PageProps) {
14
+ // Already signed in? Skip the form. `response.redirect` runs in the
15
+ // synchronous shell render, so it's a real 307 before any HTML is sent.
16
+ if (auth.user_id) response.redirect("/dashboard");
17
+ return (
18
+ <AuthShell
19
+ title="Welcome back"
20
+ switchPrompt="New to Acme?"
21
+ switchLabel="Create an account"
22
+ switchHref="/signup"
23
+ >
24
+ <AuthForm mode="login" />
25
+ </AuthShell>
26
+ );
27
+ }