@pylonsync/create-pylon 0.11.1 → 0.11.3

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 (142) hide show
  1. package/bin/create-pylon.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/_root/AGENTS.md +1 -1
  4. package/templates/agency/AGENTS.md +1 -1
  5. package/templates/agency/app/(marketing)/contact-form.tsx +22 -32
  6. package/templates/agency/app/(marketing)/layout.tsx +36 -64
  7. package/templates/agency/app/(marketing)/not-found.tsx +6 -8
  8. package/templates/agency/app/(marketing)/page.tsx +93 -144
  9. package/templates/agency/app/(marketing)/work/[slug]/page.tsx +69 -71
  10. package/templates/agency/app/(marketing)/work/page.tsx +20 -17
  11. package/templates/agency/app/globals.css +15 -6
  12. package/templates/agency/app.ts +10 -0
  13. package/templates/agency/components/marketing.tsx +40 -104
  14. package/templates/agency/lib/site.config.ts +34 -52
  15. package/templates/agency/public/images/hero.jpg +0 -0
  16. package/templates/agency/public/images/team/claire-donovan.jpg +0 -0
  17. package/templates/agency/public/images/team/dana-okafor.jpg +0 -0
  18. package/templates/agency/public/images/team/marcus-lee.jpg +0 -0
  19. package/templates/agency/public/images/work/atlas-health.jpg +0 -0
  20. package/templates/agency/public/images/work/cohort.jpg +0 -0
  21. package/templates/agency/public/images/work/ledger.jpg +0 -0
  22. package/templates/agency/public/images/work/vela.jpg +0 -0
  23. package/templates/ai-chat/AGENTS.md +1 -1
  24. package/templates/ai-studio/AGENTS.md +1 -1
  25. package/templates/barebones/AGENTS.md +1 -1
  26. package/templates/chat/AGENTS.md +1 -1
  27. package/templates/consumer/AGENTS.md +1 -1
  28. package/templates/consumer/README.md +39 -23
  29. package/templates/consumer/app/(app)/explore/page.tsx +13 -0
  30. package/templates/consumer/app/(app)/layout.tsx +17 -0
  31. package/templates/consumer/app/(app)/p/[id]/page.tsx +13 -0
  32. package/templates/consumer/app/(app)/page.tsx +15 -0
  33. package/templates/consumer/app/(app)/u/[username]/page.tsx +14 -0
  34. package/templates/consumer/app/error.tsx +1 -1
  35. package/templates/consumer/app/globals.css +25 -0
  36. package/templates/consumer/app/layout.tsx +3 -10
  37. package/templates/consumer/app/llms.ts +10 -10
  38. package/templates/consumer/app/not-found.tsx +9 -22
  39. package/templates/consumer/app/robots.ts +3 -4
  40. package/templates/consumer/app/sitemap.ts +7 -21
  41. package/templates/consumer/app.ts +152 -33
  42. package/templates/consumer/components/social/app-chrome.tsx +109 -0
  43. package/templates/consumer/components/social/avatar.tsx +50 -0
  44. package/templates/consumer/components/social/comment-form.tsx +51 -0
  45. package/templates/consumer/components/social/create-post.tsx +155 -0
  46. package/templates/consumer/components/social/edit-profile.tsx +103 -0
  47. package/templates/consumer/components/social/explore-screen.tsx +23 -0
  48. package/templates/consumer/components/social/home-screen.tsx +161 -0
  49. package/templates/consumer/components/social/post-card.tsx +77 -0
  50. package/templates/consumer/components/social/post-grid.tsx +46 -0
  51. package/templates/consumer/components/social/post-parts.tsx +146 -0
  52. package/templates/consumer/components/social/post-screen.tsx +173 -0
  53. package/templates/consumer/components/social/profile-screen.tsx +146 -0
  54. package/templates/consumer/components/social/rich-text.tsx +22 -0
  55. package/templates/consumer/components/social/session.tsx +43 -0
  56. package/templates/consumer/components/social/use-social.ts +86 -0
  57. package/templates/consumer/functions/addComment.ts +27 -0
  58. package/templates/consumer/functions/createPost.ts +39 -0
  59. package/templates/consumer/functions/ensureProfile.ts +39 -0
  60. package/templates/consumer/functions/seedFeed.ts +38 -0
  61. package/templates/consumer/functions/updateProfile.ts +61 -0
  62. package/templates/consumer/lib/seed.ts +192 -0
  63. package/templates/consumer/lib/site.ts +6 -0
  64. package/templates/consumer/lib/social.ts +245 -0
  65. package/templates/consumer/tests/social.test.ts +157 -0
  66. package/templates/creator/AGENTS.md +1 -1
  67. package/templates/creator/app/(marketing)/layout.tsx +35 -69
  68. package/templates/creator/app/(marketing)/newsletter-signup.tsx +15 -26
  69. package/templates/creator/app/(marketing)/not-found.tsx +8 -6
  70. package/templates/creator/app/(marketing)/page.tsx +90 -106
  71. package/templates/creator/app/globals.css +14 -0
  72. package/templates/creator/app/layout.tsx +1 -0
  73. package/templates/creator/app.ts +10 -0
  74. package/templates/creator/components/marketing.tsx +28 -89
  75. package/templates/creator/lib/site.config.ts +51 -29
  76. package/templates/creator/public/images/headshot.jpg +0 -0
  77. package/templates/crm/AGENTS.md +1 -1
  78. package/templates/crm/lib/seed.ts +153 -21
  79. package/templates/directory/AGENTS.md +1 -1
  80. package/templates/directory/app/(marketing)/directory-browse.tsx +99 -137
  81. package/templates/directory/app/(marketing)/layout.tsx +37 -88
  82. package/templates/directory/app/(marketing)/not-found.tsx +11 -10
  83. package/templates/directory/app/(marketing)/page.tsx +12 -33
  84. package/templates/directory/app/(marketing)/submit/page.tsx +9 -10
  85. package/templates/directory/app/(marketing)/submit-form.tsx +26 -26
  86. package/templates/directory/app/globals.css +15 -4
  87. package/templates/directory/app.ts +10 -0
  88. package/templates/directory/components/marketing.tsx +27 -91
  89. package/templates/directory/lib/site.config.ts +13 -24
  90. package/templates/helpdesk/AGENTS.md +1 -1
  91. package/templates/helpdesk/lib/seed.ts +188 -104
  92. package/templates/inventory/AGENTS.md +1 -1
  93. package/templates/inventory/lib/seed.ts +175 -44
  94. package/templates/invoices/AGENTS.md +1 -1
  95. package/templates/invoices/lib/seed.ts +74 -89
  96. package/templates/local-service/AGENTS.md +1 -1
  97. package/templates/local-service/app/(marketing)/booking-widget.tsx +31 -31
  98. package/templates/local-service/app/(marketing)/layout.tsx +25 -37
  99. package/templates/local-service/app/(marketing)/not-found.tsx +4 -4
  100. package/templates/local-service/app/(marketing)/page.tsx +145 -139
  101. package/templates/local-service/app/globals.css +11 -0
  102. package/templates/local-service/app/layout.tsx +2 -0
  103. package/templates/local-service/app.ts +9 -0
  104. package/templates/local-service/components/marketing.tsx +14 -39
  105. package/templates/local-service/lib/site.config.ts +16 -23
  106. package/templates/local-service/public/images/hero.jpg +0 -0
  107. package/templates/marketplace/AGENTS.md +1 -1
  108. package/templates/marketplace/client/SellForm.tsx +2 -0
  109. package/templates/projects/AGENTS.md +1 -1
  110. package/templates/projects/lib/seed.ts +103 -32
  111. package/templates/restaurant/AGENTS.md +1 -1
  112. package/templates/restaurant/app/(marketing)/layout.tsx +26 -48
  113. package/templates/restaurant/app/(marketing)/page.tsx +146 -123
  114. package/templates/restaurant/app/globals.css +11 -0
  115. package/templates/restaurant/app/layout.tsx +1 -0
  116. package/templates/restaurant/app.ts +10 -0
  117. package/templates/restaurant/components/marketing.tsx +10 -0
  118. package/templates/restaurant/lib/site.config.ts +16 -14
  119. package/templates/restaurant/public/images/bar.jpg +0 -0
  120. package/templates/restaurant/public/images/branzino.jpg +0 -0
  121. package/templates/restaurant/public/images/hearth.jpg +0 -0
  122. package/templates/restaurant/public/images/hero.jpg +0 -0
  123. package/templates/saas/AGENTS.md +1 -1
  124. package/templates/shop/AGENTS.md +1 -1
  125. package/templates/shop/public/images/products/cedar-smoke.jpg +0 -0
  126. package/templates/shop/public/images/products/linen.jpg +0 -0
  127. package/templates/todo/AGENTS.md +1 -1
  128. package/templates/waitlist/AGENTS.md +1 -1
  129. package/templates/waitlist/app/(marketing)/layout.tsx +39 -85
  130. package/templates/waitlist/app/(marketing)/not-found.tsx +8 -9
  131. package/templates/waitlist/app/(marketing)/page.tsx +51 -86
  132. package/templates/waitlist/app/(marketing)/waitlist-hero.tsx +43 -83
  133. package/templates/waitlist/app/globals.css +23 -0
  134. package/templates/waitlist/app/layout.tsx +1 -0
  135. package/templates/waitlist/app.ts +18 -0
  136. package/templates/waitlist/components/marketing.tsx +67 -81
  137. package/templates/waitlist/lib/site.config.ts +52 -59
  138. package/templates/consumer/app/feed-client.tsx +0 -159
  139. package/templates/consumer/app/page.tsx +0 -27
  140. package/templates/consumer/functions/_keep.ts +0 -13
  141. package/templates/consumer/functions/seedPosts.ts +0 -56
  142. package/templates/consumer/tests/example.test.ts +0 -12
@@ -1,15 +1,8 @@
1
1
  import React from "react";
2
2
  import { type Metadata } from "@pylonsync/react";
3
- import {
4
- WRAP,
5
- Eyebrow,
6
- Divider,
7
- SectionHead,
8
- ImagePlaceholder,
9
- LiveBadge,
10
- } from "@/components/marketing";
3
+ import { LiveBadge } from "@/components/marketing";
11
4
  import { ReservationWidget } from "./reservation-widget";
12
- import { siteConfig } from "@/lib/site.config";
5
+ import { siteConfig, type DayHours } from "@/lib/site.config";
13
6
 
14
7
  export const metadata: Metadata = {
15
8
  title: siteConfig.seo.title,
@@ -17,75 +10,98 @@ export const metadata: Metadata = {
17
10
  openGraph: { title: siteConfig.seo.title, description: siteConfig.seo.description, type: "website" },
18
11
  };
19
12
 
20
- // `app/(marketing)/page.tsx` → `/` (the group segment adds no URL prefix). Server-rendered single-page restaurant site. Hero, menu,
21
- // reviews, location, and FAQ are static server HTML (SEO + first paint); the
22
- // reservation section (#reserve) is a client island with live table
23
- // availability. All copy comes from siteConfig. Doesn't read `auth`, so the
24
- // public page stays cacheable.
13
+ const WRAP = "mx-auto w-full max-w-6xl px-6";
14
+ const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
15
+
16
+ // `app/(marketing)/page.tsx` → `/`. A single dark page: the room behind the
17
+ // hero, opening times, the menu, the live reservation calendar on a cream
18
+ // panel, a review, and where to find us. Every word and every image path
19
+ // comes from lib/site.config.ts. The page never reads `auth`, so the public
20
+ // render stays cacheable; the reservation widget is the one client island.
25
21
  export default function LandingPage() {
26
- const { hero, menu, reservations, reviews, location, faq } = siteConfig;
22
+ const { hero, gallery, menu, reservations, reviews, location, faq } = siteConfig;
27
23
 
28
24
  return (
29
- <div className="bg-white text-zinc-900">
30
- {/* HERO */}
31
- <section className={`${WRAP} pt-16 pb-14 sm:pt-20`}>
32
- <div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-14">
33
- <div>
34
- <p className="font-mono text-[11px] uppercase tracking-[0.16em] text-brand">{hero.tagline}</p>
35
- <h1 className="mt-4 text-balance text-[2.5rem] font-semibold leading-[1.04] tracking-[-0.02em] sm:text-[3.25rem]">
36
- {hero.headline}
37
- </h1>
38
- <p className="mt-5 max-w-xl text-[17px] leading-relaxed text-zinc-500">{hero.subcopy}</p>
39
- <div className="mt-7 flex flex-wrap items-center gap-4">
40
- <a href="#reserve" className="inline-flex items-center rounded-full bg-brand px-5 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90">
41
- {hero.ctaLabel}
42
- </a>
43
- <a href="#menu" className="text-sm font-medium text-zinc-700 hover:text-zinc-900">See the menu →</a>
44
- </div>
45
- <dl className="mt-10 grid max-w-lg grid-cols-3 gap-4 border-t border-zinc-200/70 pt-6">
46
- <QuickFact label="Hours" value={hero.quickFacts.hours} />
47
- <QuickFact label="Area" value={hero.quickFacts.area} />
48
- <QuickFact label="Call" value={hero.quickFacts.phone} />
49
- </dl>
50
- </div>
51
-
52
- {/* Hero photo — replace the placeholder with a real shot of your space. */}
53
- <div className="relative mx-auto w-full max-w-sm lg:max-w-none">
54
- <ImagePlaceholder
55
- shape="portrait"
56
- title="A photo of your dining room"
57
- hint="Swap for an <img> in app/(marketing)/page.tsx"
58
- />
59
- <div className="absolute left-4 top-4">
25
+ <div className="bg-[var(--ink)] text-[var(--cream)]">
26
+ {/* HERO: the room, full bleed, the words on top. */}
27
+ <section className="relative isolate min-h-[88vh] overflow-hidden">
28
+ <img src={hero.image} alt="" className="absolute inset-0 -z-20 size-full object-cover" />
29
+ <div className="absolute inset-0 -z-10 bg-gradient-to-t from-[var(--ink)] via-[var(--ink)]/60 to-[var(--ink)]/20" />
30
+ <div className={`${WRAP} flex min-h-[88vh] flex-col justify-end pb-16 pt-32`}>
31
+ <h1 className="font-display mt-5 max-w-3xl text-balance text-[3rem] font-medium leading-[1.02] tracking-[-0.01em] sm:text-[4.5rem]">
32
+ {hero.headline}
33
+ </h1>
34
+ <p className="mt-6 max-w-xl text-[17px] leading-relaxed text-[var(--cream-2)]">{hero.subcopy}</p>
35
+ <div className="mt-8 flex flex-wrap items-center gap-4">
36
+ <a href="#reserve" className="inline-flex items-center rounded-full bg-brand px-6 py-3 text-[14px] font-medium text-white transition-opacity hover:opacity-90">
37
+ {hero.ctaLabel}
38
+ </a>
39
+ <a href="#menu" className="text-[14px] font-medium text-[var(--cream)] underline-offset-4 hover:underline">
40
+ See the menu
41
+ </a>
42
+ <span className="ml-auto hidden sm:block">
60
43
  <LiveBadge>Tables update live</LiveBadge>
61
- </div>
44
+ </span>
62
45
  </div>
63
46
  </div>
64
47
  </section>
65
48
 
49
+ {/* OPENING TIMES */}
50
+ <section className="border-y border-white/10">
51
+ <div className={`${WRAP} grid gap-8 py-10 sm:grid-cols-[1fr_2fr] sm:items-start`}>
52
+ <h2 className="font-display text-[26px] italic">Opening times</h2>
53
+ <dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-[14px] sm:grid-cols-3">
54
+ {DAYS.map((day, i) => (
55
+ <div key={day} className="flex justify-between gap-3 border-b border-white/10 pb-2">
56
+ <dt className="text-[var(--cream-2)]">{day}</dt>
57
+ <dd className="font-medium tabular-nums">{hoursLabel(reservations.hours[i])}</dd>
58
+ </div>
59
+ ))}
60
+ </dl>
61
+ </div>
62
+ </section>
63
+
64
+ {/* GALLERY */}
65
+ {gallery.length > 0 ? (
66
+ <section className={`${WRAP} grid gap-3 py-12 sm:grid-cols-3`}>
67
+ {gallery.map((g) => (
68
+ <figure key={g.src} className="group">
69
+ <div className="aspect-[4/3] overflow-hidden rounded-[6px] bg-[var(--ink-2)]">
70
+ <img src={g.src} alt={g.caption} className="size-full object-cover transition-transform duration-500 group-hover:scale-[1.03]" loading="lazy" />
71
+ </div>
72
+ <figcaption className="mt-2 font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--cream-2)]">{g.caption}</figcaption>
73
+ </figure>
74
+ ))}
75
+ </section>
76
+ ) : null}
77
+
66
78
  {/* MENU */}
67
- <Divider />
68
- <section id="menu" className={`${WRAP} py-14`}>
69
- <SectionHead eyebrow={menu.eyebrow} title={menu.headline} />
70
- <div className="mt-8 grid gap-10 sm:grid-cols-2">
79
+ <section id="menu" className={`${WRAP} py-16`}>
80
+ <div className="max-w-2xl">
81
+ <h2 className="font-display text-balance text-[2.25rem] leading-[1.08] sm:text-[3rem]">{menu.headline}</h2>
82
+ </div>
83
+ <div className="mt-12 grid gap-12 md:grid-cols-3">
71
84
  {menu.sections.map((sec) => (
72
85
  <div key={sec.name}>
73
- <h3 className="font-mono text-[11px] uppercase tracking-[0.14em] text-brand">{sec.name}</h3>
74
- <ul className="mt-4 space-y-4">
86
+ <h3 className="font-display border-b border-white/15 pb-3 text-[22px] italic">{sec.name}</h3>
87
+ <ul className="mt-5 space-y-5">
75
88
  {sec.items.map((it) => (
76
- <li key={it.name} className="flex items-baseline justify-between gap-4">
77
- <div>
78
- <div className="flex items-center gap-2">
79
- <span className="text-[15px] font-medium text-zinc-900">{it.name}</span>
89
+ <li key={it.name}>
90
+ <div className="flex items-baseline gap-2">
91
+ <span className="text-[15px] font-medium">{it.name}</span>
92
+ <span className="mb-1 flex-1 border-b border-dotted border-white/25" aria-hidden />
93
+ <span className="text-[14px] tabular-nums text-[var(--cream-2)]">{it.price}</span>
94
+ </div>
95
+ {it.desc || it.tags?.length ? (
96
+ <p className="mt-1 text-[13px] leading-relaxed text-[var(--cream-2)]">
97
+ {it.desc}
80
98
  {it.tags?.map((t) => (
81
- <span key={t} className="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium uppercase text-zinc-500">
99
+ <span key={t} className="ml-2 font-mono text-[10px] uppercase tracking-wide text-brand">
82
100
  {t}
83
101
  </span>
84
102
  ))}
85
- </div>
86
- {it.desc ? <p className="mt-0.5 text-[13px] leading-relaxed text-zinc-500">{it.desc}</p> : null}
87
- </div>
88
- <span className="shrink-0 text-[14px] font-medium text-zinc-600">{it.price}</span>
103
+ </p>
104
+ ) : null}
89
105
  </li>
90
106
  ))}
91
107
  </ul>
@@ -94,66 +110,73 @@ export default function LandingPage() {
94
110
  </div>
95
111
  </section>
96
112
 
97
- {/* RESERVE */}
113
+ {/* RESERVE: the live calendar on a cream panel. */}
98
114
  {reservations.enabled ? (
99
- <>
100
- <Divider />
101
- <section id="reserve" className={`${WRAP} py-14`}>
102
- <SectionHead eyebrow={reservations.eyebrow} title={reservations.headline} body={reservations.subcopy} />
115
+ <section id="reserve" className="bg-[var(--paper)] text-zinc-900">
116
+ <div className={`${WRAP} py-16`}>
117
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
118
+ <div className="max-w-xl">
119
+ <h2 className="font-display text-balance text-[2.25rem] leading-[1.08] sm:text-[3rem]">{reservations.headline}</h2>
120
+ <p className="mt-4 text-[15px] leading-relaxed text-zinc-600">{reservations.subcopy}</p>
121
+ </div>
122
+ <p className="text-[13px] text-zinc-500">
123
+ Parties over {reservations.maxPartySize}? Call{" "}
124
+ <a href={`tel:${location.phone}`} className="font-medium text-zinc-900">
125
+ {location.phone}
126
+ </a>
127
+ </p>
128
+ </div>
103
129
  <div className="mt-8">
104
130
  <ReservationWidget />
105
131
  </div>
106
- </section>
107
- </>
132
+ </div>
133
+ </section>
108
134
  ) : null}
109
135
 
110
- {/* REVIEWS */}
136
+ {/* REVIEWS: one voice at a time. */}
111
137
  {reviews && reviews.items.length > 0 ? (
112
- <>
113
- <Divider />
114
- <section className={`${WRAP} py-14`}>
115
- <SectionHead eyebrow={reviews.eyebrow} title={reviews.headline} />
116
- <div className="mt-8 grid gap-5 sm:grid-cols-3">
117
- {reviews.items.map((r) => (
118
- <figure key={r.name} className="flex flex-col rounded-2xl border border-zinc-200 bg-paper p-6">
119
- {r.rating ? (
120
- <div className="text-[13px] tracking-wide text-brand">
121
- {"★".repeat(r.rating)}
122
- <span className="text-zinc-200">{"★".repeat(5 - r.rating)}</span>
123
- </div>
124
- ) : null}
125
- <blockquote className="mt-3 text-[14px] leading-relaxed text-zinc-600">&ldquo;{r.quote}&rdquo;</blockquote>
126
- <figcaption className="mt-4 text-[13px] font-semibold text-zinc-900">{r.name}</figcaption>
127
- </figure>
128
- ))}
129
- </div>
130
- </section>
131
- </>
138
+ <section className={`${WRAP} py-16`}>
139
+ <h2 className="font-display text-[2rem] leading-[1.1]">{reviews.headline}</h2>
140
+ <div className="mt-6 divide-y divide-white/10">
141
+ {reviews.items.map((r) => (
142
+ <figure key={r.name} className="grid gap-4 py-8 sm:grid-cols-[1fr_auto] sm:items-end">
143
+ <blockquote className="font-display max-w-3xl text-balance text-[1.5rem] leading-[1.3] sm:text-[1.9rem]">
144
+ “{r.quote}”
145
+ </blockquote>
146
+ <figcaption className="text-[13px] text-[var(--cream-2)]">
147
+ {r.name}
148
+ {r.rating ? <span className="ml-2 text-brand">{"★".repeat(r.rating)}</span> : null}
149
+ </figcaption>
150
+ </figure>
151
+ ))}
152
+ </div>
153
+ </section>
132
154
  ) : null}
133
155
 
134
- {/* LOCATION */}
135
- <Divider />
136
- <section className={`${WRAP} py-14`}>
137
- <div className="grid gap-8 lg:grid-cols-[1fr_1fr]">
156
+ {/* VISIT */}
157
+ <section id="visit" className="border-t border-white/10">
158
+ <div className={`${WRAP} grid gap-10 py-16 lg:grid-cols-2`}>
138
159
  <div>
139
- <SectionHead eyebrow={location.eyebrow} title={location.headline} />
140
- <div className="mt-6 space-y-3 text-[14px] leading-relaxed text-zinc-600">
141
- <p>{location.address}</p>
142
- <p className="text-zinc-500">{location.hoursText}</p>
160
+ <h2 className="font-display text-balance text-[2.25rem] leading-[1.08] sm:text-[3rem]">{location.headline}</h2>
161
+ <div className="mt-6 space-y-2 text-[15px] leading-relaxed text-[var(--cream-2)]">
162
+ <p className="text-[var(--cream)]">{location.address}</p>
163
+ <p>{location.hoursText}</p>
143
164
  <p>
144
- <a href={`tel:${location.phone}`} className="font-medium text-brand">{location.phone}</a>{" "}·{" "}
145
- <a href={`mailto:${location.email}`} className="text-zinc-600 hover:text-zinc-900">{location.email}</a>
165
+ <a href={`tel:${location.phone}`} className="text-[var(--cream)] underline-offset-4 hover:underline">
166
+ {location.phone}
167
+ </a>{" "}
168
+ · <a href={`mailto:${location.email}`} className="underline-offset-4 hover:underline">{location.email}</a>
146
169
  </p>
147
- <a href="#reserve" className="mt-2 inline-flex items-center rounded-full bg-zinc-900 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-700">
148
- {siteConfig.hero.ctaLabel}
149
- </a>
150
170
  </div>
171
+ <a href="#reserve" className="mt-8 inline-flex items-center rounded-full border border-white/25 px-5 py-2.5 text-[14px] font-medium transition-colors hover:bg-white/10">
172
+ {hero.ctaLabel}
173
+ </a>
151
174
  </div>
152
175
  {location.mapEmbedUrl ? (
153
- <iframe title="Map" src={location.mapEmbedUrl} className="h-64 w-full rounded-2xl border border-zinc-200" loading="lazy" />
176
+ <iframe title="Map" src={location.mapEmbedUrl} className="h-72 w-full rounded-[6px] border border-white/10 grayscale" loading="lazy" />
154
177
  ) : (
155
- <div className="grid h-64 place-items-center rounded-2xl border border-zinc-200 bg-paper text-sm text-zinc-400">
156
- Drop a Google Maps embed URL in <code className="mx-1">location.mapEmbedUrl</code>
178
+ <div className="grid h-72 place-items-center rounded-[6px] border border-white/10 bg-[var(--ink-2)] text-[13px] text-[var(--cream-2)]">
179
+ Set <code className="mx-1 font-mono">location.mapEmbedUrl</code> to show a map here
157
180
  </div>
158
181
  )}
159
182
  </div>
@@ -161,34 +184,34 @@ export default function LandingPage() {
161
184
 
162
185
  {/* FAQ */}
163
186
  {faq && faq.items.length > 0 ? (
164
- <>
165
- <Divider />
166
- <section className={`${WRAP} py-14`}>
167
- <Eyebrow>{faq.eyebrow}</Eyebrow>
168
- <h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">{faq.headline}</h2>
169
- <div className="mt-8 divide-y divide-zinc-200/70 border-y border-zinc-200/70">
187
+ <section className="border-t border-white/10">
188
+ <div className={`${WRAP} py-16`}>
189
+ <h2 className="font-display text-[2rem] leading-[1.1]">{faq.headline}</h2>
190
+ <div className="mt-8 max-w-3xl divide-y divide-white/10 border-y border-white/10">
170
191
  {faq.items.map((f) => (
171
192
  <details key={f.q} className="group py-5">
172
- <summary className="flex cursor-pointer items-center justify-between text-[15px] font-medium text-zinc-900 marker:hidden [&::-webkit-details-marker]:hidden">
193
+ <summary className="flex cursor-pointer items-center justify-between text-[16px] font-medium marker:hidden [&::-webkit-details-marker]:hidden">
173
194
  {f.q}
174
195
  <span className="text-brand transition-transform group-open:rotate-45">+</span>
175
196
  </summary>
176
- <p className="mt-3 max-w-2xl text-[14px] leading-relaxed text-zinc-500">{f.a}</p>
197
+ <p className="mt-3 max-w-2xl text-[14.5px] leading-relaxed text-[var(--cream-2)]">{f.a}</p>
177
198
  </details>
178
199
  ))}
179
200
  </div>
180
- </section>
181
- </>
201
+ </div>
202
+ </section>
182
203
  ) : null}
183
204
  </div>
184
205
  );
185
206
  }
186
207
 
187
- function QuickFact({ label, value }: { label: string; value: string }) {
188
- return (
189
- <div>
190
- <dt className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">{label}</dt>
191
- <dd className="mt-1 text-[13.5px] font-medium text-zinc-900">{value}</dd>
192
- </div>
193
- );
208
+ function hoursLabel(h: DayHours): string {
209
+ if (!h) return "Closed";
210
+ return `${clock(h.open)} – ${clock(h.close)}`;
211
+ }
212
+
213
+ function clock(t: string): string {
214
+ const [hh, mm] = t.split(":").map(Number);
215
+ const h12 = ((hh + 11) % 12) + 1;
216
+ return `${h12}${mm ? `:${String(mm).padStart(2, "0")}` : ""}${hh < 12 ? "am" : "pm"}`;
194
217
  }
@@ -19,6 +19,11 @@
19
19
  --brand: #4f46e5;
20
20
  --brand-soft: #eef2ff;
21
21
  --paper: #fafafa;
22
+ /* The marketing pages: a dark room, cream type. Set from lib/site.config.ts. */
23
+ --ink: #151311;
24
+ --ink-2: #1f1b18;
25
+ --cream: #f3ede4;
26
+ --cream-2: #cfc4b4;
22
27
  --background: oklch(1 0 0);
23
28
  --foreground: oklch(0.141 0.005 285.823);
24
29
  --card: oklch(1 0 0);
@@ -153,3 +158,9 @@
153
158
  cursor: pointer;
154
159
  }
155
160
  }
161
+
162
+ /* Headlines and menu section names. Fraunces is declared in app.ts. */
163
+ .font-display {
164
+ font-family: var(--font-display, "Iowan Old Style", "Palatino Linotype", Georgia, serif);
165
+ font-optical-sizing: auto;
166
+ }
@@ -21,6 +21,7 @@ export default function RootLayout({ children }: LayoutProps) {
21
21
  "--brand": colors.brand,
22
22
  "--brand-soft": colors.brandSoft,
23
23
  "--paper": colors.paper,
24
+ "--ink": colors.ink,
24
25
  } as React.CSSProperties
25
26
  }
26
27
  >
@@ -127,6 +127,16 @@ const manifest = buildManifest({
127
127
  display: "swap",
128
128
  preload: true,
129
129
  }),
130
+ // The display face for headlines and menu section names.
131
+ font({
132
+ family: "Fraunces",
133
+ variable: "--font-display",
134
+ weights: ["400", "500", "600"],
135
+ styles: ["normal", "italic"],
136
+ subsets: ["latin"],
137
+ display: "swap",
138
+ preload: true,
139
+ }),
130
140
  ],
131
141
  routes: await discoverAppRoutes(),
132
142
  });
@@ -107,11 +107,14 @@ export function ImagePlaceholder({
107
107
  shape = "landscape",
108
108
  title,
109
109
  hint,
110
+ src,
110
111
  className = "",
111
112
  }: {
112
113
  shape?: "landscape" | "portrait" | "square" | "circle";
113
114
  title: string;
114
115
  hint?: string;
116
+ /** A real image. The template ships one under public/images; swap it for yours. */
117
+ src?: string;
115
118
  className?: string;
116
119
  }) {
117
120
  const aspect =
@@ -121,6 +124,13 @@ export function ImagePlaceholder({
121
124
  ? "aspect-square"
122
125
  : "aspect-[4/3]";
123
126
  const radius = shape === "circle" ? "rounded-full" : "rounded-2xl";
127
+ if (src) {
128
+ return (
129
+ <div className={`relative overflow-hidden bg-zinc-100 ${aspect} ${radius} ${className}`}>
130
+ <img src={src} alt={title} className="absolute inset-0 size-full object-cover" loading="lazy" />
131
+ </div>
132
+ );
133
+ }
124
134
  return (
125
135
  <div
126
136
  className={`relative grid place-items-center overflow-hidden border-2 border-dashed border-zinc-300 bg-zinc-50 ${aspect} ${radius} ${className}`}
@@ -19,7 +19,7 @@ export type BaseConfig = {
19
19
  copyrightName: string;
20
20
  socials: Social[];
21
21
  };
22
- colors: { brand: string; brandSoft: string; paper: string };
22
+ colors: { brand: string; brandSoft: string; paper: string; ink: string };
23
23
  seo: { title: string; description: string };
24
24
  };
25
25
 
@@ -31,16 +31,18 @@ export type Faq = { q: string; a: string };
31
31
 
32
32
  export type RestaurantConfig = BaseConfig & {
33
33
  hero: {
34
- tagline: string;
35
34
  headline: string;
36
35
  subcopy: string;
37
36
  ctaLabel: string;
38
37
  quickFacts: { hours: string; area: string; phone: string };
38
+ /** Full-bleed photo behind the hero. Ships as public/images/hero.jpg. */
39
+ image: string;
39
40
  };
40
- menu: { eyebrow: string; headline: string; sections: MenuSection[] };
41
+ /** The photo band under the hero: public/images/*.jpg with a caption each. */
42
+ gallery: Array<{ src: string; caption: string }>;
43
+ menu: { headline: string; sections: MenuSection[] };
41
44
  reservations: {
42
45
  enabled: boolean;
43
- eyebrow: string;
44
46
  headline: string;
45
47
  subcopy: string;
46
48
  slotMinutes: number; // gap between seatings, e.g. 30
@@ -51,9 +53,8 @@ export type RestaurantConfig = BaseConfig & {
51
53
  hours: Record<number, DayHours>; // 0=Sun … 6=Sat; null = closed
52
54
  confirmationMessage: string;
53
55
  };
54
- reviews?: { eyebrow: string; headline: string; items: Review[] };
56
+ reviews?: { headline: string; items: Review[] };
55
57
  location: {
56
- eyebrow: string;
57
58
  headline: string;
58
59
  address: string;
59
60
  mapEmbedUrl?: string;
@@ -61,7 +62,7 @@ export type RestaurantConfig = BaseConfig & {
61
62
  phone: string;
62
63
  email: string;
63
64
  };
64
- faq?: { eyebrow: string; headline: string; items: Faq[] };
65
+ faq?: { headline: string; items: Faq[] };
65
66
  };
66
67
 
67
68
  /* ----------------------------- config ---------------------------- */
@@ -84,7 +85,7 @@ export const siteConfig: RestaurantConfig = {
84
85
  ],
85
86
  },
86
87
 
87
- colors: { brand: "#9f1239", brandSoft: "#ffe4e6", paper: "#fbf7f5" },
88
+ colors: { brand: "#c2571a", brandSoft: "#3a2418", paper: "#f3ede4", ink: "#151311" },
88
89
 
89
90
  seo: {
90
91
  title: "Cedar & Vine — a seasonal bistro in Dallas. Reserve a table.",
@@ -93,7 +94,6 @@ export const siteConfig: RestaurantConfig = {
93
94
  },
94
95
 
95
96
  hero: {
96
- tagline: "Dallas · Bishop Arts",
97
97
  headline: "A table by the fire, whenever you're ready.",
98
98
  subcopy:
99
99
  "Seasonal small plates, wood-fired mains, and a short natural-wine list. The reservation calendar shows current table availability.",
@@ -103,10 +103,16 @@ export const siteConfig: RestaurantConfig = {
103
103
  area: "Bishop Arts, Dallas",
104
104
  phone: "(214) 555-0172",
105
105
  },
106
+ image: "/images/hero.jpg",
106
107
  },
107
108
 
109
+ gallery: [
110
+ { src: "/images/hearth.jpg", caption: "The hearth" },
111
+ { src: "/images/branzino.jpg", caption: "Wood-fired branzino" },
112
+ { src: "/images/bar.jpg", caption: "The bar" },
113
+ ],
114
+
108
115
  menu: {
109
- eyebrow: "Menu",
110
116
  headline: "Short, seasonal, wood-fired.",
111
117
  sections: [
112
118
  {
@@ -139,7 +145,6 @@ export const siteConfig: RestaurantConfig = {
139
145
 
140
146
  reservations: {
141
147
  enabled: true,
142
- eyebrow: "Reserve",
143
148
  headline: "Find a table that's open.",
144
149
  subcopy:
145
150
  "Pick a date, time, and party size from the live table calendar. We'll hold the reservation for you.",
@@ -162,7 +167,6 @@ export const siteConfig: RestaurantConfig = {
162
167
  },
163
168
 
164
169
  reviews: {
165
- eyebrow: "Reviews",
166
170
  headline: "Regulars keep coming back.",
167
171
  items: [
168
172
  {
@@ -187,7 +191,6 @@ export const siteConfig: RestaurantConfig = {
187
191
  },
188
192
 
189
193
  location: {
190
- eyebrow: "Visit",
191
194
  headline: "Find us in Bishop Arts.",
192
195
  address: "412 N Bishop Ave, Dallas, TX 75208",
193
196
  mapEmbedUrl: "",
@@ -197,7 +200,6 @@ export const siteConfig: RestaurantConfig = {
197
200
  },
198
201
 
199
202
  faq: {
200
- eyebrow: "Questions",
201
203
  headline: "Good to know.",
202
204
  items: [
203
205
  {
@@ -65,7 +65,7 @@ this pass before the user has to ask.
65
65
  - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
- - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
68
+ - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
69
69
 
70
70
  ## Testing
71
71
 
@@ -65,7 +65,7 @@ this pass before the user has to ask.
65
65
  - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
- - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
68
+ - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
69
69
 
70
70
  ## Testing
71
71
 
@@ -65,7 +65,7 @@ this pass before the user has to ask.
65
65
  - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
- - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
68
+ - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
69
69
 
70
70
  ## Testing
71
71
 
@@ -65,7 +65,7 @@ this pass before the user has to ask.
65
65
  - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
- - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
68
+ - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
69
69
 
70
70
  ## Testing
71
71