@pylonsync/create-pylon 0.11.1 → 0.11.4
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/bin/create-pylon.js +1 -1
- package/package.json +1 -1
- package/templates/_root/AGENTS.md +1 -1
- package/templates/agency/AGENTS.md +1 -1
- package/templates/agency/app/(marketing)/contact-form.tsx +22 -32
- package/templates/agency/app/(marketing)/layout.tsx +36 -64
- package/templates/agency/app/(marketing)/not-found.tsx +6 -8
- package/templates/agency/app/(marketing)/page.tsx +93 -144
- package/templates/agency/app/(marketing)/work/[slug]/page.tsx +69 -71
- package/templates/agency/app/(marketing)/work/page.tsx +20 -17
- package/templates/agency/app/globals.css +15 -6
- package/templates/agency/app.ts +10 -0
- package/templates/agency/components/marketing.tsx +40 -104
- package/templates/agency/lib/site.config.ts +34 -52
- package/templates/agency/public/images/hero.jpg +0 -0
- package/templates/agency/public/images/team/claire-donovan.jpg +0 -0
- package/templates/agency/public/images/team/dana-okafor.jpg +0 -0
- package/templates/agency/public/images/team/marcus-lee.jpg +0 -0
- package/templates/agency/public/images/work/atlas-health.jpg +0 -0
- package/templates/agency/public/images/work/cohort.jpg +0 -0
- package/templates/agency/public/images/work/ledger.jpg +0 -0
- package/templates/agency/public/images/work/vela.jpg +0 -0
- package/templates/ai-chat/AGENTS.md +1 -1
- package/templates/ai-studio/AGENTS.md +1 -1
- package/templates/barebones/AGENTS.md +1 -1
- package/templates/chat/AGENTS.md +1 -1
- package/templates/consumer/AGENTS.md +1 -1
- package/templates/consumer/README.md +39 -23
- package/templates/consumer/app/(app)/explore/page.tsx +13 -0
- package/templates/consumer/app/(app)/layout.tsx +17 -0
- package/templates/consumer/app/(app)/p/[id]/page.tsx +13 -0
- package/templates/consumer/app/(app)/page.tsx +15 -0
- package/templates/consumer/app/(app)/u/[username]/page.tsx +14 -0
- package/templates/consumer/app/error.tsx +1 -1
- package/templates/consumer/app/globals.css +25 -0
- package/templates/consumer/app/layout.tsx +3 -10
- package/templates/consumer/app/llms.ts +10 -10
- package/templates/consumer/app/not-found.tsx +9 -22
- package/templates/consumer/app/robots.ts +3 -4
- package/templates/consumer/app/sitemap.ts +7 -21
- package/templates/consumer/app.ts +152 -33
- package/templates/consumer/components/social/app-chrome.tsx +109 -0
- package/templates/consumer/components/social/avatar.tsx +50 -0
- package/templates/consumer/components/social/comment-form.tsx +51 -0
- package/templates/consumer/components/social/create-post.tsx +155 -0
- package/templates/consumer/components/social/edit-profile.tsx +103 -0
- package/templates/consumer/components/social/explore-screen.tsx +23 -0
- package/templates/consumer/components/social/home-screen.tsx +161 -0
- package/templates/consumer/components/social/post-card.tsx +77 -0
- package/templates/consumer/components/social/post-grid.tsx +46 -0
- package/templates/consumer/components/social/post-parts.tsx +146 -0
- package/templates/consumer/components/social/post-screen.tsx +173 -0
- package/templates/consumer/components/social/profile-screen.tsx +146 -0
- package/templates/consumer/components/social/rich-text.tsx +22 -0
- package/templates/consumer/components/social/session.tsx +43 -0
- package/templates/consumer/components/social/use-social.ts +86 -0
- package/templates/consumer/functions/addComment.ts +27 -0
- package/templates/consumer/functions/createPost.ts +39 -0
- package/templates/consumer/functions/ensureProfile.ts +39 -0
- package/templates/consumer/functions/seedFeed.ts +38 -0
- package/templates/consumer/functions/updateProfile.ts +61 -0
- package/templates/consumer/lib/seed.ts +192 -0
- package/templates/consumer/lib/site.ts +6 -0
- package/templates/consumer/lib/social.ts +245 -0
- package/templates/consumer/tests/social.test.ts +157 -0
- package/templates/creator/AGENTS.md +1 -1
- package/templates/creator/app/(marketing)/layout.tsx +35 -69
- package/templates/creator/app/(marketing)/newsletter-signup.tsx +15 -26
- package/templates/creator/app/(marketing)/not-found.tsx +8 -6
- package/templates/creator/app/(marketing)/page.tsx +90 -106
- package/templates/creator/app/globals.css +14 -0
- package/templates/creator/app/layout.tsx +1 -0
- package/templates/creator/app.ts +10 -0
- package/templates/creator/components/marketing.tsx +28 -89
- package/templates/creator/lib/site.config.ts +51 -29
- package/templates/creator/public/images/headshot.jpg +0 -0
- package/templates/crm/AGENTS.md +1 -1
- package/templates/crm/lib/seed.ts +153 -21
- package/templates/directory/AGENTS.md +1 -1
- package/templates/directory/app/(marketing)/directory-browse.tsx +99 -137
- package/templates/directory/app/(marketing)/layout.tsx +37 -88
- package/templates/directory/app/(marketing)/not-found.tsx +11 -10
- package/templates/directory/app/(marketing)/page.tsx +12 -33
- package/templates/directory/app/(marketing)/submit/page.tsx +9 -10
- package/templates/directory/app/(marketing)/submit-form.tsx +26 -26
- package/templates/directory/app/globals.css +15 -4
- package/templates/directory/app.ts +10 -0
- package/templates/directory/components/marketing.tsx +27 -91
- package/templates/directory/lib/site.config.ts +13 -24
- package/templates/helpdesk/AGENTS.md +1 -1
- package/templates/helpdesk/lib/seed.ts +188 -104
- package/templates/inventory/AGENTS.md +1 -1
- package/templates/inventory/lib/seed.ts +175 -44
- package/templates/invoices/AGENTS.md +1 -1
- package/templates/invoices/lib/seed.ts +74 -89
- package/templates/local-service/AGENTS.md +1 -1
- package/templates/local-service/app/(marketing)/booking-widget.tsx +31 -31
- package/templates/local-service/app/(marketing)/layout.tsx +25 -37
- package/templates/local-service/app/(marketing)/not-found.tsx +4 -4
- package/templates/local-service/app/(marketing)/page.tsx +145 -139
- package/templates/local-service/app/globals.css +11 -0
- package/templates/local-service/app/layout.tsx +2 -0
- package/templates/local-service/app.ts +9 -0
- package/templates/local-service/components/marketing.tsx +14 -39
- package/templates/local-service/lib/site.config.ts +16 -23
- package/templates/local-service/public/images/hero.jpg +0 -0
- package/templates/marketplace/AGENTS.md +1 -1
- package/templates/marketplace/client/SellForm.tsx +2 -0
- package/templates/projects/AGENTS.md +1 -1
- package/templates/projects/lib/seed.ts +103 -32
- package/templates/restaurant/AGENTS.md +1 -1
- package/templates/restaurant/app/(marketing)/layout.tsx +26 -48
- package/templates/restaurant/app/(marketing)/page.tsx +146 -123
- package/templates/restaurant/app/globals.css +11 -0
- package/templates/restaurant/app/layout.tsx +1 -0
- package/templates/restaurant/app.ts +10 -0
- package/templates/restaurant/components/marketing.tsx +10 -0
- package/templates/restaurant/lib/site.config.ts +16 -14
- package/templates/restaurant/public/images/bar.jpg +0 -0
- package/templates/restaurant/public/images/branzino.jpg +0 -0
- package/templates/restaurant/public/images/hearth.jpg +0 -0
- package/templates/restaurant/public/images/hero.jpg +0 -0
- package/templates/saas/AGENTS.md +1 -1
- package/templates/shop/AGENTS.md +1 -1
- package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
- package/templates/shop/public/images/products/linen.jpg +0 -0
- package/templates/todo/AGENTS.md +1 -1
- package/templates/waitlist/AGENTS.md +1 -1
- package/templates/waitlist/app/(marketing)/layout.tsx +39 -85
- package/templates/waitlist/app/(marketing)/not-found.tsx +8 -9
- package/templates/waitlist/app/(marketing)/page.tsx +51 -86
- package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +43 -83
- package/templates/waitlist/app/globals.css +23 -0
- package/templates/waitlist/app/layout.tsx +1 -0
- package/templates/waitlist/app.ts +18 -0
- package/templates/waitlist/components/marketing.tsx +67 -81
- package/templates/waitlist/lib/site.config.ts +52 -59
- package/templates/consumer/app/feed-client.tsx +0 -159
- package/templates/consumer/app/page.tsx +0 -27
- package/templates/consumer/functions/_keep.ts +0 -13
- package/templates/consumer/functions/seedPosts.ts +0 -56
- package/templates/consumer/tests/example.test.ts +0 -12
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Link, type PageAuth } from "@pylonsync/react";
|
|
3
3
|
import { siteConfig } from "@/lib/site.config";
|
|
4
|
+
import { WRAP } from "@/components/marketing";
|
|
4
5
|
|
|
5
6
|
// `(marketing)` is a ROUTE GROUP: the parens segment is stripped from every
|
|
6
7
|
// URL (so `(marketing)/page.tsx` still serves `/`), and this layout wraps only
|
|
7
|
-
// the pages inside the group
|
|
8
|
-
// driven by lib/site.config.ts. /login and /dashboard sit outside
|
|
9
|
-
// and render bare in the root shell. `auth.user_id` is resolved
|
|
10
|
-
// from the session cookie before any HTML is sent, so the nav
|
|
11
|
-
// "Dashboard" once the owner is signed in and "Sign in" otherwise
|
|
12
|
-
// no client fetch.
|
|
8
|
+
// the pages inside the group. A one-line nav up top and a one-line footer
|
|
9
|
+
// below, both driven by lib/site.config.ts. /login and /dashboard sit outside
|
|
10
|
+
// the group and render bare in the root shell. `auth.user_id` is resolved
|
|
11
|
+
// server-side from the session cookie before any HTML is sent, so the nav
|
|
12
|
+
// shows "Dashboard" once the owner is signed in and "Sign in" otherwise.
|
|
13
13
|
interface LayoutProps {
|
|
14
14
|
children: React.ReactNode;
|
|
15
15
|
auth: PageAuth;
|
|
@@ -17,39 +17,25 @@ interface LayoutProps {
|
|
|
17
17
|
|
|
18
18
|
export default function MarketingLayout({ children, auth }: LayoutProps) {
|
|
19
19
|
// A guest session (minted by <EnsureGuest> for the live counter) has a
|
|
20
|
-
// `guest_…` user id
|
|
21
|
-
// so it
|
|
20
|
+
// `guest_…` user id. That is an anonymous visitor, not the signed-in owner,
|
|
21
|
+
// so it must not flip the nav to "Dashboard".
|
|
22
22
|
const signedIn = Boolean(auth?.user_id && !auth.user_id.startsWith("guest_"));
|
|
23
23
|
const { brand } = siteConfig;
|
|
24
24
|
|
|
25
25
|
return (
|
|
26
|
-
|
|
27
|
-
<header
|
|
28
|
-
<div className=
|
|
29
|
-
<Link href="/" className="
|
|
30
|
-
|
|
31
|
-
{brand.letter}
|
|
32
|
-
</span>
|
|
33
|
-
<span className="text-[15px] font-semibold tracking-tight text-zinc-900">
|
|
34
|
-
{brand.name}
|
|
35
|
-
</span>
|
|
26
|
+
<div className="flex min-h-screen flex-col bg-ink text-chalk">
|
|
27
|
+
<header>
|
|
28
|
+
<div className={`${WRAP} flex h-16 items-center justify-between`}>
|
|
29
|
+
<Link href="/" className="font-display text-[17px] font-bold tracking-tight text-chalk">
|
|
30
|
+
{brand.name}
|
|
36
31
|
</Link>
|
|
37
|
-
<nav
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
</Link>
|
|
45
|
-
) : (
|
|
46
|
-
<Link
|
|
47
|
-
href="/login"
|
|
48
|
-
className="rounded-full px-3 py-1.5 text-[13px] font-medium text-zinc-600 transition-colors hover:text-zinc-900"
|
|
49
|
-
>
|
|
50
|
-
Sign in
|
|
51
|
-
</Link>
|
|
52
|
-
)}
|
|
32
|
+
<nav>
|
|
33
|
+
<Link
|
|
34
|
+
href={signedIn ? "/dashboard" : "/login"}
|
|
35
|
+
className="text-[14px] text-chalk-2 transition-colors hover:text-chalk"
|
|
36
|
+
>
|
|
37
|
+
{signedIn ? "Dashboard" : "Sign in"}
|
|
38
|
+
</Link>
|
|
53
39
|
</nav>
|
|
54
40
|
</div>
|
|
55
41
|
</header>
|
|
@@ -57,64 +43,32 @@ export default function MarketingLayout({ children, auth }: LayoutProps) {
|
|
|
57
43
|
<main className="flex-1">{children}</main>
|
|
58
44
|
|
|
59
45
|
<SiteFooter />
|
|
60
|
-
|
|
46
|
+
</div>
|
|
61
47
|
);
|
|
62
48
|
}
|
|
63
49
|
|
|
64
50
|
function SiteFooter() {
|
|
65
51
|
const { brand } = siteConfig;
|
|
66
52
|
return (
|
|
67
|
-
<footer className="border-t border-
|
|
68
|
-
<div
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
</Link>
|
|
79
|
-
<p className="mt-3 text-[13px] leading-relaxed text-zinc-500">
|
|
80
|
-
{brand.footerBlurb}
|
|
81
|
-
</p>
|
|
82
|
-
</div>
|
|
83
|
-
<div className="flex items-center gap-4">
|
|
84
|
-
{brand.socials.map((s) => (
|
|
85
|
-
<a
|
|
86
|
-
key={s.label}
|
|
87
|
-
href={s.href}
|
|
88
|
-
aria-label={s.label}
|
|
89
|
-
className="text-zinc-400 transition-colors hover:text-zinc-900"
|
|
90
|
-
>
|
|
91
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
92
|
-
<path d={s.path} />
|
|
93
|
-
</svg>
|
|
94
|
-
</a>
|
|
95
|
-
))}
|
|
96
|
-
<a
|
|
97
|
-
href={`mailto:${brand.email}`}
|
|
98
|
-
aria-label="Email"
|
|
99
|
-
className="text-zinc-400 transition-colors hover:text-zinc-900"
|
|
100
|
-
>
|
|
101
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
102
|
-
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
103
|
-
<path d="m3 7 9 6 9-6" />
|
|
104
|
-
</svg>
|
|
105
|
-
</a>
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
<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">
|
|
109
|
-
<span>
|
|
110
|
-
© {new Date().getFullYear()} {brand.copyrightName}
|
|
111
|
-
</span>
|
|
112
|
-
<span>
|
|
113
|
-
Built with{" "}
|
|
114
|
-
<a href="https://pylonsync.com" className="font-medium text-zinc-600 hover:text-zinc-900">
|
|
115
|
-
Pylon
|
|
53
|
+
<footer className="border-t border-line">
|
|
54
|
+
<div
|
|
55
|
+
className={`${WRAP} flex flex-col gap-3 py-6 font-mono-ui text-[12px] text-chalk-2 sm:flex-row sm:items-center sm:justify-between`}
|
|
56
|
+
>
|
|
57
|
+
<span>
|
|
58
|
+
{brand.copyrightName} {new Date().getFullYear()}
|
|
59
|
+
</span>
|
|
60
|
+
<div className="flex items-center gap-5">
|
|
61
|
+
{brand.socials.map((s) => (
|
|
62
|
+
<a key={s.label} href={s.href} className="transition-colors hover:text-chalk">
|
|
63
|
+
{s.label}
|
|
116
64
|
</a>
|
|
117
|
-
|
|
65
|
+
))}
|
|
66
|
+
<a href={`mailto:${brand.email}`} className="transition-colors hover:text-chalk">
|
|
67
|
+
{brand.email}
|
|
68
|
+
</a>
|
|
69
|
+
<a href="https://pylonsync.com" className="transition-colors hover:text-chalk">
|
|
70
|
+
Built with Pylon
|
|
71
|
+
</a>
|
|
118
72
|
</div>
|
|
119
73
|
</div>
|
|
120
74
|
</footer>
|
|
@@ -4,19 +4,18 @@ import { Link, type NotFoundProps } from "@pylonsync/react";
|
|
|
4
4
|
// `(marketing)/not-found.tsx` → rendered at HTTP 404 for any unmatched URL
|
|
5
5
|
// (and when a page calls `response.notFound()`). It lives inside the
|
|
6
6
|
// `(marketing)` group on purpose: a group segment adds no URL prefix, so this
|
|
7
|
-
// is still the root 404 boundary, but it wraps in the marketing layout
|
|
7
|
+
// is still the root 404 boundary, but it wraps in the marketing layout, so a
|
|
8
8
|
// missing URL gets the site nav + footer instead of a bare page. Hydrated, so
|
|
9
9
|
// the link is a client nav.
|
|
10
10
|
export default function NotFound(_props: NotFoundProps) {
|
|
11
11
|
return (
|
|
12
|
-
<div className="mx-auto flex min-h-[60vh] max-w-
|
|
13
|
-
<
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Back home
|
|
12
|
+
<div className="mx-auto flex min-h-[60vh] w-full max-w-6xl flex-col justify-center px-6">
|
|
13
|
+
<p className="font-mono-ui text-[13px] text-brand">404</p>
|
|
14
|
+
<h1 className="font-display mt-4 text-3xl font-bold tracking-tight text-chalk">
|
|
15
|
+
That page does not exist.
|
|
16
|
+
</h1>
|
|
17
|
+
<Link href="/" className="mt-6 text-[15px] text-chalk-2 underline underline-offset-4 hover:text-chalk">
|
|
18
|
+
Back to the home page
|
|
20
19
|
</Link>
|
|
21
20
|
</div>
|
|
22
21
|
);
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { type Metadata } from "@pylonsync/react";
|
|
3
|
-
import { WRAP,
|
|
4
|
-
import {
|
|
3
|
+
import { WRAP, FactList, ProductMock } from "@/components/marketing";
|
|
4
|
+
import { SignupForm, LiveCount } from "./waitlist-hero";
|
|
5
5
|
import { siteConfig } from "@/lib/site.config";
|
|
6
6
|
|
|
7
|
-
// SEO metadata
|
|
8
|
-
//
|
|
9
|
-
// lib/site.config.ts; edit it there to rebrand.
|
|
7
|
+
// SEO metadata: server-rendered into <head>, so the page is fully indexable.
|
|
8
|
+
// All copy lives in lib/site.config.ts; edit it there to rebrand.
|
|
10
9
|
export const metadata: Metadata = {
|
|
11
10
|
title: siteConfig.seo.title,
|
|
12
11
|
description: siteConfig.seo.description,
|
|
@@ -17,102 +16,68 @@ export const metadata: Metadata = {
|
|
|
17
16
|
},
|
|
18
17
|
};
|
|
19
18
|
|
|
20
|
-
// `app/(marketing)/page.tsx` →
|
|
21
|
-
// The headline,
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// `siteConfig`, so the whole page rebrands from one file.
|
|
19
|
+
// `app/(marketing)/page.tsx` → `/`. A server-rendered, single-page launch
|
|
20
|
+
// page. The headline, product mock, facts, and FAQ are static server HTML; the
|
|
21
|
+
// email form and the live count are client islands (<SignupForm>, <LiveCount>)
|
|
22
|
+
// that hydrate in the browser. Every string comes from `siteConfig`.
|
|
25
23
|
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// the layout.
|
|
24
|
+
// This page does not read `auth`, so it stays a public, cacheable render.
|
|
25
|
+
// Signed-in state (the nav's "Dashboard" link) is resolved in the layout.
|
|
29
26
|
export default function LandingPage() {
|
|
30
|
-
const { hero, counter,
|
|
27
|
+
const { hero, counter, mock, facts, faq } = siteConfig;
|
|
31
28
|
|
|
32
29
|
return (
|
|
33
|
-
<div
|
|
30
|
+
<div>
|
|
34
31
|
{/* ============================= HERO ============================= */}
|
|
35
|
-
<section className={`${WRAP} pt-
|
|
36
|
-
<div
|
|
37
|
-
<
|
|
38
|
-
<h1 className="mt-6 max-w-2xl text-balance text-[2.5rem] font-semibold leading-[1.05] tracking-[-0.02em] sm:text-[3.25rem]">
|
|
32
|
+
<section className={`${WRAP} grid items-center gap-12 pt-16 pb-20 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-16 lg:pt-24 lg:pb-28`}>
|
|
33
|
+
<div>
|
|
34
|
+
<h1 className="font-display text-balance text-[2.5rem] font-bold leading-[1.02] tracking-[-0.02em] text-chalk sm:text-[3.5rem]">
|
|
39
35
|
{hero.headline}
|
|
40
36
|
</h1>
|
|
41
|
-
<p className="mt-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<WaitlistHero hero={hero} counter={counter} />
|
|
37
|
+
<p className="mt-6 max-w-md text-[17px] leading-relaxed text-chalk-2">{hero.subcopy}</p>
|
|
38
|
+
<div className="mt-8 max-w-md">
|
|
39
|
+
<SignupForm hero={hero} />
|
|
40
|
+
</div>
|
|
41
|
+
<p className="font-mono-ui mt-6 text-[13px] text-brand">{hero.launchNote}</p>
|
|
47
42
|
</div>
|
|
43
|
+
<ProductMock
|
|
44
|
+
windowTitle={mock.windowTitle}
|
|
45
|
+
sidebar={mock.sidebar}
|
|
46
|
+
activeSidebarIndex={mock.activeSidebarIndex}
|
|
47
|
+
rows={mock.rows}
|
|
48
|
+
/>
|
|
48
49
|
</section>
|
|
49
50
|
|
|
50
|
-
{/*
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
{/* ============================= FACTS ============================= */}
|
|
52
|
+
<section className={`${WRAP} pb-20`}>
|
|
53
|
+
<h2 className="font-display text-[1.5rem] font-medium tracking-[-0.01em] text-chalk">
|
|
54
|
+
{facts.headline}
|
|
55
|
+
</h2>
|
|
56
|
+
<div className="mt-8">
|
|
57
|
+
<FactList items={facts.items} />
|
|
56
58
|
</div>
|
|
59
|
+
{counter.enabled ? (
|
|
60
|
+
<div className="mt-12 border-t border-line pt-5">
|
|
61
|
+
<LiveCount seed={counter.seedCount ?? 0} label={counter.label} />
|
|
62
|
+
</div>
|
|
63
|
+
) : null}
|
|
57
64
|
</section>
|
|
58
65
|
|
|
59
|
-
{/* ========================= SOCIAL PROOF ========================= */}
|
|
60
|
-
{socialProof && socialProof.quotes && socialProof.quotes.length > 0 ? (
|
|
61
|
-
<>
|
|
62
|
-
<Divider />
|
|
63
|
-
<section className={`${WRAP} py-16`}>
|
|
64
|
-
<p className="font-mono text-[11px] uppercase tracking-[0.14em] text-zinc-400">
|
|
65
|
-
{socialProof.label}
|
|
66
|
-
</p>
|
|
67
|
-
<div className="mt-8 grid gap-5 sm:grid-cols-3">
|
|
68
|
-
{socialProof.quotes.map((q) => (
|
|
69
|
-
<figure
|
|
70
|
-
key={q.name + q.role}
|
|
71
|
-
className="flex flex-col rounded-2xl border border-zinc-200 bg-paper p-6"
|
|
72
|
-
>
|
|
73
|
-
<blockquote className="text-[14px] leading-relaxed text-zinc-600">
|
|
74
|
-
“{q.quote}”
|
|
75
|
-
</blockquote>
|
|
76
|
-
<figcaption className="mt-6 flex items-center gap-3">
|
|
77
|
-
<span className="flex size-8 items-center justify-center rounded-full bg-zinc-200 text-[11px] font-semibold text-zinc-500">
|
|
78
|
-
{initials(q.name)}
|
|
79
|
-
</span>
|
|
80
|
-
<div className="leading-tight">
|
|
81
|
-
<div className="text-[13px] font-semibold">{q.name}</div>
|
|
82
|
-
<div className="text-[12px] text-zinc-500">{q.role}</div>
|
|
83
|
-
</div>
|
|
84
|
-
</figcaption>
|
|
85
|
-
</figure>
|
|
86
|
-
))}
|
|
87
|
-
</div>
|
|
88
|
-
</section>
|
|
89
|
-
</>
|
|
90
|
-
) : null}
|
|
91
|
-
|
|
92
66
|
{/* ============================== FAQ ============================= */}
|
|
93
67
|
{faq && faq.items.length > 0 ? (
|
|
94
|
-
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
</summary>
|
|
108
|
-
<p className="mt-3 max-w-2xl text-[14px] leading-relaxed text-zinc-500">
|
|
109
|
-
{f.a}
|
|
110
|
-
</p>
|
|
111
|
-
</details>
|
|
112
|
-
))}
|
|
113
|
-
</div>
|
|
114
|
-
</section>
|
|
115
|
-
</>
|
|
68
|
+
<section className={`${WRAP} pb-24`}>
|
|
69
|
+
<h2 className="font-display text-[1.5rem] font-medium tracking-[-0.01em] text-chalk">
|
|
70
|
+
{faq.headline}
|
|
71
|
+
</h2>
|
|
72
|
+
<dl className="mt-8 grid gap-x-8 gap-y-8 sm:grid-cols-2">
|
|
73
|
+
{faq.items.map((f) => (
|
|
74
|
+
<div key={f.q} className="border-t border-line pt-5">
|
|
75
|
+
<dt className="text-[15px] font-medium text-chalk">{f.q}</dt>
|
|
76
|
+
<dd className="mt-2 text-[14px] leading-relaxed text-chalk-2">{f.a}</dd>
|
|
77
|
+
</div>
|
|
78
|
+
))}
|
|
79
|
+
</dl>
|
|
80
|
+
</section>
|
|
116
81
|
) : null}
|
|
117
82
|
</div>
|
|
118
83
|
);
|
|
@@ -5,40 +5,22 @@ import { db, callFn } from "@pylonsync/react";
|
|
|
5
5
|
import { EnsureGuest } from "@pylonsync/client";
|
|
6
6
|
import type { WaitlistConfig } from "@/lib/site.config";
|
|
7
7
|
|
|
8
|
-
// The
|
|
9
|
-
// signup
|
|
10
|
-
// public, PII-free aggregate row, so
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// The two client islands on the landing page: the email-capture form (in the
|
|
9
|
+
// hero) and the LIVE signup count (under the facts). The count is a live
|
|
10
|
+
// `db.useQuery("WaitlistStat")` over the public, PII-free aggregate row, so
|
|
11
|
+
// the moment anyone (this tab or another) submits an email, joinWaitlist
|
|
12
|
+
// updates that row and the new count syncs to every open tab through the
|
|
13
|
+
// replica. No refresh, no polling.
|
|
13
14
|
//
|
|
14
15
|
// The signup form (joinWaitlist) is a public mutation, so it works for any
|
|
15
|
-
// anonymous visitor. The
|
|
16
|
-
// in <EnsureGuest>, which mints an anonymous guest session on first load
|
|
17
|
-
// session
|
|
18
|
-
//
|
|
19
|
-
// count, never an email.
|
|
20
|
-
|
|
21
|
-
type HeroProps = {
|
|
22
|
-
hero: WaitlistConfig["hero"];
|
|
23
|
-
counter: WaitlistConfig["counter"];
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export function WaitlistHero({ hero, counter }: HeroProps) {
|
|
27
|
-
return (
|
|
28
|
-
<div className="flex flex-col items-center text-center">
|
|
29
|
-
<SignupForm hero={hero} />
|
|
30
|
-
{counter.enabled ? (
|
|
31
|
-
<div className="mt-8">
|
|
32
|
-
<LiveCounter seed={counter.seedCount ?? 0} label={counter.label} />
|
|
33
|
-
</div>
|
|
34
|
-
) : null}
|
|
35
|
-
</div>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
16
|
+
// anonymous visitor. The count needs a live sync connection, so it's wrapped
|
|
17
|
+
// in <EnsureGuest>, which mints an anonymous guest session on first load. That
|
|
18
|
+
// session holds no PII, and the Signup table stays unreadable to it; the page
|
|
19
|
+
// only ever reads the aggregate count, never an email.
|
|
38
20
|
|
|
39
21
|
/* ----------------------------- signup form ---------------------------- */
|
|
40
22
|
|
|
41
|
-
function SignupForm({ hero }: { hero: WaitlistConfig["hero"] }) {
|
|
23
|
+
export function SignupForm({ hero }: { hero: WaitlistConfig["hero"] }) {
|
|
42
24
|
const [email, setEmail] = useState("");
|
|
43
25
|
const [status, setStatus] = useState<"idle" | "sending" | "done">("idle");
|
|
44
26
|
const [alreadyJoined, setAlreadyJoined] = useState(false);
|
|
@@ -57,14 +39,13 @@ function SignupForm({ hero }: { hero: WaitlistConfig["hero"] }) {
|
|
|
57
39
|
);
|
|
58
40
|
setAlreadyJoined(res.alreadyJoined);
|
|
59
41
|
setStatus("done");
|
|
60
|
-
// The live
|
|
61
|
-
// increment needed here.
|
|
42
|
+
// The live count updates itself via the reactive push.
|
|
62
43
|
} catch (err) {
|
|
63
44
|
const msg = err instanceof Error ? err.message : String(err);
|
|
64
45
|
setError(
|
|
65
46
|
/valid email|INVALID_ARGS/i.test(msg)
|
|
66
47
|
? "Enter a valid email address."
|
|
67
|
-
: "Something went wrong
|
|
48
|
+
: "Something went wrong. Try again in a moment.",
|
|
68
49
|
);
|
|
69
50
|
setStatus("idle");
|
|
70
51
|
}
|
|
@@ -72,22 +53,19 @@ function SignupForm({ hero }: { hero: WaitlistConfig["hero"] }) {
|
|
|
72
53
|
|
|
73
54
|
if (status === "done") {
|
|
74
55
|
return (
|
|
75
|
-
<div className="
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
</div>
|
|
79
|
-
<p className="mt-3 text-[15px] font-medium text-zinc-900">
|
|
80
|
-
{alreadyJoined ? "You're already on the list." : hero.successMessage}
|
|
56
|
+
<div className="border-t border-brand pt-4">
|
|
57
|
+
<p className="text-[15px] font-medium text-chalk">
|
|
58
|
+
{alreadyJoined ? "You are already on the list." : hero.successMessage}
|
|
81
59
|
</p>
|
|
82
|
-
<p className="mt-1 text-[
|
|
83
|
-
|
|
60
|
+
<p className="mt-1 font-mono-ui text-[12px] text-chalk-2">
|
|
61
|
+
The count below went up for everyone.
|
|
84
62
|
</p>
|
|
85
63
|
</div>
|
|
86
64
|
);
|
|
87
65
|
}
|
|
88
66
|
|
|
89
67
|
return (
|
|
90
|
-
<form onSubmit={onSubmit}
|
|
68
|
+
<form onSubmit={onSubmit}>
|
|
91
69
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
92
70
|
<input
|
|
93
71
|
type="email"
|
|
@@ -98,28 +76,24 @@ function SignupForm({ hero }: { hero: WaitlistConfig["hero"] }) {
|
|
|
98
76
|
placeholder={hero.emailPlaceholder}
|
|
99
77
|
aria-label="Email address"
|
|
100
78
|
required
|
|
101
|
-
className="h-11 flex-1 rounded-
|
|
79
|
+
className="h-11 flex-1 rounded-md border border-line bg-paper px-3.5 text-[15px] text-chalk outline-none transition placeholder:text-chalk-2 focus:border-brand"
|
|
102
80
|
/>
|
|
103
81
|
<button
|
|
104
82
|
type="submit"
|
|
105
83
|
disabled={status === "sending"}
|
|
106
|
-
className="inline-flex h-11 items-center justify-center rounded-
|
|
84
|
+
className="inline-flex h-11 shrink-0 items-center justify-center rounded-md bg-brand px-5 text-[15px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60"
|
|
107
85
|
>
|
|
108
86
|
{status === "sending" ? "Joining…" : hero.ctaLabel}
|
|
109
87
|
</button>
|
|
110
88
|
</div>
|
|
111
|
-
{error ?
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
<p className="mt-2 text-[13px] text-zinc-400">
|
|
115
|
-
No spam. One invite, the day we launch.
|
|
116
|
-
</p>
|
|
117
|
-
)}
|
|
89
|
+
<p className={`mt-2.5 font-mono-ui text-[12px] ${error ? "text-red-400" : "text-chalk-2"}`}>
|
|
90
|
+
{error ?? hero.formHint}
|
|
91
|
+
</p>
|
|
118
92
|
</form>
|
|
119
93
|
);
|
|
120
94
|
}
|
|
121
95
|
|
|
122
|
-
/* ----------------------------- live
|
|
96
|
+
/* ----------------------------- live count ---------------------------- */
|
|
123
97
|
|
|
124
98
|
interface WaitlistStatRow {
|
|
125
99
|
id: string;
|
|
@@ -127,26 +101,26 @@ interface WaitlistStatRow {
|
|
|
127
101
|
updatedAt: string;
|
|
128
102
|
}
|
|
129
103
|
|
|
130
|
-
function
|
|
104
|
+
export function LiveCount({ seed, label }: { seed: number; label: string }) {
|
|
131
105
|
return (
|
|
132
|
-
<EnsureGuest fallback={<
|
|
133
|
-
<
|
|
106
|
+
<EnsureGuest fallback={<CountView value={seed} label={label} />}>
|
|
107
|
+
<LiveCountInner seed={seed} label={label} />
|
|
134
108
|
</EnsureGuest>
|
|
135
109
|
);
|
|
136
110
|
}
|
|
137
111
|
|
|
138
|
-
function
|
|
112
|
+
function LiveCountInner({ seed, label }: { seed: number; label: string }) {
|
|
139
113
|
// Live entity query over the public WaitlistStat row. `db.useQuery` re-renders
|
|
140
|
-
// the instant the row changes
|
|
114
|
+
// the instant the row changes, in THIS tab and every other open tab, because
|
|
141
115
|
// the change syncs through the shared replica. `loading` is true until the
|
|
142
116
|
// first sync settles; until then we show the seed (never a flash of 0).
|
|
143
117
|
const { data, loading } = db.useQuery<WaitlistStatRow>("WaitlistStat");
|
|
144
118
|
const real = data.length > 0 ? data[0].count : 0;
|
|
145
119
|
const value = seed + real;
|
|
146
|
-
return <
|
|
120
|
+
return <CountView value={value} label={label} live={!loading} />;
|
|
147
121
|
}
|
|
148
122
|
|
|
149
|
-
function
|
|
123
|
+
function CountView({
|
|
150
124
|
value,
|
|
151
125
|
label,
|
|
152
126
|
live,
|
|
@@ -157,26 +131,20 @@ function CounterView({
|
|
|
157
131
|
}) {
|
|
158
132
|
const shown = useCountUp(value);
|
|
159
133
|
return (
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
<span className="text-[15px] font-semibold tabular-nums text-zinc-900">
|
|
170
|
-
{shown.toLocaleString()}
|
|
171
|
-
</span>
|
|
172
|
-
<span className="text-[13px] text-zinc-500">{label}</span>
|
|
173
|
-
</div>
|
|
134
|
+
<p className="flex items-center gap-3 font-mono-ui text-[14px] text-chalk-2">
|
|
135
|
+
<span
|
|
136
|
+
aria-hidden
|
|
137
|
+
className={live ? "size-2 rounded-full bg-brand" : "size-2 rounded-full bg-line"}
|
|
138
|
+
/>
|
|
139
|
+
<span className="tabular-nums text-chalk">{shown.toLocaleString()}</span>
|
|
140
|
+
<span>{label}</span>
|
|
141
|
+
<span className="text-[12px]">{live ? "live" : ""}</span>
|
|
142
|
+
</p>
|
|
174
143
|
);
|
|
175
144
|
}
|
|
176
145
|
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// directly (no animation).
|
|
146
|
+
// Tween the displayed number toward `target` whenever it changes, so a live
|
|
147
|
+
// increment counts up instead of snapping. First paint shows the value directly.
|
|
180
148
|
function useCountUp(target: number): number {
|
|
181
149
|
const [shown, setShown] = useState(target);
|
|
182
150
|
const fromRef = useRef(target);
|
|
@@ -208,11 +176,3 @@ function useCountUp(target: number): number {
|
|
|
208
176
|
|
|
209
177
|
return shown;
|
|
210
178
|
}
|
|
211
|
-
|
|
212
|
-
function CheckIcon() {
|
|
213
|
-
return (
|
|
214
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
215
|
-
<path d="M20 6 9 17l-5-5" />
|
|
216
|
-
</svg>
|
|
217
|
-
);
|
|
218
|
-
}
|
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
--brand: #4f46e5;
|
|
20
20
|
--brand-soft: #eef2ff;
|
|
21
21
|
--paper: #fafafa;
|
|
22
|
+
/* The marketing pages: near-black page, off-white type. Set from
|
|
23
|
+
lib/site.config.ts on <html>. */
|
|
24
|
+
--ink: #0b0b0c;
|
|
25
|
+
--ink-2: #19191b;
|
|
26
|
+
--line: #26262a;
|
|
27
|
+
--chalk: #ededea;
|
|
28
|
+
--chalk-2: #8f8f95;
|
|
22
29
|
--background: oklch(1 0 0);
|
|
23
30
|
--foreground: oklch(0.141 0.005 285.823);
|
|
24
31
|
--card: oklch(1 0 0);
|
|
@@ -112,6 +119,11 @@
|
|
|
112
119
|
--color-brand: var(--brand);
|
|
113
120
|
--color-brand-soft: var(--brand-soft);
|
|
114
121
|
--color-paper: var(--paper);
|
|
122
|
+
--color-ink: var(--ink);
|
|
123
|
+
--color-ink-2: var(--ink-2);
|
|
124
|
+
--color-line: var(--line);
|
|
125
|
+
--color-chalk: var(--chalk);
|
|
126
|
+
--color-chalk-2: var(--chalk-2);
|
|
115
127
|
--color-chart-1: var(--chart-1);
|
|
116
128
|
--color-chart-2: var(--chart-2);
|
|
117
129
|
--color-chart-3: var(--chart-3);
|
|
@@ -153,3 +165,14 @@
|
|
|
153
165
|
cursor: pointer;
|
|
154
166
|
}
|
|
155
167
|
}
|
|
168
|
+
|
|
169
|
+
/* Headlines. Space Grotesk is declared in app.ts. */
|
|
170
|
+
.font-display {
|
|
171
|
+
font-family: var(--font-display, "Space Grotesk", "Helvetica Neue", Arial, sans-serif);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* Small details: the launch date, the live count, the form hint. JetBrains Mono
|
|
175
|
+
is declared in app.ts. */
|
|
176
|
+
.font-mono-ui {
|
|
177
|
+
font-family: var(--font-mono-ui, "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace);
|
|
178
|
+
}
|