@pylonsync/create-pylon 0.3.274 → 0.3.276

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 (340) hide show
  1. package/bin/create-pylon.js +80 -0
  2. package/package.json +1 -1
  3. package/templates/ARCHETYPES.md +339 -0
  4. package/templates/agency/.env.example +12 -0
  5. package/templates/agency/AGENTS.md +61 -0
  6. package/templates/agency/README.md +90 -0
  7. package/templates/agency/app/auth-form.tsx +129 -0
  8. package/templates/agency/app/contact-form.tsx +258 -0
  9. package/templates/agency/app/dashboard/dashboard-client.tsx +1440 -0
  10. package/templates/agency/app/dashboard/page.tsx +70 -0
  11. package/templates/agency/app/error.tsx +26 -0
  12. package/templates/agency/app/globals.css +148 -0
  13. package/templates/agency/app/layout.tsx +174 -0
  14. package/templates/agency/app/login/page.tsx +39 -0
  15. package/templates/agency/app/not-found.tsx +19 -0
  16. package/templates/agency/app/page.tsx +249 -0
  17. package/templates/agency/app/robots.ts +12 -0
  18. package/templates/agency/app/seeder.tsx +26 -0
  19. package/templates/agency/app/sitemap.ts +9 -0
  20. package/templates/agency/app/work/[slug]/page.tsx +182 -0
  21. package/templates/agency/app/work/page.tsx +83 -0
  22. package/templates/agency/app.ts +284 -0
  23. package/templates/agency/components/marketing.tsx +187 -0
  24. package/templates/agency/components/section-scroller.tsx +35 -0
  25. package/templates/agency/components/ui/button.tsx +56 -0
  26. package/templates/agency/components/ui/card.tsx +90 -0
  27. package/templates/agency/components.json +20 -0
  28. package/templates/agency/functions/bookInquiry.ts +42 -0
  29. package/templates/agency/functions/clientsForOwner.ts +27 -0
  30. package/templates/agency/functions/declineInquiry.ts +41 -0
  31. package/templates/agency/functions/deleteClient.ts +27 -0
  32. package/templates/agency/functions/deleteInvoice.ts +19 -0
  33. package/templates/agency/functions/deleteProject.ts +20 -0
  34. package/templates/agency/functions/inquiriesForOwner.ts +31 -0
  35. package/templates/agency/functions/invoicesForOwner.ts +27 -0
  36. package/templates/agency/functions/seedCapacity.ts +26 -0
  37. package/templates/agency/functions/seedProjects.ts +41 -0
  38. package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
  39. package/templates/agency/functions/setCapacity.ts +32 -0
  40. package/templates/agency/functions/setInvoiceStatus.ts +27 -0
  41. package/templates/agency/functions/setProjectFlags.ts +35 -0
  42. package/templates/agency/functions/submitInquiry.ts +55 -0
  43. package/templates/agency/functions/upsertClient.ts +73 -0
  44. package/templates/agency/functions/upsertInvoice.ts +113 -0
  45. package/templates/agency/functions/upsertProject.ts +97 -0
  46. package/templates/agency/gitignore +10 -0
  47. package/templates/agency/lib/agency.ts +189 -0
  48. package/templates/agency/lib/invoice-pdf.tsx +174 -0
  49. package/templates/agency/lib/owner.ts +26 -0
  50. package/templates/agency/lib/site.config.ts +418 -0
  51. package/templates/agency/lib/utils.ts +10 -0
  52. package/templates/agency/package.json +35 -0
  53. package/templates/agency/tsconfig.json +18 -0
  54. package/templates/ai-chat/.env.example +33 -0
  55. package/templates/ai-chat/AGENTS.md +61 -0
  56. package/templates/ai-chat/README.md +99 -0
  57. package/templates/ai-chat/app/auth-form.tsx +124 -0
  58. package/templates/ai-chat/app/chat-client.tsx +727 -0
  59. package/templates/ai-chat/app/error.tsx +26 -0
  60. package/templates/ai-chat/app/globals.css +148 -0
  61. package/templates/ai-chat/app/layout.tsx +75 -0
  62. package/templates/ai-chat/app/login/page.tsx +39 -0
  63. package/templates/ai-chat/app/not-found.tsx +19 -0
  64. package/templates/ai-chat/app/page.tsx +23 -0
  65. package/templates/ai-chat/app.ts +121 -0
  66. package/templates/ai-chat/components.json +20 -0
  67. package/templates/ai-chat/functions/deleteConversation.ts +33 -0
  68. package/templates/ai-chat/gitignore +10 -0
  69. package/templates/ai-chat/lib/site.config.ts +103 -0
  70. package/templates/ai-chat/lib/utils.ts +10 -0
  71. package/templates/ai-chat/package.json +34 -0
  72. package/templates/ai-chat/tsconfig.json +18 -0
  73. package/templates/ai-studio/.env.example +19 -0
  74. package/templates/ai-studio/AGENTS.md +61 -0
  75. package/templates/ai-studio/README.md +83 -0
  76. package/templates/ai-studio/app/auth-form.tsx +124 -0
  77. package/templates/ai-studio/app/error.tsx +26 -0
  78. package/templates/ai-studio/app/globals.css +148 -0
  79. package/templates/ai-studio/app/layout.tsx +75 -0
  80. package/templates/ai-studio/app/login/page.tsx +39 -0
  81. package/templates/ai-studio/app/not-found.tsx +19 -0
  82. package/templates/ai-studio/app/page.tsx +34 -0
  83. package/templates/ai-studio/app/studio-client.tsx +357 -0
  84. package/templates/ai-studio/app.ts +108 -0
  85. package/templates/ai-studio/components.json +20 -0
  86. package/templates/ai-studio/functions/_getGeneration.ts +25 -0
  87. package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
  88. package/templates/ai-studio/functions/generate.ts +42 -0
  89. package/templates/ai-studio/functions/pollGeneration.ts +134 -0
  90. package/templates/ai-studio/gitignore +10 -0
  91. package/templates/ai-studio/lib/site.config.ts +80 -0
  92. package/templates/ai-studio/lib/studio.ts +52 -0
  93. package/templates/ai-studio/lib/utils.ts +10 -0
  94. package/templates/ai-studio/package.json +34 -0
  95. package/templates/ai-studio/tsconfig.json +18 -0
  96. package/templates/creator/.env.example +12 -0
  97. package/templates/creator/AGENTS.md +61 -0
  98. package/templates/creator/README.md +67 -0
  99. package/templates/creator/app/auth-form.tsx +129 -0
  100. package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
  101. package/templates/creator/app/dashboard/page.tsx +70 -0
  102. package/templates/creator/app/error.tsx +26 -0
  103. package/templates/creator/app/globals.css +148 -0
  104. package/templates/creator/app/layout.tsx +160 -0
  105. package/templates/creator/app/login/page.tsx +39 -0
  106. package/templates/creator/app/newsletter-signup.tsx +162 -0
  107. package/templates/creator/app/not-found.tsx +19 -0
  108. package/templates/creator/app/page.tsx +160 -0
  109. package/templates/creator/app/robots.ts +12 -0
  110. package/templates/creator/app/sitemap.ts +9 -0
  111. package/templates/creator/app.ts +134 -0
  112. package/templates/creator/components/marketing.tsx +148 -0
  113. package/templates/creator/components/section-scroller.tsx +35 -0
  114. package/templates/creator/components/ui/button.tsx +56 -0
  115. package/templates/creator/components/ui/card.tsx +90 -0
  116. package/templates/creator/components.json +20 -0
  117. package/templates/creator/functions/subscribe.ts +82 -0
  118. package/templates/creator/functions/subscriberStats.ts +75 -0
  119. package/templates/creator/gitignore +10 -0
  120. package/templates/creator/lib/owner.ts +26 -0
  121. package/templates/creator/lib/site.config.ts +173 -0
  122. package/templates/creator/lib/stats.ts +30 -0
  123. package/templates/creator/lib/utils.ts +10 -0
  124. package/templates/creator/package.json +34 -0
  125. package/templates/creator/tsconfig.json +18 -0
  126. package/templates/default/app/layout.tsx +26 -27
  127. package/templates/default/app/page.tsx +90 -274
  128. package/templates/default/lib/products.ts +9 -122
  129. package/templates/default/lib/site.config.ts +739 -0
  130. package/templates/default/lib/site.ts +14 -261
  131. package/templates/directory/.env.example +12 -0
  132. package/templates/directory/AGENTS.md +61 -0
  133. package/templates/directory/README.md +80 -0
  134. package/templates/directory/app/auth-form.tsx +129 -0
  135. package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
  136. package/templates/directory/app/dashboard/page.tsx +70 -0
  137. package/templates/directory/app/directory-browse.tsx +328 -0
  138. package/templates/directory/app/error.tsx +26 -0
  139. package/templates/directory/app/globals.css +148 -0
  140. package/templates/directory/app/layout.tsx +171 -0
  141. package/templates/directory/app/login/page.tsx +39 -0
  142. package/templates/directory/app/not-found.tsx +19 -0
  143. package/templates/directory/app/page.tsx +50 -0
  144. package/templates/directory/app/robots.ts +12 -0
  145. package/templates/directory/app/sitemap.ts +9 -0
  146. package/templates/directory/app/submit/page.tsx +30 -0
  147. package/templates/directory/app/submit-form.tsx +151 -0
  148. package/templates/directory/app.ts +146 -0
  149. package/templates/directory/components/marketing.tsx +148 -0
  150. package/templates/directory/components/section-scroller.tsx +35 -0
  151. package/templates/directory/components/ui/button.tsx +56 -0
  152. package/templates/directory/components/ui/card.tsx +90 -0
  153. package/templates/directory/components.json +20 -0
  154. package/templates/directory/functions/approveSubmission.ts +45 -0
  155. package/templates/directory/functions/rejectSubmission.ts +20 -0
  156. package/templates/directory/functions/seedListings.ts +33 -0
  157. package/templates/directory/functions/submissionsForOwner.ts +29 -0
  158. package/templates/directory/functions/submitListing.ts +63 -0
  159. package/templates/directory/functions/upvote.ts +24 -0
  160. package/templates/directory/gitignore +10 -0
  161. package/templates/directory/lib/directory.ts +45 -0
  162. package/templates/directory/lib/owner.ts +26 -0
  163. package/templates/directory/lib/site.config.ts +130 -0
  164. package/templates/directory/lib/utils.ts +10 -0
  165. package/templates/directory/package.json +34 -0
  166. package/templates/directory/tsconfig.json +18 -0
  167. package/templates/local-service/.env.example +12 -0
  168. package/templates/local-service/AGENTS.md +61 -0
  169. package/templates/local-service/README.md +82 -0
  170. package/templates/local-service/app/auth-form.tsx +129 -0
  171. package/templates/local-service/app/booking-widget.tsx +399 -0
  172. package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
  173. package/templates/local-service/app/dashboard/page.tsx +63 -0
  174. package/templates/local-service/app/error.tsx +26 -0
  175. package/templates/local-service/app/globals.css +148 -0
  176. package/templates/local-service/app/layout.tsx +151 -0
  177. package/templates/local-service/app/login/page.tsx +39 -0
  178. package/templates/local-service/app/not-found.tsx +19 -0
  179. package/templates/local-service/app/page.tsx +233 -0
  180. package/templates/local-service/app/robots.ts +12 -0
  181. package/templates/local-service/app/sitemap.ts +9 -0
  182. package/templates/local-service/app.ts +131 -0
  183. package/templates/local-service/components/marketing.tsx +162 -0
  184. package/templates/local-service/components/section-scroller.tsx +35 -0
  185. package/templates/local-service/components/ui/button.tsx +56 -0
  186. package/templates/local-service/components/ui/card.tsx +90 -0
  187. package/templates/local-service/components.json +20 -0
  188. package/templates/local-service/functions/bookingsForOwner.ts +30 -0
  189. package/templates/local-service/functions/cancelBooking.ts +27 -0
  190. package/templates/local-service/functions/confirmBooking.ts +18 -0
  191. package/templates/local-service/functions/createBooking.ts +98 -0
  192. package/templates/local-service/gitignore +10 -0
  193. package/templates/local-service/lib/booking.ts +24 -0
  194. package/templates/local-service/lib/owner.ts +26 -0
  195. package/templates/local-service/lib/site.config.ts +232 -0
  196. package/templates/local-service/lib/slots.ts +97 -0
  197. package/templates/local-service/lib/utils.ts +10 -0
  198. package/templates/local-service/package.json +34 -0
  199. package/templates/local-service/tsconfig.json +18 -0
  200. package/templates/marketplace/.env.example +9 -0
  201. package/templates/marketplace/AGENTS.md +61 -0
  202. package/templates/marketplace/README.md +78 -0
  203. package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
  204. package/templates/marketplace/app/error.tsx +26 -0
  205. package/templates/marketplace/app/globals.css +64 -0
  206. package/templates/marketplace/app/layout.tsx +60 -0
  207. package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
  208. package/templates/marketplace/app/me/page.tsx +15 -0
  209. package/templates/marketplace/app/not-found.tsx +20 -0
  210. package/templates/marketplace/app/page.tsx +159 -0
  211. package/templates/marketplace/app/robots.ts +12 -0
  212. package/templates/marketplace/app/sell/page.tsx +26 -0
  213. package/templates/marketplace/app/sitemap.ts +14 -0
  214. package/templates/marketplace/app.ts +190 -0
  215. package/templates/marketplace/client/AuthNav.tsx +46 -0
  216. package/templates/marketplace/client/LiveTicker.tsx +104 -0
  217. package/templates/marketplace/client/LoginCard.tsx +130 -0
  218. package/templates/marketplace/client/MarketProvider.tsx +148 -0
  219. package/templates/marketplace/client/MyMarket.tsx +180 -0
  220. package/templates/marketplace/client/OfferPanel.tsx +355 -0
  221. package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
  222. package/templates/marketplace/client/SellForm.tsx +160 -0
  223. package/templates/marketplace/client/WatchButton.tsx +88 -0
  224. package/templates/marketplace/client/market.ts +341 -0
  225. package/templates/marketplace/functions/buyNow.ts +78 -0
  226. package/templates/marketplace/functions/makeOffer.ts +65 -0
  227. package/templates/marketplace/functions/respondToOffer.ts +62 -0
  228. package/templates/marketplace/functions/seedMarket.ts +90 -0
  229. package/templates/marketplace/gitignore +10 -0
  230. package/templates/marketplace/package.json +35 -0
  231. package/templates/marketplace/tsconfig.json +14 -0
  232. package/templates/marketplace/ui/badge.tsx +30 -0
  233. package/templates/marketplace/ui/button.tsx +49 -0
  234. package/templates/marketplace/ui/card.tsx +48 -0
  235. package/templates/marketplace/ui/input.tsx +17 -0
  236. package/templates/marketplace/ui/label.tsx +18 -0
  237. package/templates/marketplace/ui/textarea.tsx +17 -0
  238. package/templates/marketplace/ui/tokens.css +32 -0
  239. package/templates/marketplace/ui/utils.ts +6 -0
  240. package/templates/restaurant/.env.example +12 -0
  241. package/templates/restaurant/AGENTS.md +61 -0
  242. package/templates/restaurant/README.md +77 -0
  243. package/templates/restaurant/app/auth-form.tsx +129 -0
  244. package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
  245. package/templates/restaurant/app/dashboard/page.tsx +59 -0
  246. package/templates/restaurant/app/error.tsx +26 -0
  247. package/templates/restaurant/app/globals.css +148 -0
  248. package/templates/restaurant/app/layout.tsx +151 -0
  249. package/templates/restaurant/app/login/page.tsx +39 -0
  250. package/templates/restaurant/app/not-found.tsx +19 -0
  251. package/templates/restaurant/app/page.tsx +194 -0
  252. package/templates/restaurant/app/reservation-widget.tsx +359 -0
  253. package/templates/restaurant/app/robots.ts +12 -0
  254. package/templates/restaurant/app/sitemap.ts +9 -0
  255. package/templates/restaurant/app.ts +115 -0
  256. package/templates/restaurant/components/marketing.tsx +162 -0
  257. package/templates/restaurant/components/section-scroller.tsx +35 -0
  258. package/templates/restaurant/components/ui/button.tsx +56 -0
  259. package/templates/restaurant/components/ui/card.tsx +90 -0
  260. package/templates/restaurant/components.json +20 -0
  261. package/templates/restaurant/functions/cancelReservation.ts +26 -0
  262. package/templates/restaurant/functions/confirmReservation.ts +17 -0
  263. package/templates/restaurant/functions/createReservation.ts +92 -0
  264. package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
  265. package/templates/restaurant/gitignore +10 -0
  266. package/templates/restaurant/lib/owner.ts +26 -0
  267. package/templates/restaurant/lib/reservation.ts +22 -0
  268. package/templates/restaurant/lib/site.config.ts +218 -0
  269. package/templates/restaurant/lib/slots.ts +55 -0
  270. package/templates/restaurant/lib/utils.ts +10 -0
  271. package/templates/restaurant/package.json +34 -0
  272. package/templates/restaurant/tsconfig.json +18 -0
  273. package/templates/shop/.env.example +32 -0
  274. package/templates/shop/AGENTS.md +61 -0
  275. package/templates/shop/README.md +102 -0
  276. package/templates/shop/app/auth-form.tsx +129 -0
  277. package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
  278. package/templates/shop/app/dashboard/page.tsx +59 -0
  279. package/templates/shop/app/error.tsx +26 -0
  280. package/templates/shop/app/globals.css +148 -0
  281. package/templates/shop/app/layout.tsx +160 -0
  282. package/templates/shop/app/login/page.tsx +39 -0
  283. package/templates/shop/app/not-found.tsx +19 -0
  284. package/templates/shop/app/page.tsx +95 -0
  285. package/templates/shop/app/robots.ts +12 -0
  286. package/templates/shop/app/shop-client.tsx +436 -0
  287. package/templates/shop/app/sitemap.ts +9 -0
  288. package/templates/shop/app/success/page.tsx +33 -0
  289. package/templates/shop/app.ts +134 -0
  290. package/templates/shop/components/marketing.tsx +96 -0
  291. package/templates/shop/components/section-scroller.tsx +35 -0
  292. package/templates/shop/components/ui/button.tsx +56 -0
  293. package/templates/shop/components/ui/card.tsx +90 -0
  294. package/templates/shop/components.json +20 -0
  295. package/templates/shop/functions/cancelOrder.ts +33 -0
  296. package/templates/shop/functions/checkout.ts +130 -0
  297. package/templates/shop/functions/fulfillOrder.ts +17 -0
  298. package/templates/shop/functions/markGroupPaid.ts +26 -0
  299. package/templates/shop/functions/ordersForOwner.ts +28 -0
  300. package/templates/shop/functions/releaseGroup.ts +36 -0
  301. package/templates/shop/functions/reserveCart.ts +87 -0
  302. package/templates/shop/functions/restockProduct.ts +23 -0
  303. package/templates/shop/functions/seedProducts.ts +30 -0
  304. package/templates/shop/functions/stripeWebhook.ts +72 -0
  305. package/templates/shop/gitignore +10 -0
  306. package/templates/shop/lib/owner.ts +26 -0
  307. package/templates/shop/lib/shop.ts +45 -0
  308. package/templates/shop/lib/site.config.ts +198 -0
  309. package/templates/shop/lib/utils.ts +10 -0
  310. package/templates/shop/package.json +35 -0
  311. package/templates/shop/tsconfig.json +18 -0
  312. package/templates/waitlist/.env.example +12 -0
  313. package/templates/waitlist/AGENTS.md +61 -0
  314. package/templates/waitlist/README.md +81 -0
  315. package/templates/waitlist/app/auth-form.tsx +129 -0
  316. package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
  317. package/templates/waitlist/app/dashboard/page.tsx +70 -0
  318. package/templates/waitlist/app/error.tsx +26 -0
  319. package/templates/waitlist/app/globals.css +148 -0
  320. package/templates/waitlist/app/layout.tsx +158 -0
  321. package/templates/waitlist/app/login/page.tsx +39 -0
  322. package/templates/waitlist/app/not-found.tsx +19 -0
  323. package/templates/waitlist/app/page.tsx +119 -0
  324. package/templates/waitlist/app/robots.ts +12 -0
  325. package/templates/waitlist/app/sitemap.ts +9 -0
  326. package/templates/waitlist/app/waitlist-hero.tsx +219 -0
  327. package/templates/waitlist/app.ts +134 -0
  328. package/templates/waitlist/components/marketing.tsx +96 -0
  329. package/templates/waitlist/components/ui/button.tsx +56 -0
  330. package/templates/waitlist/components/ui/card.tsx +90 -0
  331. package/templates/waitlist/components.json +20 -0
  332. package/templates/waitlist/functions/joinWaitlist.ts +82 -0
  333. package/templates/waitlist/functions/waitlistStats.ts +75 -0
  334. package/templates/waitlist/gitignore +10 -0
  335. package/templates/waitlist/lib/owner.ts +26 -0
  336. package/templates/waitlist/lib/site.config.ts +178 -0
  337. package/templates/waitlist/lib/stats.ts +30 -0
  338. package/templates/waitlist/lib/utils.ts +10 -0
  339. package/templates/waitlist/package.json +34 -0
  340. package/templates/waitlist/tsconfig.json +18 -0
@@ -0,0 +1,115 @@
1
+ import {
2
+ entity,
3
+ field,
4
+ policy,
5
+ auth,
6
+ buildManifest,
7
+ discoverAppRoutes,
8
+ } from "@pylonsync/sdk";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // restaurant — a food & hospitality site with LIVE reservation availability.
12
+ // Same realtime engine as `local-service`, but capacity-based: each seating
13
+ // time has N tables, so the picker shows "4 left" and a time greys out for
14
+ // EVERYONE the instant the last table goes — no refresh. The server re-checks
15
+ // the count at insert time (under a per-slot lock) so two parties can't claim
16
+ // the last table at once.
17
+ //
18
+ // • Reservation — the real booking, with the guest's name/email/phone +
19
+ // party size. Holds PII → denies ALL client reads/writes.
20
+ // • ReservationSlot — a PII-free `{ startsAt }` marker, one per reservation,
21
+ // PUBLIC-READ so the picker can COUNT how many tables are
22
+ // taken per seating and show what's left, live.
23
+ // • User — the owner's account (email/password) for the dashboard.
24
+ //
25
+ // Menu + seating hours + tables-per-seating are CONFIG (lib/site.config.ts).
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const Reservation = entity(
29
+ "Reservation",
30
+ {
31
+ startsAt: field.datetime(), // the seating time
32
+ partySize: field.int(),
33
+ customerName: field.string(),
34
+ customerEmail: field.string(),
35
+ customerPhone: field.string().optional(),
36
+ notes: field.string().optional(),
37
+ status: field.string().default("pending"), // "pending" | "confirmed" | "cancelled"
38
+ createdAt: field.datetime().defaultNow(),
39
+ },
40
+ { indexes: [{ name: "by_start", fields: ["startsAt"], unique: false }] },
41
+ );
42
+
43
+ // PII-free occupancy marker. One row per active reservation; the picker counts
44
+ // rows per `startsAt` to know how many tables are taken. Cancelling a
45
+ // reservation deletes its marker, which frees a table live.
46
+ const ReservationSlot = entity(
47
+ "ReservationSlot",
48
+ {
49
+ startsAt: field.datetime(),
50
+ reservationId: field.id("Reservation"),
51
+ },
52
+ { indexes: [{ name: "by_start", fields: ["startsAt"], unique: false }] },
53
+ );
54
+
55
+ const User = entity(
56
+ "User",
57
+ {
58
+ email: field.string(),
59
+ displayName: field.string().optional(),
60
+ passwordHash: field.string().serverOnly().optional(),
61
+ avatarColor: field.string().optional(),
62
+ emailVerified: field.datetime().optional(),
63
+ createdAt: field.datetime().defaultNow(),
64
+ },
65
+ { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
66
+ );
67
+
68
+ // PRIVACY — Reservation holds the guest's contact details + notes, so it denies
69
+ // EVERY client read and write. The public page only ever reads ReservationSlot
70
+ // (a bare time marker), and the full reservations come back only through the
71
+ // owner-gated `reservationsForOwner`.
72
+ const reservationPolicy = policy({
73
+ name: "reservation_private",
74
+ entity: "Reservation",
75
+ allowRead: "false",
76
+ allowInsert: "false",
77
+ allowUpdate: "false",
78
+ allowDelete: "false",
79
+ });
80
+
81
+ // Occupancy markers are public to READ (just a timestamp — the point is the
82
+ // live "tables left" count). Clients can't WRITE them; only createReservation /
83
+ // cancelReservation maintain them server-side.
84
+ const reservationSlotPolicy = policy({
85
+ name: "reservation_slot_public_read",
86
+ entity: "ReservationSlot",
87
+ allowRead: "true",
88
+ allowInsert: "false",
89
+ allowUpdate: "false",
90
+ allowDelete: "false",
91
+ });
92
+
93
+ const userPolicy = policy({
94
+ name: "user_self",
95
+ entity: "User",
96
+ allowRead: "auth.userId == data.id",
97
+ allowInsert: "false",
98
+ allowUpdate: "false",
99
+ allowDelete: "false",
100
+ });
101
+
102
+ const manifest = buildManifest({
103
+ name: "__APP_NAME__",
104
+ version: "0.1.0",
105
+ entities: [Reservation, ReservationSlot, User],
106
+ queries: [],
107
+ actions: [],
108
+ policies: [reservationPolicy, reservationSlotPolicy, userPolicy],
109
+ auth: auth(),
110
+ routes: await discoverAppRoutes(),
111
+ });
112
+
113
+ console.log(JSON.stringify(manifest, null, 2));
114
+
115
+ export default manifest;
@@ -0,0 +1,162 @@
1
+ import React from "react";
2
+
3
+ // Reusable presentational pieces for the landing page. All server-rendered —
4
+ // no client JS. Restyle here and the whole page follows. The brand accent
5
+ // (`text-brand`, `bg-brand-soft`) comes from CSS vars set on <html> in
6
+ // app/layout.tsx, which read lib/site.config.ts — so re-theming is one edit.
7
+
8
+ // Shared container: a contained, centered column.
9
+ export const WRAP = "mx-auto w-full max-w-5xl px-6";
10
+
11
+ export function Eyebrow({ children }: { children: React.ReactNode }) {
12
+ return (
13
+ <p className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-brand">
14
+ {children}
15
+ </p>
16
+ );
17
+ }
18
+
19
+ // "New / Coming soon"-style pill for the hero.
20
+ export function Badge({ children }: { children: React.ReactNode }) {
21
+ return (
22
+ <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1.5 pr-3 text-[13px] text-zinc-600 shadow-sm">
23
+ <span className="inline-block size-1.5 rounded-full bg-brand" />
24
+ {children}
25
+ </span>
26
+ );
27
+ }
28
+
29
+ export function Divider() {
30
+ return (
31
+ <div className={WRAP}>
32
+ <div className="border-t border-zinc-200/70" />
33
+ </div>
34
+ );
35
+ }
36
+
37
+ export function SectionHead({
38
+ eyebrow,
39
+ title,
40
+ body,
41
+ }: {
42
+ eyebrow: string;
43
+ title: string;
44
+ body?: string;
45
+ }) {
46
+ return (
47
+ <div>
48
+ <Eyebrow>{eyebrow}</Eyebrow>
49
+ <h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
50
+ {title}
51
+ </h2>
52
+ {body ? (
53
+ <p className="mt-4 max-w-xl text-[15px] leading-relaxed text-zinc-500">
54
+ {body}
55
+ </p>
56
+ ) : null}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ // A grid of value props — icon + title + body.
62
+ export function FeatureGrid({
63
+ items,
64
+ }: {
65
+ items: { title: string; body: string; icon?: string }[];
66
+ }) {
67
+ return (
68
+ <div className="grid gap-6 sm:grid-cols-3">
69
+ {items.map((f) => (
70
+ <div key={f.title}>
71
+ {f.icon ? (
72
+ <span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
73
+ {f.icon}
74
+ </span>
75
+ ) : null}
76
+ <h3 className="mt-4 text-[15px] font-semibold text-zinc-900">
77
+ {f.title}
78
+ </h3>
79
+ <p className="mt-2 text-[14px] leading-relaxed text-zinc-500">
80
+ {f.body}
81
+ </p>
82
+ </div>
83
+ ))}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ // Initials for testimonial avatars, so the cards look finished without a photo.
89
+ export function initials(name: string) {
90
+ return name
91
+ .split(/\s+/)
92
+ .map((w) => w[0])
93
+ .join("")
94
+ .slice(0, 2)
95
+ .toUpperCase();
96
+ }
97
+
98
+ // A deliberately-obvious image placeholder. Real sites drop a photo here; this
99
+ // makes the spot unmistakable — dashed border, a photo glyph, and a one-line
100
+ // "swap this" instruction telling you exactly what to replace and where. Looks
101
+ // tidy enough to demo, but no one will mistake it for a finished design.
102
+ //
103
+ // shape — "landscape" | "portrait" | "square" | "circle"
104
+ // title — what photo belongs here ("A photo of your dining room")
105
+ // hint — how to replace it ("Swap for an <img> in app/page.tsx")
106
+ export function ImagePlaceholder({
107
+ shape = "landscape",
108
+ title,
109
+ hint,
110
+ className = "",
111
+ }: {
112
+ shape?: "landscape" | "portrait" | "square" | "circle";
113
+ title: string;
114
+ hint?: string;
115
+ className?: string;
116
+ }) {
117
+ const aspect =
118
+ shape === "portrait"
119
+ ? "aspect-[4/5]"
120
+ : shape === "square" || shape === "circle"
121
+ ? "aspect-square"
122
+ : "aspect-[4/3]";
123
+ const radius = shape === "circle" ? "rounded-full" : "rounded-2xl";
124
+ return (
125
+ <div
126
+ className={`relative grid place-items-center overflow-hidden border-2 border-dashed border-zinc-300 bg-zinc-50 ${aspect} ${radius} ${className}`}
127
+ >
128
+ <div className="px-6 text-center">
129
+ <svg
130
+ className="mx-auto size-7 text-zinc-300"
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ strokeWidth="1.5"
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ aria-hidden
138
+ >
139
+ <rect x="3" y="3" width="18" height="18" rx="2" />
140
+ <circle cx="9" cy="9" r="1.6" />
141
+ <path d="m21 15-4.5-4.5L7 20" />
142
+ </svg>
143
+ <p className="mt-3 text-[13px] font-medium text-zinc-500">{title}</p>
144
+ {hint ? <p className="mt-1 text-[11.5px] leading-snug text-zinc-400">{hint}</p> : null}
145
+ </div>
146
+ </div>
147
+ );
148
+ }
149
+
150
+ // A small "live" pill — float it over a hero image to keep the realtime hook
151
+ // visible (e.g. "Tables update live"). Pure decoration; no client JS.
152
+ export function LiveBadge({ children }: { children: React.ReactNode }) {
153
+ return (
154
+ <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/95 px-3 py-1.5 text-[12.5px] font-medium text-zinc-700 shadow-sm backdrop-blur">
155
+ <span className="relative flex size-2">
156
+ <span className="absolute inline-flex size-2 animate-ping rounded-full bg-green-500/60" />
157
+ <span className="relative inline-flex size-2 rounded-full bg-green-600" />
158
+ </span>
159
+ {children}
160
+ </span>
161
+ );
162
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+
5
+ // Makes in-page section links work. A hydrated Pylon page updates the URL for a
6
+ // plain `<a href="#section">` click but doesn't perform the browser's native
7
+ // fragment scroll, so the page jumps nowhere. This installs ONE delegated click
8
+ // handler that catches any same-page `#`/`/#` anchor and scrolls to it smoothly.
9
+ //
10
+ // Render it once (in the root layout). Renders nothing. Real route links should
11
+ // still use `<Link>` from @pylonsync/react — this only handles `#` anchors.
12
+ export function SectionScroller() {
13
+ useEffect(() => {
14
+ function onClick(e: MouseEvent) {
15
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
16
+ return;
17
+ }
18
+ const target = e.target as Element | null;
19
+ const link = target?.closest?.('a[href^="#"], a[href^="/#"]') as HTMLAnchorElement | null;
20
+ if (!link) return;
21
+ const href = link.getAttribute("href") || "";
22
+ const id = href.slice(href.indexOf("#") + 1);
23
+ if (!id) return;
24
+ const el = document.getElementById(id);
25
+ if (!el) return; // target not on this page — leave it to the browser
26
+ e.preventDefault();
27
+ el.scrollIntoView({ block: "start" });
28
+ history.replaceState(null, "", "#" + id);
29
+ }
30
+ document.addEventListener("click", onClick);
31
+ return () => document.removeEventListener("click", onClick);
32
+ }, []);
33
+
34
+ return null;
35
+ }
@@ -0,0 +1,56 @@
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
13
+ destructive:
14
+ "bg-destructive text-white hover:bg-destructive/90",
15
+ outline:
16
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost: "hover:bg-accent hover:text-accent-foreground",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default: "h-9 px-4 py-2",
24
+ sm: "h-8 rounded-md px-3 text-xs",
25
+ lg: "h-10 rounded-md px-8",
26
+ icon: "h-9 w-9",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ variant: "default",
31
+ size: "default",
32
+ },
33
+ },
34
+ );
35
+
36
+ export interface ButtonProps
37
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
38
+ VariantProps<typeof buttonVariants> {
39
+ asChild?: boolean;
40
+ }
41
+
42
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
43
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
44
+ const Comp = asChild ? Slot : "button";
45
+ return (
46
+ <Comp
47
+ className={cn(buttonVariants({ variant, size, className }))}
48
+ ref={ref}
49
+ {...props}
50
+ />
51
+ );
52
+ },
53
+ );
54
+ Button.displayName = "Button";
55
+
56
+ export { Button, buttonVariants };
@@ -0,0 +1,90 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
6
+ return (
7
+ <div
8
+ data-slot="card"
9
+ className={cn(
10
+ "rounded-xl border bg-card text-card-foreground shadow-sm",
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ function CardHeader({
19
+ className,
20
+ ...props
21
+ }: React.HTMLAttributes<HTMLDivElement>) {
22
+ return (
23
+ <div
24
+ data-slot="card-header"
25
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
26
+ {...props}
27
+ />
28
+ );
29
+ }
30
+
31
+ function CardTitle({
32
+ className,
33
+ ...props
34
+ }: React.HTMLAttributes<HTMLDivElement>) {
35
+ return (
36
+ <div
37
+ data-slot="card-title"
38
+ className={cn("font-semibold leading-none tracking-tight", className)}
39
+ {...props}
40
+ />
41
+ );
42
+ }
43
+
44
+ function CardDescription({
45
+ className,
46
+ ...props
47
+ }: React.HTMLAttributes<HTMLDivElement>) {
48
+ return (
49
+ <div
50
+ data-slot="card-description"
51
+ className={cn("text-sm text-muted-foreground", className)}
52
+ {...props}
53
+ />
54
+ );
55
+ }
56
+
57
+ function CardContent({
58
+ className,
59
+ ...props
60
+ }: React.HTMLAttributes<HTMLDivElement>) {
61
+ return (
62
+ <div
63
+ data-slot="card-content"
64
+ className={cn("p-6 pt-0", className)}
65
+ {...props}
66
+ />
67
+ );
68
+ }
69
+
70
+ function CardFooter({
71
+ className,
72
+ ...props
73
+ }: React.HTMLAttributes<HTMLDivElement>) {
74
+ return (
75
+ <div
76
+ data-slot="card-footer"
77
+ className={cn("flex items-center p-6 pt-0", className)}
78
+ {...props}
79
+ />
80
+ );
81
+ }
82
+
83
+ export {
84
+ Card,
85
+ CardHeader,
86
+ CardFooter,
87
+ CardTitle,
88
+ CardDescription,
89
+ CardContent,
90
+ };
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "app/globals.css",
9
+ "baseColor": "zinc",
10
+ "cssVariables": true
11
+ },
12
+ "aliases": {
13
+ "components": "@/components",
14
+ "utils": "@/lib/utils",
15
+ "ui": "@/components/ui",
16
+ "lib": "@/lib",
17
+ "hooks": "@/hooks"
18
+ },
19
+ "iconLibrary": "lucide"
20
+ }
@@ -0,0 +1,26 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // cancelReservation — owner-only. Marks the reservation cancelled AND deletes
5
+ // its ReservationSlot marker, which FREES a table at that seating: the deletion
6
+ // syncs to every open picker, so "tables left" ticks back up live.
7
+ export default mutation<{ reservationId: string }, { ok: boolean }>({
8
+ auth: "user",
9
+ args: { reservationId: v.id("Reservation") },
10
+ async handler(ctx, args) {
11
+ const me = await ctx.db.get("User", ctx.auth.userId);
12
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
13
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage reservations.");
14
+ }
15
+ await ctx.db.unsafe.update("Reservation", args.reservationId, { status: "cancelled" });
16
+
17
+ const markers = (await ctx.db.unsafe.list("ReservationSlot")) as unknown as {
18
+ id: string;
19
+ reservationId: string;
20
+ }[];
21
+ const marker = markers.find((m) => m.reservationId === args.reservationId);
22
+ if (marker) await ctx.db.unsafe.delete("ReservationSlot", marker.id);
23
+
24
+ return { ok: true };
25
+ },
26
+ });
@@ -0,0 +1,17 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+
4
+ // confirmReservation — owner-only. Marks a pending reservation confirmed; the
5
+ // ReservationSlot marker stays, so the table remains held.
6
+ export default mutation<{ reservationId: string }, { ok: boolean }>({
7
+ auth: "user",
8
+ args: { reservationId: v.id("Reservation") },
9
+ async handler(ctx, args) {
10
+ const me = await ctx.db.get("User", ctx.auth.userId);
11
+ if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
12
+ throw ctx.error("POLICY_DENIED", "Only the owner can manage reservations.");
13
+ }
14
+ await ctx.db.unsafe.update("Reservation", args.reservationId, { status: "confirmed" });
15
+ return { ok: true };
16
+ },
17
+ });
@@ -0,0 +1,92 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+ import { siteConfig } from "../lib/site.config";
3
+
4
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5
+
6
+ // createReservation — the ONLY writer of Reservation + its ReservationSlot
7
+ // marker. A transactional `mutation` (atomic + `ctx.db`); the marker insert
8
+ // fires a change event so every open picker recomputes "tables left" live.
9
+ //
10
+ // `auth: "public"` — a guest has no account. The server RE-CHECKS that the
11
+ // seating is still under capacity before inserting, under a per-seating advisory
12
+ // lock so two parties can't both grab the last table. Capacity is per seating
13
+ // time: count the markers sharing this `startsAt`, compare to tablesPerSlot.
14
+ //
15
+ // PRIVACY: returns only `{ ok, reason? }` — never a reservation row or any
16
+ // guest's contact details.
17
+ export default mutation<
18
+ {
19
+ startsAt: string;
20
+ partySize: number;
21
+ customerName: string;
22
+ customerEmail: string;
23
+ customerPhone?: string;
24
+ notes?: string;
25
+ },
26
+ { ok: boolean; reason?: "past" | "full" | "party" | "invalid" }
27
+ >({
28
+ auth: "public",
29
+ args: {
30
+ startsAt: v.string(),
31
+ partySize: v.int(),
32
+ customerName: v.string(),
33
+ customerEmail: v.string(),
34
+ customerPhone: v.optional(v.string()),
35
+ notes: v.optional(v.string()),
36
+ },
37
+ async handler(ctx, args) {
38
+ const cfg = siteConfig.reservations;
39
+ const name = args.customerName.trim();
40
+ const email = args.customerEmail.trim().toLowerCase();
41
+ const phone = args.customerPhone?.trim() || null;
42
+ const notes = args.notes?.trim() || null;
43
+
44
+ if (name.length < 1 || name.length > 120) {
45
+ throw ctx.error("INVALID_ARGS", "Enter your name.");
46
+ }
47
+ if (!EMAIL_RE.test(email) || email.length > 254) {
48
+ throw ctx.error("INVALID_ARGS", "Enter a valid email address.");
49
+ }
50
+ if (!Number.isInteger(args.partySize) || args.partySize < 1) {
51
+ return { ok: false, reason: "party" };
52
+ }
53
+ if (args.partySize > cfg.maxPartySize) {
54
+ return { ok: false, reason: "party" };
55
+ }
56
+
57
+ const startMs = Date.parse(args.startsAt);
58
+ if (Number.isNaN(startMs)) return { ok: false, reason: "invalid" };
59
+ const startsAt = new Date(startMs).toISOString();
60
+ if (startMs < Date.now() + cfg.leadTimeHours * 3_600_000) {
61
+ return { ok: false, reason: "past" };
62
+ }
63
+
64
+ // Serialize the count-then-insert for THIS seating so two parties can't
65
+ // both claim the last table. Held until the tx commits.
66
+ await ctx.db.advisoryLock(`reservation_slot:${startsAt}`);
67
+
68
+ // Capacity check: how many tables are already taken at this seating?
69
+ // Cross-user read of the deny-all-projection → `unsafe`.
70
+ const markers = (await ctx.db.unsafe.list("ReservationSlot")) as unknown as {
71
+ startsAt: string;
72
+ }[];
73
+ const taken = markers.filter((m) => m.startsAt === startsAt).length;
74
+ if (taken >= cfg.tablesPerSlot) {
75
+ return { ok: false, reason: "full" };
76
+ }
77
+
78
+ const reservationId = await ctx.db.unsafe.insert("Reservation", {
79
+ startsAt,
80
+ partySize: args.partySize,
81
+ customerName: name,
82
+ customerEmail: email,
83
+ customerPhone: phone,
84
+ notes,
85
+ status: "pending",
86
+ createdAt: new Date().toISOString(),
87
+ });
88
+ await ctx.db.unsafe.insert("ReservationSlot", { startsAt, reservationId });
89
+
90
+ return { ok: true };
91
+ },
92
+ });
@@ -0,0 +1,28 @@
1
+ import { query } from "@pylonsync/functions";
2
+ import { emailMatchesOwner } from "../lib/owner";
3
+ import type { ReservationRow, OwnerReservationsResult } from "../lib/reservation";
4
+
5
+ // reservationsForOwner — the owner's view of every reservation, INCLUDING the
6
+ // guest's name/email/phone + notes. The one function allowed to return that
7
+ // PII, gated to the configured owner (PYLON_OWNER_EMAIL via ctx.env).
8
+ //
9
+ // The dashboard calls it with `callFn` and re-fetches whenever the live, public
10
+ // ReservationSlot set changes — so new reservations + cancellations show up
11
+ // without a refresh, while contact details never travel over entity sync.
12
+ export default query({
13
+ auth: "user",
14
+ async handler(ctx): Promise<OwnerReservationsResult> {
15
+ const me = await ctx.db.get("User", ctx.auth.userId);
16
+ const email = (me?.email as string | undefined) ?? null;
17
+ if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
18
+ return { authorized: false };
19
+ }
20
+
21
+ const rows = (await ctx.db.unsafe.list("Reservation")) as unknown as ReservationRow[];
22
+ const reservations = rows
23
+ .map((r) => ({ ...r }))
24
+ .sort((a, b) => (a.startsAt < b.startsAt ? -1 : a.startsAt > b.startsAt ? 1 : 0));
25
+
26
+ return { authorized: true, reservations };
27
+ },
28
+ });
@@ -0,0 +1,10 @@
1
+ node_modules/
2
+ .pylon/
3
+ pylon.manifest.json
4
+ pylon.client.ts
5
+ web/dist/
6
+ *.db
7
+ *.db-*
8
+ .env
9
+ .env.local
10
+ .DS_Store
@@ -0,0 +1,26 @@
1
+ // Who owns this waitlist? A waitlist is single-tenant — one business, one
2
+ // owner — so ownership is just "the email the owner signs in with", configured
3
+ // once via the PYLON_OWNER_EMAIL env var. The owner-only `waitlistStats`
4
+ // function reads that env (via `ctx.env`) and compares it here.
5
+ //
6
+ // Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
7
+ // dashboard stays locked. That's deliberate — an unset owner on a public site
8
+ // must not mean "everyone can read the signups". Set it in .env (see
9
+ // .env.example) before signing in.
10
+
11
+ export function normalizeOwner(raw: string | null | undefined): string | null {
12
+ const v = raw?.trim().toLowerCase();
13
+ return v && v.length > 0 ? v : null;
14
+ }
15
+
16
+ // Pure comparator — the caller supplies the configured owner value (from
17
+ // `ctx.env.PYLON_OWNER_EMAIL`), so the rule lives in one place and stays
18
+ // testable without reaching for the environment here.
19
+ export function emailMatchesOwner(
20
+ email: string | null | undefined,
21
+ ownerRaw: string | null | undefined,
22
+ ): boolean {
23
+ const owner = normalizeOwner(ownerRaw);
24
+ if (!owner) return false;
25
+ return (email ?? "").trim().toLowerCase() === owner;
26
+ }