@pylonsync/create-pylon 0.3.274 → 0.3.275

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 (323) 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 +286 -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 +207 -0
  17. package/templates/agency/app/robots.ts +12 -0
  18. package/templates/agency/app/sitemap.ts +9 -0
  19. package/templates/agency/app.ts +135 -0
  20. package/templates/agency/components/marketing.tsx +148 -0
  21. package/templates/agency/components/section-scroller.tsx +35 -0
  22. package/templates/agency/components/ui/button.tsx +56 -0
  23. package/templates/agency/components/ui/card.tsx +90 -0
  24. package/templates/agency/components.json +20 -0
  25. package/templates/agency/functions/bookInquiry.ts +42 -0
  26. package/templates/agency/functions/declineInquiry.ts +41 -0
  27. package/templates/agency/functions/inquiriesForOwner.ts +31 -0
  28. package/templates/agency/functions/seedCapacity.ts +26 -0
  29. package/templates/agency/functions/setCapacity.ts +32 -0
  30. package/templates/agency/functions/submitInquiry.ts +55 -0
  31. package/templates/agency/gitignore +10 -0
  32. package/templates/agency/lib/agency.ts +27 -0
  33. package/templates/agency/lib/owner.ts +26 -0
  34. package/templates/agency/lib/site.config.ts +239 -0
  35. package/templates/agency/lib/utils.ts +10 -0
  36. package/templates/agency/package.json +34 -0
  37. package/templates/agency/tsconfig.json +18 -0
  38. package/templates/ai-chat/.env.example +33 -0
  39. package/templates/ai-chat/AGENTS.md +61 -0
  40. package/templates/ai-chat/README.md +99 -0
  41. package/templates/ai-chat/app/auth-form.tsx +124 -0
  42. package/templates/ai-chat/app/chat-client.tsx +414 -0
  43. package/templates/ai-chat/app/error.tsx +26 -0
  44. package/templates/ai-chat/app/globals.css +148 -0
  45. package/templates/ai-chat/app/layout.tsx +75 -0
  46. package/templates/ai-chat/app/login/page.tsx +39 -0
  47. package/templates/ai-chat/app/not-found.tsx +19 -0
  48. package/templates/ai-chat/app/page.tsx +23 -0
  49. package/templates/ai-chat/app.ts +121 -0
  50. package/templates/ai-chat/components.json +20 -0
  51. package/templates/ai-chat/gitignore +10 -0
  52. package/templates/ai-chat/lib/site.config.ts +103 -0
  53. package/templates/ai-chat/lib/utils.ts +10 -0
  54. package/templates/ai-chat/package.json +34 -0
  55. package/templates/ai-chat/tsconfig.json +18 -0
  56. package/templates/ai-studio/.env.example +19 -0
  57. package/templates/ai-studio/AGENTS.md +61 -0
  58. package/templates/ai-studio/README.md +83 -0
  59. package/templates/ai-studio/app/auth-form.tsx +124 -0
  60. package/templates/ai-studio/app/error.tsx +26 -0
  61. package/templates/ai-studio/app/globals.css +148 -0
  62. package/templates/ai-studio/app/layout.tsx +75 -0
  63. package/templates/ai-studio/app/login/page.tsx +39 -0
  64. package/templates/ai-studio/app/not-found.tsx +19 -0
  65. package/templates/ai-studio/app/page.tsx +34 -0
  66. package/templates/ai-studio/app/studio-client.tsx +214 -0
  67. package/templates/ai-studio/app.ts +108 -0
  68. package/templates/ai-studio/components.json +20 -0
  69. package/templates/ai-studio/functions/_getGeneration.ts +25 -0
  70. package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
  71. package/templates/ai-studio/functions/generate.ts +42 -0
  72. package/templates/ai-studio/functions/pollGeneration.ts +134 -0
  73. package/templates/ai-studio/gitignore +10 -0
  74. package/templates/ai-studio/lib/site.config.ts +80 -0
  75. package/templates/ai-studio/lib/studio.ts +52 -0
  76. package/templates/ai-studio/lib/utils.ts +10 -0
  77. package/templates/ai-studio/package.json +34 -0
  78. package/templates/ai-studio/tsconfig.json +18 -0
  79. package/templates/creator/.env.example +12 -0
  80. package/templates/creator/AGENTS.md +61 -0
  81. package/templates/creator/README.md +67 -0
  82. package/templates/creator/app/auth-form.tsx +129 -0
  83. package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
  84. package/templates/creator/app/dashboard/page.tsx +70 -0
  85. package/templates/creator/app/error.tsx +26 -0
  86. package/templates/creator/app/globals.css +148 -0
  87. package/templates/creator/app/layout.tsx +160 -0
  88. package/templates/creator/app/login/page.tsx +39 -0
  89. package/templates/creator/app/newsletter-signup.tsx +162 -0
  90. package/templates/creator/app/not-found.tsx +19 -0
  91. package/templates/creator/app/page.tsx +160 -0
  92. package/templates/creator/app/robots.ts +12 -0
  93. package/templates/creator/app/sitemap.ts +9 -0
  94. package/templates/creator/app.ts +134 -0
  95. package/templates/creator/components/marketing.tsx +148 -0
  96. package/templates/creator/components/section-scroller.tsx +35 -0
  97. package/templates/creator/components/ui/button.tsx +56 -0
  98. package/templates/creator/components/ui/card.tsx +90 -0
  99. package/templates/creator/components.json +20 -0
  100. package/templates/creator/functions/subscribe.ts +82 -0
  101. package/templates/creator/functions/subscriberStats.ts +75 -0
  102. package/templates/creator/gitignore +10 -0
  103. package/templates/creator/lib/owner.ts +26 -0
  104. package/templates/creator/lib/site.config.ts +173 -0
  105. package/templates/creator/lib/stats.ts +30 -0
  106. package/templates/creator/lib/utils.ts +10 -0
  107. package/templates/creator/package.json +34 -0
  108. package/templates/creator/tsconfig.json +18 -0
  109. package/templates/default/app/layout.tsx +26 -27
  110. package/templates/default/app/page.tsx +90 -274
  111. package/templates/default/lib/products.ts +9 -122
  112. package/templates/default/lib/site.config.ts +739 -0
  113. package/templates/default/lib/site.ts +14 -261
  114. package/templates/directory/.env.example +12 -0
  115. package/templates/directory/AGENTS.md +61 -0
  116. package/templates/directory/README.md +80 -0
  117. package/templates/directory/app/auth-form.tsx +129 -0
  118. package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
  119. package/templates/directory/app/dashboard/page.tsx +70 -0
  120. package/templates/directory/app/directory-browse.tsx +328 -0
  121. package/templates/directory/app/error.tsx +26 -0
  122. package/templates/directory/app/globals.css +148 -0
  123. package/templates/directory/app/layout.tsx +171 -0
  124. package/templates/directory/app/login/page.tsx +39 -0
  125. package/templates/directory/app/not-found.tsx +19 -0
  126. package/templates/directory/app/page.tsx +50 -0
  127. package/templates/directory/app/robots.ts +12 -0
  128. package/templates/directory/app/sitemap.ts +9 -0
  129. package/templates/directory/app/submit/page.tsx +30 -0
  130. package/templates/directory/app/submit-form.tsx +151 -0
  131. package/templates/directory/app.ts +146 -0
  132. package/templates/directory/components/marketing.tsx +148 -0
  133. package/templates/directory/components/section-scroller.tsx +35 -0
  134. package/templates/directory/components/ui/button.tsx +56 -0
  135. package/templates/directory/components/ui/card.tsx +90 -0
  136. package/templates/directory/components.json +20 -0
  137. package/templates/directory/functions/approveSubmission.ts +45 -0
  138. package/templates/directory/functions/rejectSubmission.ts +20 -0
  139. package/templates/directory/functions/seedListings.ts +33 -0
  140. package/templates/directory/functions/submissionsForOwner.ts +29 -0
  141. package/templates/directory/functions/submitListing.ts +63 -0
  142. package/templates/directory/functions/upvote.ts +24 -0
  143. package/templates/directory/gitignore +10 -0
  144. package/templates/directory/lib/directory.ts +45 -0
  145. package/templates/directory/lib/owner.ts +26 -0
  146. package/templates/directory/lib/site.config.ts +130 -0
  147. package/templates/directory/lib/utils.ts +10 -0
  148. package/templates/directory/package.json +34 -0
  149. package/templates/directory/tsconfig.json +18 -0
  150. package/templates/local-service/.env.example +12 -0
  151. package/templates/local-service/AGENTS.md +61 -0
  152. package/templates/local-service/README.md +82 -0
  153. package/templates/local-service/app/auth-form.tsx +129 -0
  154. package/templates/local-service/app/booking-widget.tsx +399 -0
  155. package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
  156. package/templates/local-service/app/dashboard/page.tsx +63 -0
  157. package/templates/local-service/app/error.tsx +26 -0
  158. package/templates/local-service/app/globals.css +148 -0
  159. package/templates/local-service/app/layout.tsx +151 -0
  160. package/templates/local-service/app/login/page.tsx +39 -0
  161. package/templates/local-service/app/not-found.tsx +19 -0
  162. package/templates/local-service/app/page.tsx +233 -0
  163. package/templates/local-service/app/robots.ts +12 -0
  164. package/templates/local-service/app/sitemap.ts +9 -0
  165. package/templates/local-service/app.ts +131 -0
  166. package/templates/local-service/components/marketing.tsx +162 -0
  167. package/templates/local-service/components/section-scroller.tsx +35 -0
  168. package/templates/local-service/components/ui/button.tsx +56 -0
  169. package/templates/local-service/components/ui/card.tsx +90 -0
  170. package/templates/local-service/components.json +20 -0
  171. package/templates/local-service/functions/bookingsForOwner.ts +30 -0
  172. package/templates/local-service/functions/cancelBooking.ts +27 -0
  173. package/templates/local-service/functions/confirmBooking.ts +18 -0
  174. package/templates/local-service/functions/createBooking.ts +98 -0
  175. package/templates/local-service/gitignore +10 -0
  176. package/templates/local-service/lib/booking.ts +24 -0
  177. package/templates/local-service/lib/owner.ts +26 -0
  178. package/templates/local-service/lib/site.config.ts +232 -0
  179. package/templates/local-service/lib/slots.ts +97 -0
  180. package/templates/local-service/lib/utils.ts +10 -0
  181. package/templates/local-service/package.json +34 -0
  182. package/templates/local-service/tsconfig.json +18 -0
  183. package/templates/marketplace/.env.example +9 -0
  184. package/templates/marketplace/AGENTS.md +61 -0
  185. package/templates/marketplace/README.md +78 -0
  186. package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
  187. package/templates/marketplace/app/error.tsx +26 -0
  188. package/templates/marketplace/app/globals.css +64 -0
  189. package/templates/marketplace/app/layout.tsx +60 -0
  190. package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
  191. package/templates/marketplace/app/me/page.tsx +15 -0
  192. package/templates/marketplace/app/not-found.tsx +20 -0
  193. package/templates/marketplace/app/page.tsx +159 -0
  194. package/templates/marketplace/app/robots.ts +12 -0
  195. package/templates/marketplace/app/sell/page.tsx +26 -0
  196. package/templates/marketplace/app/sitemap.ts +14 -0
  197. package/templates/marketplace/app.ts +190 -0
  198. package/templates/marketplace/client/AuthNav.tsx +46 -0
  199. package/templates/marketplace/client/LiveTicker.tsx +104 -0
  200. package/templates/marketplace/client/LoginCard.tsx +130 -0
  201. package/templates/marketplace/client/MarketProvider.tsx +148 -0
  202. package/templates/marketplace/client/MyMarket.tsx +180 -0
  203. package/templates/marketplace/client/OfferPanel.tsx +355 -0
  204. package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
  205. package/templates/marketplace/client/SellForm.tsx +160 -0
  206. package/templates/marketplace/client/WatchButton.tsx +88 -0
  207. package/templates/marketplace/client/market.ts +341 -0
  208. package/templates/marketplace/functions/buyNow.ts +78 -0
  209. package/templates/marketplace/functions/makeOffer.ts +65 -0
  210. package/templates/marketplace/functions/respondToOffer.ts +62 -0
  211. package/templates/marketplace/functions/seedMarket.ts +90 -0
  212. package/templates/marketplace/gitignore +10 -0
  213. package/templates/marketplace/package.json +35 -0
  214. package/templates/marketplace/tsconfig.json +14 -0
  215. package/templates/marketplace/ui/badge.tsx +30 -0
  216. package/templates/marketplace/ui/button.tsx +49 -0
  217. package/templates/marketplace/ui/card.tsx +48 -0
  218. package/templates/marketplace/ui/input.tsx +17 -0
  219. package/templates/marketplace/ui/label.tsx +18 -0
  220. package/templates/marketplace/ui/textarea.tsx +17 -0
  221. package/templates/marketplace/ui/tokens.css +32 -0
  222. package/templates/marketplace/ui/utils.ts +6 -0
  223. package/templates/restaurant/.env.example +12 -0
  224. package/templates/restaurant/AGENTS.md +61 -0
  225. package/templates/restaurant/README.md +77 -0
  226. package/templates/restaurant/app/auth-form.tsx +129 -0
  227. package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
  228. package/templates/restaurant/app/dashboard/page.tsx +59 -0
  229. package/templates/restaurant/app/error.tsx +26 -0
  230. package/templates/restaurant/app/globals.css +148 -0
  231. package/templates/restaurant/app/layout.tsx +151 -0
  232. package/templates/restaurant/app/login/page.tsx +39 -0
  233. package/templates/restaurant/app/not-found.tsx +19 -0
  234. package/templates/restaurant/app/page.tsx +194 -0
  235. package/templates/restaurant/app/reservation-widget.tsx +359 -0
  236. package/templates/restaurant/app/robots.ts +12 -0
  237. package/templates/restaurant/app/sitemap.ts +9 -0
  238. package/templates/restaurant/app.ts +115 -0
  239. package/templates/restaurant/components/marketing.tsx +162 -0
  240. package/templates/restaurant/components/section-scroller.tsx +35 -0
  241. package/templates/restaurant/components/ui/button.tsx +56 -0
  242. package/templates/restaurant/components/ui/card.tsx +90 -0
  243. package/templates/restaurant/components.json +20 -0
  244. package/templates/restaurant/functions/cancelReservation.ts +26 -0
  245. package/templates/restaurant/functions/confirmReservation.ts +17 -0
  246. package/templates/restaurant/functions/createReservation.ts +92 -0
  247. package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
  248. package/templates/restaurant/gitignore +10 -0
  249. package/templates/restaurant/lib/owner.ts +26 -0
  250. package/templates/restaurant/lib/reservation.ts +22 -0
  251. package/templates/restaurant/lib/site.config.ts +218 -0
  252. package/templates/restaurant/lib/slots.ts +55 -0
  253. package/templates/restaurant/lib/utils.ts +10 -0
  254. package/templates/restaurant/package.json +34 -0
  255. package/templates/restaurant/tsconfig.json +18 -0
  256. package/templates/shop/.env.example +32 -0
  257. package/templates/shop/AGENTS.md +61 -0
  258. package/templates/shop/README.md +102 -0
  259. package/templates/shop/app/auth-form.tsx +129 -0
  260. package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
  261. package/templates/shop/app/dashboard/page.tsx +59 -0
  262. package/templates/shop/app/error.tsx +26 -0
  263. package/templates/shop/app/globals.css +148 -0
  264. package/templates/shop/app/layout.tsx +160 -0
  265. package/templates/shop/app/login/page.tsx +39 -0
  266. package/templates/shop/app/not-found.tsx +19 -0
  267. package/templates/shop/app/page.tsx +95 -0
  268. package/templates/shop/app/robots.ts +12 -0
  269. package/templates/shop/app/shop-client.tsx +436 -0
  270. package/templates/shop/app/sitemap.ts +9 -0
  271. package/templates/shop/app/success/page.tsx +33 -0
  272. package/templates/shop/app.ts +134 -0
  273. package/templates/shop/components/marketing.tsx +96 -0
  274. package/templates/shop/components/section-scroller.tsx +35 -0
  275. package/templates/shop/components/ui/button.tsx +56 -0
  276. package/templates/shop/components/ui/card.tsx +90 -0
  277. package/templates/shop/components.json +20 -0
  278. package/templates/shop/functions/cancelOrder.ts +33 -0
  279. package/templates/shop/functions/checkout.ts +130 -0
  280. package/templates/shop/functions/fulfillOrder.ts +17 -0
  281. package/templates/shop/functions/markGroupPaid.ts +26 -0
  282. package/templates/shop/functions/ordersForOwner.ts +28 -0
  283. package/templates/shop/functions/releaseGroup.ts +36 -0
  284. package/templates/shop/functions/reserveCart.ts +87 -0
  285. package/templates/shop/functions/restockProduct.ts +23 -0
  286. package/templates/shop/functions/seedProducts.ts +30 -0
  287. package/templates/shop/functions/stripeWebhook.ts +72 -0
  288. package/templates/shop/gitignore +10 -0
  289. package/templates/shop/lib/owner.ts +26 -0
  290. package/templates/shop/lib/shop.ts +45 -0
  291. package/templates/shop/lib/site.config.ts +198 -0
  292. package/templates/shop/lib/utils.ts +10 -0
  293. package/templates/shop/package.json +35 -0
  294. package/templates/shop/tsconfig.json +18 -0
  295. package/templates/waitlist/.env.example +12 -0
  296. package/templates/waitlist/AGENTS.md +61 -0
  297. package/templates/waitlist/README.md +81 -0
  298. package/templates/waitlist/app/auth-form.tsx +129 -0
  299. package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
  300. package/templates/waitlist/app/dashboard/page.tsx +70 -0
  301. package/templates/waitlist/app/error.tsx +26 -0
  302. package/templates/waitlist/app/globals.css +148 -0
  303. package/templates/waitlist/app/layout.tsx +158 -0
  304. package/templates/waitlist/app/login/page.tsx +39 -0
  305. package/templates/waitlist/app/not-found.tsx +19 -0
  306. package/templates/waitlist/app/page.tsx +119 -0
  307. package/templates/waitlist/app/robots.ts +12 -0
  308. package/templates/waitlist/app/sitemap.ts +9 -0
  309. package/templates/waitlist/app/waitlist-hero.tsx +219 -0
  310. package/templates/waitlist/app.ts +134 -0
  311. package/templates/waitlist/components/marketing.tsx +96 -0
  312. package/templates/waitlist/components/ui/button.tsx +56 -0
  313. package/templates/waitlist/components/ui/card.tsx +90 -0
  314. package/templates/waitlist/components.json +20 -0
  315. package/templates/waitlist/functions/joinWaitlist.ts +82 -0
  316. package/templates/waitlist/functions/waitlistStats.ts +75 -0
  317. package/templates/waitlist/gitignore +10 -0
  318. package/templates/waitlist/lib/owner.ts +26 -0
  319. package/templates/waitlist/lib/site.config.ts +178 -0
  320. package/templates/waitlist/lib/stats.ts +30 -0
  321. package/templates/waitlist/lib/utils.ts +10 -0
  322. package/templates/waitlist/package.json +34 -0
  323. package/templates/waitlist/tsconfig.json +18 -0
@@ -0,0 +1,61 @@
1
+ # AGENTS.md — working in a Pylon project
2
+
3
+ Operating rules for a coding agent in this Pylon app. Pylon is a Rails-like framework for realtime apps: you declare entities, policies, and server functions in TypeScript, and a single Rust binary (`pylon`) serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR — one process, one port. The full API reference is at **/llms-full.txt** (served at `/llms-full.txt`; in the repo at `apps/web/public/llms-full.txt`). Read it before guessing an API name.
4
+
5
+ ## Directory conventions
6
+
7
+ **Unified SSR app:**
8
+ - `app.ts` — data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
9
+ - `app/` — file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
10
+ - `app/globals.css` — Tailwind v4 entrypoint (auto-compiled and injected).
11
+ - `functions/` — server functions, one per file, `default`-exported.
12
+ - `.pylon/` — local dev state (sqlite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
13
+
14
+ **Monorepo app:** backend is `apps/api/` (entry `apps/api/schema.ts`, handlers in `apps/api/functions/`); frontend in `apps/web/`. `pylon.manifest.json` / `pylon.client.ts` are generated — do not hand-edit.
15
+
16
+ ## The core authoring loop
17
+
18
+ 1. **Define an entity** — `entity("Thing", { name: field.string(), done: field.boolean().default(false) })`. Modifiers: `.optional()`, `.unique()`, `.readonly()` (settable on insert, rejected on client update — use for `authorId`/`orgId`), `.serverOnly()` (never in HTTP responses), `.encrypted()` (AEAD at rest, needs `PYLON_ENCRYPTION_KEY`), `.crdt("text")` (collaborative).
19
+ 2. **Write a policy** — `policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })` with CEL-like expressions over `auth.*` / `data.*` (e.g. `"auth.userId == data.authorId"`). **Omitted actions DENY by default.** Wide-open dev policies (`allow*: "true"`) are flagged by `pylon lint` — tighten before shipping.
20
+ 3. **Author a function** in `functions/<name>.ts` — `query` (read-only), `mutation` (transactional read+write), or `action` (external I/O, no direct `ctx.db`). Import `{ query, mutation, action, v }` from `@pylonsync/functions`. `auth` defaults to `"user"` (secure-by-default); set `"public"` explicitly for unauthenticated access. Use `ctx.db.*`, `ctx.auth.userId`, `ctx.error(code, msg)`.
21
+ 4. **Read it on the client** — `db.useQuery("Thing")` (live, re-renders on any write) or `db.useQueryOne("Thing", id)`. Call functions with `db.fn(name, args)` / `callFn`. On SSR pages, read via `use(serverData.list("Thing"))` inside `<Suspense>`.
22
+
23
+ ## Key gotchas
24
+
25
+ - **Policies deny by default; server functions BYPASS them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full DB access — enforce trust with `ctx.auth` checks inside the handler, not policies.
26
+ - **Type page props from the SDK, don't hand-roll them.** `import type { PageProps, Metadata } from "@pylonsync/react"`. Every page/layout gets `{ url, params, searchParams, auth, response, serverData }`; `PageProps<{ slug: string }>` types a `[slug]` route's params. Request headers/cookies are intentionally NOT on `PageProps` — they're server-only and stripped from hydration, so reading them in the render would mismatch.
27
+ - **Anonymous output caching is opt-in + earned.** `export const revalidate = 60` (seconds) on a page makes it CDN-cacheable (`public, s-maxage=60`) — but ONLY if the render is auth-INDEPENDENT: it must NOT read `props.auth` (reading it at all opts out, even for anonymous), set no cookie, and the app must not run strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. Fail-closed: without the opt-in (or if any condition fails) the page is `no-cache`. A page that reads `auth` or sets a cookie is never shared. The SAME earned render is also kept in an **origin disk cache** (`.pylon/.cache/ssr`): a cookie-less GET with no query string is served straight off disk for the TTL — skipping the render entirely — then re-rendered live when stale. The disk cache is namespaced per deploy (wiped on each new build) and OFF in `pylon dev` (so an edit is never masked by a stale entry); invalidation is by the `revalidate` TTL or the next deploy.
28
+ - **No-JS forms use `route.ts` + `<Form>`.** Drop `app/.../route.ts` exporting `export const POST: RouteHandler = async ({ form, db, response, auth }) => { await db.insert("X", {...}); response.redirect("/x?ok=1"); }` (303 POST-redirect-GET by default). Render `<Form action="/x">` (from @pylonsync/react) with plain `<input name=...>` — works with JS off (native POST→handler→redirect) and is enhanced to no-reload when JS is on. The handler's `db` is read+write (mutation trust model — gate on `auth`); CSRF is automatic (Origin gate + SameSite=Lax). Multipart/file uploads aren't supported yet — use urlencoded forms + `/api/files`.
29
+ - **`loading.tsx` streams a skeleton while the page's data resolves.** Drop `app/.../loading.tsx` (default export, page props) and the nearest one becomes a route-level Suspense fallback: Pylon flushes the shell + skeleton immediately, then reveals the real page when its top-level `use(serverData…)` resolves (no blank page). It only shows when the PAGE suspends — a page that wraps its own `<Suspense>` around a child (like `/dashboard` in this template) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
30
+ - **`export const streaming = true` streams a page's OWN inner `<Suspense>` boundaries.** Without it (and without a `loading.tsx`), a page is BUFFERED — the whole document, including suspended children, resolves before the first byte. Opt in and the shell + each inner `<Suspense>` fallback flush immediately, then each boundary's real content streams in as its data resolves (multi-boundary progressive streaming). It's opt-in because it changes the response timing contract: a streaming render commits its HTTP head BEFORE suspended subtrees finish, so (a) it's never CDN/disk cacheable — don't combine with `export const revalidate`; (b) `response.setStatus/setCookie/redirect/notFound` only take effect from the SYNCHRONOUS shell render — a call from inside a suspended subtree is dropped (the runtime logs a loud warning naming what was lost); (c) a `throw` from a deep `<Suspense>` child resolves via its nearest `error.tsx` at HTTP 200, not a 5xx. Hydration is clean for any number of boundaries (the data blob ships before hydration runs). Type the config with `import type { RouteSegmentConfig } from "@pylonsync/react"`.
31
+ - **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`) — `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
32
+ - **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive — during SSR they return defaults (empty params / "/"); for server-side URL values read the `url` / `searchParams` page props.
33
+ - **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"` — `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
34
+ - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
35
+ - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready — status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
36
+ - **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db` — use `ctx.runQuery` / `ctx.runMutation`.
37
+ - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
38
+ - **There is no `ctx.files` or `defineWorkflow`/`defineJob`.** Files go through `<FileUpload>` + `/api/files/*`; deferred execution is `ctx.scheduler.runAfter/runAt/cancel`.
39
+
40
+ ## Use the CLI — don't guess
41
+
42
+ | Need | Command |
43
+ |---|---|
44
+ | Run the app (SSR + API, hot reload, one port `:4321`) | `pylon dev` (or `npm run dev`) |
45
+ | Regenerate manifest + typed client | `pylon codegen` (Swift client: `pylon codegen client --target swift`) |
46
+ | Validate / diff / push schema | `pylon schema check` \| `diff` \| `push` |
47
+ | Migrations | `pylon migrate create <name>` \| `plan` \| `apply` |
48
+ | Lint policies (PYL001–PYL004) | `pylon lint --strict` |
49
+ | Tests | `pylon test` |
50
+ | Adversarial security probe | `pylon test:security` |
51
+ | Inspect cloud request logs (agent-safe) | `pylon logs --json --limit 50` |
52
+ | Inspect data / entities | `pylon data entities` \| `pylon data list <Entity>` |
53
+ | Call a function | `pylon fn <name> key=value` |
54
+ | Health snapshot | `pylon status` |
55
+ | Build for prod | `pylon build` |
56
+ | Deploy (Pylon Cloud by default) | `pylon deploy` |
57
+ | Look up an error code | `pylon explain <CODE>` |
58
+
59
+ `--json` works on every command for machine-readable output. Prefer one-shot/agent-safe flags (`pylon logs --limit N`, not a blocking `--follow`).
60
+
61
+ For full signatures, env vars, the complete CLI, and SSR/client/server-primitive details: **/llms-full.txt**.
@@ -0,0 +1,67 @@
1
+ # __APP_NAME__
2
+
3
+ A personal-brand / creator site built with [Pylon](https://pylonsync.com) — a
4
+ server-rendered landing page with a **live newsletter subscriber counter** and a
5
+ private owner dashboard, all from one binary on one port. No Next.js, no
6
+ separate API server.
7
+
8
+ The realtime point: the subscriber count ticks up for everyone with the page
9
+ open the instant someone subscribes.
10
+
11
+ ## Develop
12
+
13
+ ```bash
14
+ __RUN_DEV__
15
+ ```
16
+
17
+ Open http://localhost:4321. Then **open a second tab**, subscribe in one, and
18
+ watch the counter increment in the other — with no refresh.
19
+
20
+ ## How the realtime works
21
+
22
+ - `functions/subscribe.ts` — a public **mutation** that validates, lowercases,
23
+ and dedupes the email, inserts one `Subscriber` row, and bumps a public,
24
+ PII-free `SubscriberCount` row.
25
+ - `app/newsletter-signup.tsx` subscribes to `SubscriberCount` with
26
+ `db.useQuery`, so the live count syncs to every open tab. No polling.
27
+
28
+ ## Privacy — read this
29
+
30
+ The `Subscriber` entity holds reader emails (PII), so its policy in `app.ts`
31
+ **denies every client read and write**. The public page only ever reads the
32
+ aggregate `SubscriberCount` (a bare integer); the full list — with emails —
33
+ comes back only through `subscriberStats`, gated to the owner server-side.
34
+
35
+ ## The owner dashboard
36
+
37
+ `/dashboard` shows total subscribers, a growth chart, a searchable list, and CSV
38
+ export — updating live as people subscribe.
39
+
40
+ Set `PYLON_OWNER_EMAIL` in `.env` (see `.env.example`) to the email you'll sign
41
+ in with, then create that account at `/login`.
42
+
43
+ ## Rebrand it
44
+
45
+ Everything lives in **`lib/site.config.ts`** — your name, colors, bio,
46
+ offerings, testimonials, newsletter copy, links. Edit that one file (or have a
47
+ generator produce it) and the whole page re-themes.
48
+
49
+ ## Layout
50
+
51
+ ```
52
+ app.ts Subscriber + SubscriberCount + User + policies
53
+ lib/site.config.ts ALL copy + brand + offerings + newsletter (edit this)
54
+ functions/subscribe.ts public mutation: validate + dedupe + count
55
+ functions/subscriberStats.ts owner-only query: subscribers + emails
56
+ app/page.tsx the landing page (server-rendered)
57
+ app/newsletter-signup.tsx client island: signup form + live counter
58
+ app/dashboard/ owner dashboard (auth-gated, live)
59
+ ```
60
+
61
+ ## Deploy
62
+
63
+ ```bash
64
+ pylon deploy
65
+ ```
66
+
67
+ Docs: https://docs.pylonsync.com
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import {
5
+ passwordLogin,
6
+ passwordRegister,
7
+ persistSession,
8
+ ApiError,
9
+ } from "@pylonsync/client";
10
+
11
+ // The owner's email/password form — one form, two modes. It calls the built-in
12
+ // auth API directly (`passwordLogin` / `passwordRegister` POST to
13
+ // `/api/auth/password/*`), then `persistSession` writes the freshly-minted
14
+ // token to local storage so the sync engine + `callFn` authenticate AS THE
15
+ // OWNER on the next load. This step matters here specifically: the landing page
16
+ // mints an anonymous guest session (for the live counter), and without
17
+ // persisting the real session that stale guest token would shadow the owner's
18
+ // — so the owner-only `subscriberStats` call would come back as a guest and get
19
+ // rejected. We then do a full navigation to /dashboard so the SSR runtime
20
+ // re-resolves auth from the HttpOnly cookie and renders server-side.
21
+ //
22
+ // A newsletter is single-tenant: there's no public subscriber funnel, just the owner
23
+ // creating their one account. Whoever signs in only sees data if their email
24
+ // matches PYLON_OWNER_EMAIL — enforced by the subscriberStats function.
25
+ export function AuthForm() {
26
+ const [mode, setMode] = useState<"login" | "subscriber">("login");
27
+ const [email, setEmail] = useState("");
28
+ const [password, setPassword] = useState("");
29
+ const [error, setError] = useState<string | null>(null);
30
+ const [pending, setPending] = useState(false);
31
+
32
+ async function onSubmit(e: React.FormEvent) {
33
+ e.preventDefault();
34
+ setError(null);
35
+ setPending(true);
36
+ try {
37
+ const session =
38
+ mode === "login"
39
+ ? await passwordLogin({ email, password })
40
+ : await passwordRegister({ email, password });
41
+ // Make this session authoritative, replacing any anonymous guest token.
42
+ persistSession(session);
43
+ window.location.assign("/dashboard");
44
+ } catch (err) {
45
+ setError(messageFor(err));
46
+ setPending(false);
47
+ }
48
+ }
49
+
50
+ return (
51
+ <div className="space-y-5">
52
+ <form onSubmit={onSubmit} className="space-y-4">
53
+ <label className="block">
54
+ <span className="mb-1.5 block text-[13px] font-medium text-zinc-700">Email</span>
55
+ <input
56
+ type="email"
57
+ value={email}
58
+ onChange={(e) => setEmail(e.target.value)}
59
+ required
60
+ autoComplete="email"
61
+ placeholder="you@yourbusiness.com"
62
+ className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
63
+ />
64
+ </label>
65
+ <label className="block">
66
+ <span className="mb-1.5 block text-[13px] font-medium text-zinc-700">Password</span>
67
+ <input
68
+ type="password"
69
+ value={password}
70
+ onChange={(e) => setPassword(e.target.value)}
71
+ required
72
+ autoComplete={mode === "login" ? "current-password" : "new-password"}
73
+ placeholder={mode === "login" ? "Your password" : "At least 10 characters"}
74
+ className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
75
+ />
76
+ </label>
77
+ {error ? (
78
+ <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[13px] leading-snug text-red-700">
79
+ {error}
80
+ </p>
81
+ ) : null}
82
+ <button
83
+ type="submit"
84
+ disabled={pending}
85
+ className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-zinc-900 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
86
+ >
87
+ {pending ? "…" : mode === "login" ? "Sign in" : "Create account"}
88
+ </button>
89
+ </form>
90
+
91
+ <p className="text-center text-[13px] text-zinc-500">
92
+ {mode === "login" ? "First time here?" : "Already have an account?"}{" "}
93
+ <button
94
+ type="button"
95
+ onClick={() => {
96
+ setMode(mode === "login" ? "subscriber" : "login");
97
+ setError(null);
98
+ }}
99
+ className="font-medium text-zinc-900 underline underline-offset-2"
100
+ >
101
+ {mode === "login" ? "Create the owner account" : "Sign in"}
102
+ </button>
103
+ </p>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ // Map the framework's auth error codes to friendly copy. `ApiError` carries a
109
+ // stable `.code` so you branch on the code, not the message.
110
+ function messageFor(err: unknown): string {
111
+ if (err instanceof ApiError) {
112
+ switch (err.code) {
113
+ case "INVALID_CREDENTIALS":
114
+ return "Wrong email or password.";
115
+ case "USER_EXISTS":
116
+ return "That email is already registered — sign in instead.";
117
+ case "WEAK_PASSWORD":
118
+ return "Pick a longer password — at least 10 characters.";
119
+ case "PWNED_PASSWORD":
120
+ return "That password has appeared in a known data breach. Choose a different one.";
121
+ case "RATE_LIMITED":
122
+ return "Too many attempts — try again in a minute.";
123
+ default:
124
+ return err.message;
125
+ }
126
+ }
127
+ if (err instanceof Error) return err.message;
128
+ return "Something went wrong. Try again.";
129
+ }
@@ -0,0 +1,297 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useMemo, useState } from "react";
4
+ import { db, callFn } from "@pylonsync/react";
5
+ import { useAuth } from "@pylonsync/client";
6
+ import type { SubscriberStatsData, SubscriberStatsResult, SubscriberRow } from "@/lib/stats";
7
+
8
+ // The owner's live dashboard. Liveness rides the SAME public aggregate the
9
+ // landing page uses: `db.useQuery("SubscriberCount")` re-renders the instant the
10
+ // count changes (cross-tab, via the replica). The emails themselves never sync
11
+ // — they come from the owner-gated `subscriberStats` function, (re)fetched on
12
+ // mount and whenever the live count ticks. So the total, chart, and list all
13
+ // stay live, but PII only ever travels through the gated call.
14
+ export function SubscriberDashboard({ userEmail }: { userEmail: string }) {
15
+ const { data: statRows } = db.useQuery<{ id: string; count: number }>("SubscriberCount");
16
+ const liveCount = statRows.length > 0 ? statRows[0].count : 0;
17
+
18
+ const [data, setData] = useState<SubscriberStatsData | null>(null);
19
+ const [denied, setDenied] = useState(false);
20
+ const [error, setError] = useState<string | null>(null);
21
+
22
+ useEffect(() => {
23
+ let cancelled = false;
24
+ callFn<SubscriberStatsResult>("subscriberStats", {})
25
+ .then((r) => {
26
+ if (cancelled) return;
27
+ // The owner gate (PYLON_OWNER_EMAIL) lives in the function; a non-owner
28
+ // comes back as `{ authorized: false }` (with no data) → locked card.
29
+ if (!r.authorized) {
30
+ setDenied(true);
31
+ } else {
32
+ setData(r);
33
+ setError(null);
34
+ setDenied(false);
35
+ }
36
+ })
37
+ .catch((e) => {
38
+ if (!cancelled) setError(e instanceof Error ? e.message : String(e));
39
+ });
40
+ return () => {
41
+ cancelled = true;
42
+ };
43
+ // Re-fetch whenever a new subscriber moves the live count.
44
+ }, [liveCount]);
45
+
46
+ if (denied) return <OwnerOnly email={userEmail} />;
47
+ if (error) {
48
+ return (
49
+ <div className="rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">
50
+ {error}
51
+ </div>
52
+ );
53
+ }
54
+ if (!data) return <Skeleton />;
55
+
56
+ return (
57
+ <div className="space-y-8">
58
+ <div>
59
+ <h1 className="text-xl font-semibold tracking-tight">Subscribers</h1>
60
+ <p className="mt-1 text-sm text-zinc-500">
61
+ Live — new subscribers appear here the moment they sign up.
62
+ </p>
63
+ </div>
64
+
65
+ <div className="grid gap-4 sm:grid-cols-3">
66
+ <Stat label="Total subscribers" value={data.total} />
67
+ <Stat label="Last 7 days" value={data.last7} />
68
+ <Stat label="Today" value={data.today} />
69
+ </div>
70
+
71
+ <TrendChart daily={data.daily} />
72
+
73
+ <SubscriberTable subscribers={data.subscribers} />
74
+ </div>
75
+ );
76
+ }
77
+
78
+ function Stat({ label, value }: { label: string; value: number }) {
79
+ return (
80
+ <div className="rounded-xl border border-zinc-200 bg-white p-4">
81
+ <div className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">
82
+ {label}
83
+ </div>
84
+ <div className="mt-1 text-2xl font-semibold tabular-nums text-zinc-900">
85
+ {value.toLocaleString()}
86
+ </div>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ /* ----------------------------- trend chart ---------------------------- */
92
+
93
+ function TrendChart({ daily }: { daily: { date: string; count: number }[] }) {
94
+ const max = Math.max(1, ...daily.map((d) => d.count));
95
+ return (
96
+ <div className="rounded-xl border border-zinc-200 bg-white p-5">
97
+ <div className="flex items-center justify-between">
98
+ <h2 className="text-sm font-semibold text-zinc-900">Last 30 days</h2>
99
+ <span className="text-[12px] text-zinc-400">
100
+ peak {max.toLocaleString()}/day
101
+ </span>
102
+ </div>
103
+ {/* Each column is a FULL-HEIGHT flex cell (h-full) that bottom-aligns its
104
+ bar — so the bar's percentage height resolves against the 7rem track,
105
+ not an auto-height parent (which would collapse it to nothing). */}
106
+ <div className="mt-4 flex h-28 items-end gap-1">
107
+ {daily.map((d) => (
108
+ <div
109
+ key={d.date}
110
+ className="group relative flex h-full flex-1 flex-col justify-end"
111
+ >
112
+ <div
113
+ className="w-full rounded-t bg-brand/80 transition-colors group-hover:bg-brand"
114
+ style={{ height: `${Math.max(2, (d.count / max) * 100)}%` }}
115
+ />
116
+ {/* Tooltip on hover — date + count. */}
117
+ <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 whitespace-nowrap rounded bg-zinc-900 px-2 py-1 text-[11px] text-white group-hover:block">
118
+ {fmtDay(d.date)} · {d.count}
119
+ </div>
120
+ </div>
121
+ ))}
122
+ </div>
123
+ <div className="mt-2 flex justify-between text-[11px] text-zinc-400">
124
+ <span>{fmtDay(daily[0]?.date)}</span>
125
+ <span>{fmtDay(daily[daily.length - 1]?.date)}</span>
126
+ </div>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ /* ----------------------------- subscriber list ---------------------------- */
132
+
133
+ function SubscriberTable({ subscribers }: { subscribers: SubscriberRow[] }) {
134
+ const [q, setQ] = useState("");
135
+ const filtered = useMemo(() => {
136
+ const needle = q.trim().toLowerCase();
137
+ if (!needle) return subscribers;
138
+ return subscribers.filter((s) => s.email.toLowerCase().includes(needle));
139
+ }, [q, subscribers]);
140
+
141
+ return (
142
+ <div className="rounded-xl border border-zinc-200 bg-white">
143
+ <div className="flex flex-col gap-3 border-b border-zinc-100 p-4 sm:flex-row sm:items-center sm:justify-between">
144
+ <h2 className="text-sm font-semibold text-zinc-900">
145
+ Subscribers{" "}
146
+ <span className="font-normal text-zinc-400">
147
+ ({filtered.length.toLocaleString()})
148
+ </span>
149
+ </h2>
150
+ <div className="flex items-center gap-2">
151
+ <input
152
+ value={q}
153
+ onChange={(e) => setQ(e.target.value)}
154
+ placeholder="Search email…"
155
+ aria-label="Search subscribers"
156
+ className="h-9 w-44 rounded-md border border-zinc-300 bg-white px-3 text-sm outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20"
157
+ />
158
+ <button
159
+ type="button"
160
+ onClick={() => exportCsv(subscribers)}
161
+ disabled={subscribers.length === 0}
162
+ className="inline-flex h-9 items-center rounded-md border border-zinc-300 px-3 text-[13px] font-medium text-zinc-700 transition-colors hover:bg-zinc-50 disabled:opacity-40"
163
+ >
164
+ Export CSV
165
+ </button>
166
+ </div>
167
+ </div>
168
+
169
+ {filtered.length === 0 ? (
170
+ <p className="p-8 text-center text-sm text-zinc-500">
171
+ {subscribers.length === 0
172
+ ? "No subscribers yet. Share your landing page and watch them roll in — live."
173
+ : "No emails match your search."}
174
+ </p>
175
+ ) : (
176
+ <ul className="divide-y divide-zinc-100">
177
+ {filtered.map((s) => (
178
+ <li key={s.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
179
+ <span className="truncate text-sm text-zinc-800">{s.email}</span>
180
+ <span className="shrink-0 text-[12px] text-zinc-400">
181
+ {fmtDateTime(s.createdAt)}
182
+ </span>
183
+ </li>
184
+ ))}
185
+ </ul>
186
+ )}
187
+ </div>
188
+ );
189
+ }
190
+
191
+ /* --------------------------- owner-only gate -------------------------- */
192
+
193
+ // Shown to a signed-in user the `subscriberStats` function refused (not the
194
+ // configured owner, or no PYLON_OWNER_EMAIL set). Fails closed — no subscriber data
195
+ // is fetched or shown.
196
+ function OwnerOnly({ email }: { email: string }) {
197
+ return (
198
+ <div className="rounded-xl border border-dashed border-zinc-300 px-6 py-12 text-center">
199
+ <h1 className="text-lg font-semibold">This dashboard is owner-only</h1>
200
+ <p className="mx-auto mt-2 max-w-md text-sm text-zinc-500">
201
+ You&apos;re signed in as{" "}
202
+ <span className="font-medium text-zinc-700">{email || "this account"}</span>.
203
+ A newsletter is single-tenant — only the owner can see the subscribers. Set{" "}
204
+ <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">
205
+ PYLON_OWNER_EMAIL={email || "you@yourbusiness.com"}
206
+ </code>{" "}
207
+ in your{" "}
208
+ <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">.env</code>,
209
+ restart, and reload — or sign in with the owner account.
210
+ </p>
211
+ </div>
212
+ );
213
+ }
214
+
215
+ /* ----------------------------- user menu ------------------------------ */
216
+
217
+ export function UserMenu({ email }: { email: string }) {
218
+ const { signOut } = useAuth();
219
+ const initial = (email.trim()[0] || "?").toUpperCase();
220
+ async function onSignOut() {
221
+ await signOut();
222
+ window.location.assign("/");
223
+ }
224
+ return (
225
+ <details className="group relative">
226
+ <summary className="flex size-8 cursor-pointer select-none list-none items-center justify-center rounded-full bg-zinc-900 text-[12px] font-semibold text-white marker:hidden [&::-webkit-details-marker]:hidden">
227
+ {initial}
228
+ </summary>
229
+ <div className="absolute right-0 top-full z-40 mt-2 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.25)]">
230
+ <div className="border-b border-zinc-100 px-3 py-2">
231
+ <div className="truncate text-[13px] font-medium text-zinc-900">
232
+ {email || "Signed in"}
233
+ </div>
234
+ </div>
235
+ <button
236
+ type="button"
237
+ onClick={onSignOut}
238
+ className="flex w-full items-center px-3 py-2 text-left text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
239
+ >
240
+ Sign out
241
+ </button>
242
+ </div>
243
+ </details>
244
+ );
245
+ }
246
+
247
+ /* ------------------------------ helpers ------------------------------- */
248
+
249
+ function Skeleton() {
250
+ return (
251
+ <div className="space-y-8">
252
+ <div className="h-6 w-32 animate-pulse rounded bg-zinc-100" />
253
+ <div className="grid gap-4 sm:grid-cols-3">
254
+ {[0, 1, 2].map((i) => (
255
+ <div key={i} className="h-20 animate-pulse rounded-xl bg-zinc-100" />
256
+ ))}
257
+ </div>
258
+ <div className="h-44 animate-pulse rounded-xl bg-zinc-100" />
259
+ <div className="h-64 animate-pulse rounded-xl bg-zinc-100" />
260
+ </div>
261
+ );
262
+ }
263
+
264
+ function exportCsv(subscribers: SubscriberRow[]) {
265
+ const cell = (v: string) =>
266
+ /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
267
+ const rows = [
268
+ "email,joined_at",
269
+ ...subscribers.map((s) => `${cell(s.email)},${cell(s.createdAt)}`),
270
+ ];
271
+ const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8" });
272
+ const url = URL.createObjectURL(blob);
273
+ const a = document.createElement("a");
274
+ a.href = url;
275
+ a.download = `newsletter-${new Date().toISOString().slice(0, 10)}.csv`;
276
+ document.body.appendChild(a);
277
+ a.click();
278
+ a.remove();
279
+ URL.revokeObjectURL(url);
280
+ }
281
+
282
+ function fmtDay(iso?: string) {
283
+ if (!iso) return "";
284
+ const d = new Date(iso + "T00:00:00Z");
285
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
286
+ }
287
+
288
+ function fmtDateTime(iso: string) {
289
+ const t = Date.parse(iso);
290
+ if (Number.isNaN(t)) return "";
291
+ return new Date(t).toLocaleString(undefined, {
292
+ month: "short",
293
+ day: "numeric",
294
+ hour: "numeric",
295
+ minute: "2-digit",
296
+ });
297
+ }
@@ -0,0 +1,70 @@
1
+ import React, { use } from "react";
2
+ import { Link, type Metadata, type PageProps } from "@pylonsync/react";
3
+ import { siteConfig } from "@/lib/site.config";
4
+ import { UserMenu, SubscriberDashboard } from "./dashboard-client";
5
+
6
+ export const metadata: Metadata = {
7
+ title: `Dashboard — ${siteConfig.brand.name}`,
8
+ robots: "noindex",
9
+ };
10
+
11
+ // `app/dashboard/page.tsx` → `/dashboard`. Server-side auth gate only — any
12
+ // signed-in user can load the shell. The OWNER gate (a newsletter is
13
+ // single-tenant: only PYLON_OWNER_EMAIL may see the subscribers) lives in the
14
+ // `subscriberStats` function, which checks `ctx.env.PYLON_OWNER_EMAIL` — the
15
+ // authoritative server env. Non-owners get a clean "owner-only" card from the
16
+ // client when that call is denied. (Env-based config is read in the function,
17
+ // not here: an SSR page render can't reliably read arbitrary `process.env`.)
18
+ export default function DashboardPage({ auth, response, serverData }: PageProps) {
19
+ // Anonymous visitors and guest sessions (guest_… ids) get bounced to login —
20
+ // the dashboard is for the real, signed-in owner only.
21
+ if (!auth.user_id || auth.user_id.startsWith("guest_")) {
22
+ response.redirect("/login");
23
+ return null;
24
+ }
25
+
26
+ // The User self-read policy lets the signed-in user read their own row → the
27
+ // email shown in the account menu (and on the owner-only card).
28
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
29
+ const email = me?.email ?? "";
30
+
31
+ return (
32
+ <Shell email={email}>
33
+ <SubscriberDashboard userEmail={email} />
34
+ </Shell>
35
+ );
36
+ }
37
+
38
+ // Dashboard chrome: a slim top bar with the logo, a link back to the public
39
+ // site, and the account menu (a client island for sign-out).
40
+ function Shell({ email, children }: { email: string; children: React.ReactNode }) {
41
+ const { brand } = siteConfig;
42
+ return (
43
+ <div className="flex min-h-screen flex-col bg-white text-zinc-900">
44
+ <header className="border-b border-zinc-200">
45
+ <div className="mx-auto flex h-14 max-w-4xl items-center justify-between px-6">
46
+ <div className="flex items-center gap-2">
47
+ <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
48
+ {brand.letter}
49
+ </span>
50
+ <span className="text-[15px] font-semibold tracking-tight">
51
+ {brand.name} <span className="text-zinc-400">/ newsletter</span>
52
+ </span>
53
+ </div>
54
+ <div className="flex items-center gap-4">
55
+ <Link
56
+ href="/"
57
+ className="text-[13px] text-zinc-500 transition-colors hover:text-zinc-900"
58
+ >
59
+ View site ↗
60
+ </Link>
61
+ <UserMenu email={email} />
62
+ </div>
63
+ </div>
64
+ </header>
65
+ <main className="flex-1">
66
+ <div className="mx-auto max-w-4xl px-6 py-8">{children}</div>
67
+ </main>
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { type ErrorBoundaryProps } from "@pylonsync/react";
3
+
4
+ // `app/error.tsx` → the error boundary for this segment. Hydrated + interactive:
5
+ // `reset()` re-attempts the route. The thrown error reaches the client as
6
+ // `{ message, digest }` only — the stack stays in the dev overlay / server logs.
7
+ export default function Error({ error, reset }: ErrorBoundaryProps) {
8
+ return (
9
+ <div className="mx-auto flex min-h-[60vh] max-w-3xl flex-col items-center justify-center px-6 text-center">
10
+ <h1 className="text-2xl font-semibold tracking-tight">Something went wrong</h1>
11
+ <p className="mt-2 text-zinc-500">{error.message}</p>
12
+ {error.digest ? (
13
+ <p className="mt-1 text-xs text-zinc-400">
14
+ Reference: <code>{error.digest}</code>
15
+ </p>
16
+ ) : null}
17
+ <button
18
+ type="button"
19
+ onClick={reset}
20
+ className="mt-6 inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 text-sm font-medium text-white transition-colors hover:bg-zinc-700"
21
+ >
22
+ Try again
23
+ </button>
24
+ </div>
25
+ );
26
+ }