@pylonsync/create-pylon 0.3.344 → 0.3.346

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 (295) hide show
  1. package/bin/create-pylon.js +35 -0
  2. package/package.json +1 -1
  3. package/templates/crm/AGENTS.md +116 -0
  4. package/templates/crm/CLAUDE.md +7 -0
  5. package/templates/crm/README.md +96 -0
  6. package/templates/crm/app/companies/companies-view.tsx +129 -0
  7. package/templates/crm/app/companies/page.tsx +17 -0
  8. package/templates/crm/app/contacts/contacts-view.tsx +139 -0
  9. package/templates/crm/app/contacts/page.tsx +17 -0
  10. package/templates/crm/app/deals/[id]/deal-view.tsx +189 -0
  11. package/templates/crm/app/deals/[id]/page.tsx +23 -0
  12. package/templates/crm/app/error.tsx +43 -0
  13. package/templates/crm/app/globals.css +201 -0
  14. package/templates/crm/app/layout.tsx +31 -0
  15. package/templates/crm/app/login/auth-form.tsx +122 -0
  16. package/templates/crm/app/login/page.tsx +38 -0
  17. package/templates/crm/app/not-found.tsx +29 -0
  18. package/templates/crm/app/page.tsx +38 -0
  19. package/templates/crm/app/pipeline-view.tsx +99 -0
  20. package/templates/crm/app/robots.ts +12 -0
  21. package/templates/crm/app/sitemap.ts +27 -0
  22. package/templates/crm/app/workspace.tsx +231 -0
  23. package/templates/crm/app.ts +171 -0
  24. package/templates/crm/bunfig +4 -0
  25. package/templates/crm/components/activity-timeline.tsx +133 -0
  26. package/templates/crm/components/avatar.tsx +40 -0
  27. package/templates/crm/components/board-skeleton.tsx +44 -0
  28. package/templates/crm/components/command-palette.tsx +162 -0
  29. package/templates/crm/components/data-table.tsx +90 -0
  30. package/templates/crm/components/deal-card.tsx +91 -0
  31. package/templates/crm/components/deal-dialog.tsx +169 -0
  32. package/templates/crm/components/empty-state.tsx +43 -0
  33. package/templates/crm/components/kbd.tsx +23 -0
  34. package/templates/crm/components/metrics-bar.tsx +46 -0
  35. package/templates/crm/components/page-header.tsx +36 -0
  36. package/templates/crm/components/pipeline-board.tsx +105 -0
  37. package/templates/crm/components/record-dialog.tsx +126 -0
  38. package/templates/crm/components/sidebar.tsx +112 -0
  39. package/templates/crm/components/stage-badge.tsx +33 -0
  40. package/templates/crm/components/ui/badge.tsx +37 -0
  41. package/templates/crm/components/ui/button.tsx +56 -0
  42. package/templates/crm/components/ui/card.tsx +90 -0
  43. package/templates/crm/components/ui/dialog.tsx +110 -0
  44. package/templates/crm/components/ui/input.tsx +30 -0
  45. package/templates/crm/components/ui/label.tsx +21 -0
  46. package/templates/crm/components/ui/select.tsx +39 -0
  47. package/templates/crm/components/ui/table.tsx +124 -0
  48. package/templates/crm/components/ui/textarea.tsx +24 -0
  49. package/templates/crm/components.json +20 -0
  50. package/templates/crm/functions/createDeal.ts +59 -0
  51. package/templates/crm/functions/logActivity.ts +49 -0
  52. package/templates/crm/functions/moveDeal.ts +53 -0
  53. package/templates/crm/functions/seedWorkspace.ts +63 -0
  54. package/templates/crm/gitignore +10 -0
  55. package/templates/crm/lib/pipeline.ts +221 -0
  56. package/templates/crm/lib/search.ts +78 -0
  57. package/templates/crm/lib/seed.ts +130 -0
  58. package/templates/crm/lib/utils.ts +10 -0
  59. package/templates/crm/package.json +39 -0
  60. package/templates/crm/tests/components.test.tsx +245 -0
  61. package/templates/crm/tests/pipeline.test.ts +219 -0
  62. package/templates/crm/tests/search.test.ts +83 -0
  63. package/templates/crm/tests/setup.ts +5 -0
  64. package/templates/crm/tsconfig.json +33 -0
  65. package/templates/helpdesk/AGENTS.md +116 -0
  66. package/templates/helpdesk/CLAUDE.md +7 -0
  67. package/templates/helpdesk/README.md +90 -0
  68. package/templates/helpdesk/app/customers/customers-view.tsx +140 -0
  69. package/templates/helpdesk/app/customers/page.tsx +17 -0
  70. package/templates/helpdesk/app/error.tsx +43 -0
  71. package/templates/helpdesk/app/globals.css +201 -0
  72. package/templates/helpdesk/app/inbox-view.tsx +147 -0
  73. package/templates/helpdesk/app/layout.tsx +31 -0
  74. package/templates/helpdesk/app/login/auth-form.tsx +122 -0
  75. package/templates/helpdesk/app/login/page.tsx +38 -0
  76. package/templates/helpdesk/app/not-found.tsx +29 -0
  77. package/templates/helpdesk/app/page.tsx +38 -0
  78. package/templates/helpdesk/app/robots.ts +12 -0
  79. package/templates/helpdesk/app/sitemap.ts +27 -0
  80. package/templates/helpdesk/app/tickets/[id]/page.tsx +23 -0
  81. package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +164 -0
  82. package/templates/helpdesk/app/workspace.tsx +210 -0
  83. package/templates/helpdesk/app.ts +142 -0
  84. package/templates/helpdesk/bunfig +4 -0
  85. package/templates/helpdesk/components/avatar.tsx +40 -0
  86. package/templates/helpdesk/components/command-palette.tsx +162 -0
  87. package/templates/helpdesk/components/data-table.tsx +90 -0
  88. package/templates/helpdesk/components/empty-state.tsx +43 -0
  89. package/templates/helpdesk/components/kbd.tsx +23 -0
  90. package/templates/helpdesk/components/new-ticket-dialog.tsx +147 -0
  91. package/templates/helpdesk/components/page-header.tsx +36 -0
  92. package/templates/helpdesk/components/priority-badge.tsx +61 -0
  93. package/templates/helpdesk/components/record-dialog.tsx +126 -0
  94. package/templates/helpdesk/components/sidebar.tsx +111 -0
  95. package/templates/helpdesk/components/sla-indicator.tsx +47 -0
  96. package/templates/helpdesk/components/thread.tsx +143 -0
  97. package/templates/helpdesk/components/ticket-list.tsx +78 -0
  98. package/templates/helpdesk/components/ui/badge.tsx +37 -0
  99. package/templates/helpdesk/components/ui/button.tsx +56 -0
  100. package/templates/helpdesk/components/ui/card.tsx +90 -0
  101. package/templates/helpdesk/components/ui/dialog.tsx +110 -0
  102. package/templates/helpdesk/components/ui/input.tsx +30 -0
  103. package/templates/helpdesk/components/ui/label.tsx +21 -0
  104. package/templates/helpdesk/components/ui/select.tsx +39 -0
  105. package/templates/helpdesk/components/ui/table.tsx +124 -0
  106. package/templates/helpdesk/components/ui/textarea.tsx +24 -0
  107. package/templates/helpdesk/components.json +20 -0
  108. package/templates/helpdesk/functions/createTicket.ts +62 -0
  109. package/templates/helpdesk/functions/replyToTicket.ts +55 -0
  110. package/templates/helpdesk/functions/seedWorkspace.ts +52 -0
  111. package/templates/helpdesk/functions/setTicketState.ts +58 -0
  112. package/templates/helpdesk/gitignore +10 -0
  113. package/templates/helpdesk/lib/format.ts +53 -0
  114. package/templates/helpdesk/lib/search.ts +78 -0
  115. package/templates/helpdesk/lib/seed.ts +185 -0
  116. package/templates/helpdesk/lib/tickets.ts +184 -0
  117. package/templates/helpdesk/lib/utils.ts +10 -0
  118. package/templates/helpdesk/package.json +39 -0
  119. package/templates/helpdesk/tests/components.test.tsx +202 -0
  120. package/templates/helpdesk/tests/search.test.ts +83 -0
  121. package/templates/helpdesk/tests/setup.ts +5 -0
  122. package/templates/helpdesk/tests/tickets.test.ts +215 -0
  123. package/templates/helpdesk/tsconfig.json +33 -0
  124. package/templates/inventory/AGENTS.md +116 -0
  125. package/templates/inventory/CLAUDE.md +7 -0
  126. package/templates/inventory/README.md +103 -0
  127. package/templates/inventory/app/error.tsx +43 -0
  128. package/templates/inventory/app/globals.css +201 -0
  129. package/templates/inventory/app/layout.tsx +31 -0
  130. package/templates/inventory/app/login/auth-form.tsx +122 -0
  131. package/templates/inventory/app/login/page.tsx +38 -0
  132. package/templates/inventory/app/movements/movements-view.tsx +103 -0
  133. package/templates/inventory/app/movements/page.tsx +17 -0
  134. package/templates/inventory/app/not-found.tsx +29 -0
  135. package/templates/inventory/app/page.tsx +34 -0
  136. package/templates/inventory/app/products/[id]/page.tsx +23 -0
  137. package/templates/inventory/app/products/[id]/product-view.tsx +178 -0
  138. package/templates/inventory/app/products-view.tsx +213 -0
  139. package/templates/inventory/app/robots.ts +12 -0
  140. package/templates/inventory/app/sitemap.ts +27 -0
  141. package/templates/inventory/app/workspace.tsx +172 -0
  142. package/templates/inventory/app.ts +154 -0
  143. package/templates/inventory/bunfig +4 -0
  144. package/templates/inventory/components/avatar.tsx +40 -0
  145. package/templates/inventory/components/command-palette.tsx +162 -0
  146. package/templates/inventory/components/data-table.tsx +90 -0
  147. package/templates/inventory/components/empty-state.tsx +43 -0
  148. package/templates/inventory/components/kbd.tsx +23 -0
  149. package/templates/inventory/components/movement-dialog.tsx +182 -0
  150. package/templates/inventory/components/page-header.tsx +36 -0
  151. package/templates/inventory/components/record-dialog.tsx +126 -0
  152. package/templates/inventory/components/sidebar.tsx +111 -0
  153. package/templates/inventory/components/stock-level.tsx +47 -0
  154. package/templates/inventory/components/summary-bar.tsx +56 -0
  155. package/templates/inventory/components/ui/badge.tsx +37 -0
  156. package/templates/inventory/components/ui/button.tsx +56 -0
  157. package/templates/inventory/components/ui/card.tsx +90 -0
  158. package/templates/inventory/components/ui/dialog.tsx +110 -0
  159. package/templates/inventory/components/ui/input.tsx +30 -0
  160. package/templates/inventory/components/ui/label.tsx +21 -0
  161. package/templates/inventory/components/ui/select.tsx +39 -0
  162. package/templates/inventory/components/ui/table.tsx +124 -0
  163. package/templates/inventory/components/ui/textarea.tsx +24 -0
  164. package/templates/inventory/components.json +20 -0
  165. package/templates/inventory/functions/recordMovement.ts +72 -0
  166. package/templates/inventory/functions/seedWorkspace.ts +40 -0
  167. package/templates/inventory/gitignore +10 -0
  168. package/templates/inventory/lib/format.ts +53 -0
  169. package/templates/inventory/lib/search.ts +78 -0
  170. package/templates/inventory/lib/seed.ts +102 -0
  171. package/templates/inventory/lib/stock.ts +207 -0
  172. package/templates/inventory/lib/utils.ts +10 -0
  173. package/templates/inventory/package.json +39 -0
  174. package/templates/inventory/tests/components.test.tsx +155 -0
  175. package/templates/inventory/tests/search.test.ts +83 -0
  176. package/templates/inventory/tests/setup.ts +5 -0
  177. package/templates/inventory/tests/stock.test.ts +255 -0
  178. package/templates/inventory/tsconfig.json +33 -0
  179. package/templates/invoices/AGENTS.md +116 -0
  180. package/templates/invoices/CLAUDE.md +7 -0
  181. package/templates/invoices/README.md +99 -0
  182. package/templates/invoices/app/clients/clients-view.tsx +130 -0
  183. package/templates/invoices/app/clients/page.tsx +17 -0
  184. package/templates/invoices/app/error.tsx +43 -0
  185. package/templates/invoices/app/globals.css +201 -0
  186. package/templates/invoices/app/invoices/[id]/invoice-view.tsx +253 -0
  187. package/templates/invoices/app/invoices/[id]/page.tsx +23 -0
  188. package/templates/invoices/app/invoices-view.tsx +183 -0
  189. package/templates/invoices/app/layout.tsx +31 -0
  190. package/templates/invoices/app/login/auth-form.tsx +122 -0
  191. package/templates/invoices/app/login/page.tsx +38 -0
  192. package/templates/invoices/app/not-found.tsx +29 -0
  193. package/templates/invoices/app/page.tsx +36 -0
  194. package/templates/invoices/app/robots.ts +12 -0
  195. package/templates/invoices/app/sitemap.ts +27 -0
  196. package/templates/invoices/app/workspace.tsx +174 -0
  197. package/templates/invoices/app.ts +169 -0
  198. package/templates/invoices/bunfig +4 -0
  199. package/templates/invoices/components/avatar.tsx +40 -0
  200. package/templates/invoices/components/command-palette.tsx +162 -0
  201. package/templates/invoices/components/data-table.tsx +90 -0
  202. package/templates/invoices/components/empty-state.tsx +43 -0
  203. package/templates/invoices/components/kbd.tsx +23 -0
  204. package/templates/invoices/components/line-items.tsx +172 -0
  205. package/templates/invoices/components/page-header.tsx +36 -0
  206. package/templates/invoices/components/payment-dialog.tsx +132 -0
  207. package/templates/invoices/components/record-dialog.tsx +126 -0
  208. package/templates/invoices/components/sidebar.tsx +111 -0
  209. package/templates/invoices/components/status-badge.tsx +35 -0
  210. package/templates/invoices/components/summary-bar.tsx +65 -0
  211. package/templates/invoices/components/totals-panel.tsx +70 -0
  212. package/templates/invoices/components/ui/badge.tsx +37 -0
  213. package/templates/invoices/components/ui/button.tsx +56 -0
  214. package/templates/invoices/components/ui/card.tsx +90 -0
  215. package/templates/invoices/components/ui/dialog.tsx +110 -0
  216. package/templates/invoices/components/ui/input.tsx +30 -0
  217. package/templates/invoices/components/ui/label.tsx +21 -0
  218. package/templates/invoices/components/ui/select.tsx +39 -0
  219. package/templates/invoices/components/ui/table.tsx +124 -0
  220. package/templates/invoices/components/ui/textarea.tsx +24 -0
  221. package/templates/invoices/components.json +20 -0
  222. package/templates/invoices/functions/createInvoice.ts +49 -0
  223. package/templates/invoices/functions/recordPayment.ts +72 -0
  224. package/templates/invoices/functions/seedWorkspace.ts +57 -0
  225. package/templates/invoices/functions/setInvoiceStatus.ts +35 -0
  226. package/templates/invoices/gitignore +10 -0
  227. package/templates/invoices/lib/billing.ts +259 -0
  228. package/templates/invoices/lib/format.ts +53 -0
  229. package/templates/invoices/lib/search.ts +78 -0
  230. package/templates/invoices/lib/seed.ts +190 -0
  231. package/templates/invoices/lib/utils.ts +10 -0
  232. package/templates/invoices/package.json +39 -0
  233. package/templates/invoices/tests/billing.test.ts +283 -0
  234. package/templates/invoices/tests/components.test.tsx +172 -0
  235. package/templates/invoices/tests/search.test.ts +83 -0
  236. package/templates/invoices/tests/setup.ts +5 -0
  237. package/templates/invoices/tsconfig.json +33 -0
  238. package/templates/projects/AGENTS.md +116 -0
  239. package/templates/projects/CLAUDE.md +7 -0
  240. package/templates/projects/README.md +96 -0
  241. package/templates/projects/app/clients/clients-view.tsx +126 -0
  242. package/templates/projects/app/clients/page.tsx +17 -0
  243. package/templates/projects/app/error.tsx +43 -0
  244. package/templates/projects/app/globals.css +201 -0
  245. package/templates/projects/app/layout.tsx +31 -0
  246. package/templates/projects/app/login/auth-form.tsx +122 -0
  247. package/templates/projects/app/login/page.tsx +38 -0
  248. package/templates/projects/app/not-found.tsx +29 -0
  249. package/templates/projects/app/page.tsx +30 -0
  250. package/templates/projects/app/projects/[id]/page.tsx +23 -0
  251. package/templates/projects/app/projects/[id]/project-view.tsx +179 -0
  252. package/templates/projects/app/projects-view.tsx +181 -0
  253. package/templates/projects/app/robots.ts +12 -0
  254. package/templates/projects/app/sitemap.ts +27 -0
  255. package/templates/projects/app/workspace.tsx +190 -0
  256. package/templates/projects/app.ts +186 -0
  257. package/templates/projects/bunfig +4 -0
  258. package/templates/projects/components/avatar.tsx +40 -0
  259. package/templates/projects/components/board-skeleton.tsx +37 -0
  260. package/templates/projects/components/budget-bar.tsx +61 -0
  261. package/templates/projects/components/command-palette.tsx +162 -0
  262. package/templates/projects/components/data-table.tsx +90 -0
  263. package/templates/projects/components/empty-state.tsx +43 -0
  264. package/templates/projects/components/kbd.tsx +23 -0
  265. package/templates/projects/components/page-header.tsx +36 -0
  266. package/templates/projects/components/record-dialog.tsx +126 -0
  267. package/templates/projects/components/sidebar.tsx +111 -0
  268. package/templates/projects/components/task-board.tsx +109 -0
  269. package/templates/projects/components/task-card.tsx +77 -0
  270. package/templates/projects/components/time-dialog.tsx +114 -0
  271. package/templates/projects/components/ui/badge.tsx +37 -0
  272. package/templates/projects/components/ui/button.tsx +56 -0
  273. package/templates/projects/components/ui/card.tsx +90 -0
  274. package/templates/projects/components/ui/dialog.tsx +110 -0
  275. package/templates/projects/components/ui/input.tsx +30 -0
  276. package/templates/projects/components/ui/label.tsx +21 -0
  277. package/templates/projects/components/ui/select.tsx +39 -0
  278. package/templates/projects/components/ui/table.tsx +124 -0
  279. package/templates/projects/components/ui/textarea.tsx +24 -0
  280. package/templates/projects/components.json +20 -0
  281. package/templates/projects/functions/logTime.ts +49 -0
  282. package/templates/projects/functions/moveTask.ts +40 -0
  283. package/templates/projects/functions/seedWorkspace.ts +59 -0
  284. package/templates/projects/gitignore +10 -0
  285. package/templates/projects/lib/format.ts +53 -0
  286. package/templates/projects/lib/search.ts +78 -0
  287. package/templates/projects/lib/seed.ts +140 -0
  288. package/templates/projects/lib/utils.ts +10 -0
  289. package/templates/projects/lib/work.ts +233 -0
  290. package/templates/projects/package.json +39 -0
  291. package/templates/projects/tests/components.test.tsx +200 -0
  292. package/templates/projects/tests/search.test.ts +83 -0
  293. package/templates/projects/tests/setup.ts +5 -0
  294. package/templates/projects/tests/work.test.ts +238 -0
  295. package/templates/projects/tsconfig.json +33 -0
@@ -164,6 +164,36 @@ const TEMPLATE_REGISTRY = {
164
164
  platforms: [],
165
165
  unified: true,
166
166
  },
167
+ crm: {
168
+ blurb:
169
+ "Sales CRM — companies, contacts, and deals on a LIVE pipeline board: drag a deal and it moves on every teammate's board instantly. ⌘K search, activity timeline, weighted forecast, dense keyboard-first UI. Seeds a demo pipeline on first sign-in. One SSR app.",
170
+ platforms: [],
171
+ unified: true,
172
+ },
173
+ helpdesk: {
174
+ blurb:
175
+ "Support inbox — tickets triaged by priority and SLA, a LIVE queue that reorders as breaches approach, shared threads with internal notes, and assignment that lands on every agent's screen at once. ⌘K search. Seeds a demo queue on first sign-in. One SSR app.",
176
+ platforms: [],
177
+ unified: true,
178
+ },
179
+ invoices: {
180
+ blurb:
181
+ "Invoicing — clients, line items, and payments, with totals and ageing DERIVED rather than stored (integer cents throughout). Recording a payment flips the balance and status on every open tab. ⌘K search. Seeds a demo book, including an overdue and a part-paid invoice. One SSR app.",
182
+ platforms: [],
183
+ unified: true,
184
+ },
185
+ inventory: {
186
+ blurb:
187
+ "Stock control — products plus an APPEND-ONLY movement ledger; on-hand is the sum of the ledger, never a mutable counter, so concurrent receipts can't lose units and every level is explainable. Reorder flags, valuation at cost, live across tabs. Seeds a demo shelf. One SSR app.",
188
+ platforms: [],
189
+ unified: true,
190
+ },
191
+ projects: {
192
+ blurb:
193
+ "Client project delivery — a LIVE task board (drag and it moves on every teammate's board), plus time logged as an APPEND-ONLY ledger so concurrent entries can't be lost and every billed hour traces to who logged it. Budget tracking, billable rollups. Seeds two demo projects. One SSR app.",
194
+ platforms: [],
195
+ unified: true,
196
+ },
167
197
  "ai-chat": {
168
198
  blurb:
169
199
  "Streaming AI chat — token streaming via the built-in /api/ai/stream (your key stays server-side), multi-conversation history that's owner-scoped + synced across tabs in realtime, guest or signed-in. Set PYLON_AI_API_KEY to enable. One SSR app.",
@@ -248,6 +278,11 @@ Examples:
248
278
  npm create @pylonsync/pylon my-app --template agency # studio/agency + live project availability
249
279
  npm create @pylonsync/pylon my-app --template marketplace # two-sided marketplace + realtime offers
250
280
  npm create @pylonsync/pylon my-app --template directory # searchable directory + facets + live upvotes
281
+ npm create @pylonsync/pylon my-app --template crm # sales CRM — live pipeline board, ⌘K, activity timeline
282
+ npm create @pylonsync/pylon my-app --template helpdesk # support inbox — SLA triage, shared threads, internal notes
283
+ npm create @pylonsync/pylon my-app --template invoices # invoicing — line items, payments, derived ageing
284
+ npm create @pylonsync/pylon my-app --template inventory # stock control — append-only ledger, reorder flags
285
+ npm create @pylonsync/pylon my-app --template projects # client delivery — task board, time tracking, budgets
251
286
  npm create @pylonsync/pylon my-app --template ai-chat # streaming LLM chat + synced history
252
287
  npm create @pylonsync/pylon my-app --template ai-studio # generative media studio + live gallery
253
288
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.3.344",
3
+ "version": "0.3.346",
4
4
  "description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,116 @@
1
+ # AGENTS.md: working in a Pylon project
2
+
3
+ Pylon serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR from one Rust process on one port. Treat this app as production infrastructure: it supports real auth, SQLite or Postgres, row-level policies, jobs, search, and one-command deploys. Declare entities, policies, and server functions in TypeScript; the binary handles the runtime. Read the full API reference at https://docs.pylonsync.com/llms-full.txt 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. `pylon lint` flags wide-open development policies such as `allow*: "true"`; tighten them 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 and conditional.** `export const revalidate = 60` makes a page CDN-cacheable (`public, s-maxage=60`) only when the render is auth-independent: it must not read `props.auth`, set a cookie, or run with strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. When any condition fails, the page is `no-cache`. Eligible renders also use the origin disk cache at `.pylon/.cache/ssr`: a cookie-less GET without a query string is served from disk for the TTL and rerendered when stale. The cache is namespaced per deploy, cleared by each build, disabled in `pylon dev`, and invalidated by the `revalidate` TTL or the next deploy.
28
+ - **No-JS forms use `route.ts` and `<Form>`.** Add `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=...>`. It uses native POST → handler → redirect without JavaScript and no-reload enhancement with JavaScript. The handler's `db` is read+write under the mutation trust model, so gate it on `auth`. CSRF protection is automatic through the Origin gate and SameSite=Lax. Multipart uploads are not supported yet; use URL-encoded forms and `/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 inner `<Suspense>` boundaries.** Without it or a `loading.tsx`, the page is buffered until all suspended children resolve. With it, the shell and fallbacks flush immediately, then each boundary streams its content. Streaming commits the HTTP head before suspended subtrees finish, so the page is never CDN- or disk-cacheable; do not combine it with `export const revalidate`. Calls to `response.setStatus`, `setCookie`, `redirect`, or `notFound` only take effect during the synchronous shell render. A call from a suspended subtree is dropped and logged. An error from a deep `<Suspense>` child resolves through the nearest `error.tsx` at HTTP 200 rather than 5xx. 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
+ - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
39
+
40
+ ## Testing
41
+
42
+ `pylon test` discovers every `*.test.ts` / `*.test.tsx` file under `tests/` (or `functions/`) and runs it with **Bun's test runner** (`import { test, expect } from "bun:test"`). Run the suite with `pylon test` (or `npm test`); filter with `pylon test <substring>`. This template ships `bunfig.toml` + `tests/setup.ts` (registers happy-dom) so component tests render out of the box, plus starter tests under `tests/`; replace them with your own.
43
+
44
+ **Tier 1: pure logic (start here).** Keep access and plan gating, pricing, credit math, validation, and formatting in pure functions under `lib/`, and test them exhaustively. These tests need no server and run instantly. Keep `query`, `mutation`, and `action` handlers as thin wrappers so their decision logic remains testable without a running app.
45
+
46
+ ```ts
47
+ import { expect, test } from "bun:test";
48
+ import { productBySlug } from "../lib/site.config";
49
+
50
+ test("unknown slug → undefined", () => {
51
+ expect(productBySlug("nope")).toBeUndefined();
52
+ });
53
+ ```
54
+
55
+ **Tier 2: React components.** `@testing-library/react` and happy-dom are already wired through `tests/setup.ts`. Render and assert. The template uses the classic JSX transform, so add `import React from "react"` in `.tsx` tests. For a component that reads Pylon data hooks, mock the boundary, then dynamically import the component so the mock is in place first:
56
+
57
+ ```tsx
58
+ import { test, expect, mock } from "bun:test";
59
+ import React from "react";
60
+ import { render, screen } from "@testing-library/react";
61
+
62
+ mock.module("@pylonsync/react", () => ({
63
+ db: { useQuery: () => ({ data: [{ id: "1", name: "Acme" }], loading: false }) },
64
+ }));
65
+ const { OrgList } = await import("../app/orgs/org-list"); // your component
66
+
67
+ test("renders orgs from the query", () => {
68
+ render(<OrgList />);
69
+ expect(screen.getByText("Acme")).toBeDefined();
70
+ });
71
+ ```
72
+
73
+ **Tier 3: functions over HTTP.** Use this tier when pure logic tests cannot cover the behavior. A handler's policies, `ctx.db` calls, and auth run in the app. Start `pylon dev` in another terminal and call the API. `resetDb()` from `@pylonsync/functions` clears the in-memory database between cases; it does nothing when the server is down and refuses to run in production.
74
+
75
+ ```ts
76
+ import { afterEach, expect, test } from "bun:test";
77
+ import { resetDb } from "@pylonsync/functions";
78
+
79
+ const BASE = "http://localhost:4321";
80
+ afterEach(() => resetDb(BASE)); // or installTestIsolation(BASE) once at top-of-file
81
+
82
+ test("createThing then read it back", async () => {
83
+ const t = await fetch(`${BASE}/api/fn/createThing`, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify({ name: "hello" }),
87
+ }).then((r) => r.json());
88
+ const rows = await fetch(`${BASE}/api/entities/Thing`).then((r) => r.json());
89
+ expect(rows.some((r: { id: string }) => r.id === t.id)).toBe(true);
90
+ });
91
+ ```
92
+
93
+ `pylon test:security` is a separate adversarial probe; it hits a running app and reports auth/policy holes (run `pylon dev`, then `pylon test:security`).
94
+
95
+ ## Use the CLI
96
+
97
+ | Need | Command |
98
+ |---|---|
99
+ | Run the app (SSR + API, hot reload, one port `:4321`) | `pylon dev` (or `npm run dev`) |
100
+ | Regenerate manifest + typed client | `pylon codegen` (Swift client: `pylon codegen client --target swift`) |
101
+ | Validate / diff / push schema | `pylon schema check` \| `diff` \| `push` |
102
+ | Migrations | `pylon migrate create <name>` \| `plan` \| `apply` |
103
+ | Lint policies (PYL001–PYL004) | `pylon lint --strict` |
104
+ | Tests | `pylon test` |
105
+ | Adversarial security probe | `pylon test:security` |
106
+ | Inspect cloud request logs (agent-safe) | `pylon logs --json --limit 50` |
107
+ | Inspect data / entities | `pylon data entities` \| `pylon data list <Entity>` |
108
+ | Call a function | `pylon fn <name> key=value` |
109
+ | Health snapshot | `pylon status` |
110
+ | Build for prod | `pylon build` |
111
+ | Deploy (Pylon Cloud by default) | `pylon deploy` |
112
+ | Look up an error code | `pylon explain <CODE>` |
113
+
114
+ `--json` works on every command for machine-readable output. Prefer one-shot/agent-safe flags (`pylon logs --limit N`, not a blocking `--follow`).
115
+
116
+ For full signatures, env vars, the complete CLI, and SSR/client/server-primitive details: **https://docs.pylonsync.com/llms-full.txt**.
@@ -0,0 +1,7 @@
1
+ # CLAUDE.md
2
+
3
+ Agent guidance for this Pylon project lives in **AGENTS.md**, the cross-editor
4
+ standard read by Claude Code, Cursor, Codex, and other agents. The import below
5
+ makes it available to Claude Code:
6
+
7
+ @AGENTS.md
@@ -0,0 +1,96 @@
1
+ # __APP_NAME__
2
+
3
+ A sales CRM for a small team — companies, contacts, and deals on a live
4
+ pipeline board. One [Pylon](https://pylonsync.com) app: SSR frontend, API, auth,
5
+ and realtime sync from one process on one port.
6
+
7
+ ## Develop
8
+
9
+ ```bash
10
+ __RUN_DEV__
11
+ ```
12
+
13
+ Open http://localhost:4321 and create an account. The first sign-in seeds a demo
14
+ pipeline so the board isn't empty; delete `functions/seedWorkspace.ts` and
15
+ `lib/seed.ts` once you have real customers.
16
+
17
+ ## What's interesting here
18
+
19
+ **The board is live.** Drag a deal to another column and it moves on every
20
+ teammate's board immediately — no refresh, no polling. Your own move takes the
21
+ same path as theirs: write → sync → re-render, so there's one behaviour to
22
+ reason about instead of two. Open the app in two windows and try it.
23
+
24
+ **⌘K searches everything.** Deals, companies, and contacts, ranked by how well
25
+ they match (`lib/search.ts`). It runs against the synced replica in memory, so
26
+ results appear as you type with no request per keystroke. `/` opens it too, and
27
+ `c` creates a deal.
28
+
29
+ **The forecast is derived, never stored.** Open pipeline, weighted value, and
30
+ win rate are computed from the deals themselves in `lib/pipeline.ts` — pure
31
+ functions with no React and no `db`, so the arithmetic is unit-tested directly
32
+ (`tests/pipeline.test.ts`).
33
+
34
+ ## Layout
35
+
36
+ ```
37
+ app.ts entities + policies
38
+ app/
39
+ page.tsx "/" — the pipeline board
40
+ pipeline-view.tsx board container
41
+ workspace.tsx app shell — the ONLY place that touches `db`
42
+ companies/, contacts/ list views
43
+ deals/[id]/ deal detail + activity timeline
44
+ login/ email/password auth
45
+ components/ presentational — props in, callbacks out
46
+ ui/ shadcn primitives
47
+ lib/pipeline.ts stages, money, metrics — pure
48
+ lib/search.ts ⌘K ranking — pure
49
+ functions/ server functions
50
+ tests/ pure logic + component tests
51
+ ```
52
+
53
+ The data boundary lives in exactly one module (`app/workspace.tsx`). Everything
54
+ under `components/` takes data as props and reports changes through callbacks,
55
+ which is why the component tests render them with fixtures and mock nothing.
56
+
57
+ ## Access
58
+
59
+ Every entity is readable and writable by any **signed-in** user, and by nobody
60
+ else — a shared pipeline is the point of a team CRM. `ownerId` records who
61
+ created a row without partitioning access.
62
+
63
+ For per-rep isolation, tighten `allowRead` in `app.ts`:
64
+
65
+ ```ts
66
+ allowRead: "auth.userId == data.ownerId"
67
+ ```
68
+
69
+ Contacts hold customer email and phone. There is no public route in this app and
70
+ no policy allows an anonymous read — keep it that way if you add one.
71
+
72
+ ## Theme
73
+
74
+ Dark by default (`class="dark"` in `app/layout.tsx`). The light tokens in
75
+ `app/globals.css` are complete, so removing that class flips the whole app. Both
76
+ use the standard shadcn variables, so `npx shadcn@latest add <component>` drops
77
+ in already themed.
78
+
79
+ ## Grow it
80
+
81
+ - **Add a field:** edit an entity in `app.ts` — the typed client and
82
+ REST/realtime API follow, no migration.
83
+ - **Add a stage:** `PIPELINE` in `lib/pipeline.ts`. The board, the forecast, and
84
+ the stage picker all read from it.
85
+ - **Add a view:** drop `app/reports/page.tsx` and add it to `NAV` in
86
+ `components/sidebar.tsx`.
87
+
88
+ ## Deploy
89
+
90
+ ```bash
91
+ pylon lint --strict
92
+ pylon test
93
+ pylon deploy
94
+ ```
95
+
96
+ Docs: https://docs.pylonsync.com
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { db } from "@pylonsync/react";
5
+ import { Building2, Plus } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { PageHeader } from "@/components/page-header";
8
+ import { DataTable, type ColumnDef } from "@/components/data-table";
9
+ import { EmptyState } from "@/components/empty-state";
10
+ import { RecordDialog } from "@/components/record-dialog";
11
+ import { Avatar } from "@/components/avatar";
12
+ import { money, relativeTime } from "@/lib/pipeline";
13
+ import { Workspace, type CompanyRow, type DealRow } from "../workspace";
14
+
15
+ export function CompaniesView({ email }: { email: string }) {
16
+ const [open, setOpen] = useState(false);
17
+
18
+ return (
19
+ <Workspace email={email} pathname="/companies">
20
+ {(data) => {
21
+ // Pipeline per company, computed here rather than stored — one less
22
+ // thing to keep in sync when a deal moves.
23
+ const openValue = (companyId: string) =>
24
+ data.deals
25
+ .filter((deal: DealRow) => deal.companyId === companyId)
26
+ .reduce((sum, deal) => sum + (Number(deal.value) || 0), 0);
27
+
28
+ const columns: ColumnDef<CompanyRow>[] = [
29
+ {
30
+ key: "name",
31
+ header: "Company",
32
+ cell: (row) => (
33
+ <span className="flex items-center gap-2">
34
+ <Avatar name={row.name} size="sm" />
35
+ <span className="truncate font-medium">{row.name}</span>
36
+ </span>
37
+ ),
38
+ },
39
+ {
40
+ key: "domain",
41
+ header: "Domain",
42
+ cell: (row) => (
43
+ <span className="text-muted-foreground">{row.domain ?? "—"}</span>
44
+ ),
45
+ },
46
+ {
47
+ key: "industry",
48
+ header: "Industry",
49
+ cell: (row) => (
50
+ <span className="text-muted-foreground">{row.industry ?? "—"}</span>
51
+ ),
52
+ },
53
+ {
54
+ key: "size",
55
+ header: "Size",
56
+ cell: (row) => (
57
+ <span className="text-muted-foreground">{row.size ?? "—"}</span>
58
+ ),
59
+ },
60
+ {
61
+ key: "pipeline",
62
+ header: "Pipeline",
63
+ numeric: true,
64
+ cell: (row) => money(openValue(row.id)),
65
+ },
66
+ {
67
+ key: "created",
68
+ header: "Added",
69
+ cell: (row) => (
70
+ <span className="text-muted-foreground">
71
+ {relativeTime(row.createdAt)}
72
+ </span>
73
+ ),
74
+ },
75
+ ];
76
+
77
+ const rows = [...data.companies].sort((a, b) => a.name.localeCompare(b.name));
78
+
79
+ return (
80
+ <>
81
+ <PageHeader title="Companies" count={rows.length}>
82
+ <Button size="sm" onClick={() => setOpen(true)}>
83
+ <Plus />
84
+ New company
85
+ </Button>
86
+ </PageHeader>
87
+
88
+ <DataTable
89
+ rows={rows}
90
+ columns={columns}
91
+ empty={
92
+ <EmptyState
93
+ icon={<Building2 />}
94
+ title="No companies yet"
95
+ description="Companies are the accounts your deals and contacts hang off. Add the first one to start building a pipeline."
96
+ action={
97
+ <Button size="sm" onClick={() => setOpen(true)}>
98
+ <Plus />
99
+ New company
100
+ </Button>
101
+ }
102
+ />
103
+ }
104
+ />
105
+
106
+ <RecordDialog
107
+ open={open}
108
+ title="New company"
109
+ submitLabel="Create company"
110
+ onOpenChange={setOpen}
111
+ fields={[
112
+ { name: "name", label: "Name", required: true, placeholder: "Acme Inc" },
113
+ { name: "domain", label: "Domain", placeholder: "acme.com" },
114
+ { name: "industry", label: "Industry", placeholder: "Logistics" },
115
+ { name: "size", label: "Size", placeholder: "50-200" },
116
+ ]}
117
+ onCreate={async (values) => {
118
+ // A plain policy-checked insert — no server function needed,
119
+ // because there's nothing to validate beyond the field types
120
+ // and nothing to stamp that the policy doesn't already gate.
121
+ await db.insert("Company", values);
122
+ }}
123
+ />
124
+ </>
125
+ );
126
+ }}
127
+ </Workspace>
128
+ );
129
+ }
@@ -0,0 +1,17 @@
1
+ import React, { use } from "react";
2
+ import type { Metadata, PageProps } from "@pylonsync/react";
3
+ import { CompaniesView } from "./companies-view";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Companies",
7
+ robots: "noindex",
8
+ };
9
+
10
+ export default function CompaniesPage({ auth, response, serverData }: PageProps) {
11
+ if (!auth.user_id || auth.user_id.startsWith("guest_")) {
12
+ response.redirect("/login");
13
+ return null;
14
+ }
15
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
16
+ return <CompaniesView email={me?.email ?? ""} />;
17
+ }
@@ -0,0 +1,139 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import { db } from "@pylonsync/react";
5
+ import { Plus, Users } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { PageHeader } from "@/components/page-header";
8
+ import { DataTable, type ColumnDef } from "@/components/data-table";
9
+ import { EmptyState } from "@/components/empty-state";
10
+ import { RecordDialog } from "@/components/record-dialog";
11
+ import { Avatar } from "@/components/avatar";
12
+ import { relativeTime } from "@/lib/pipeline";
13
+ import { Workspace, type ContactRow } from "../workspace";
14
+
15
+ export function ContactsView({ email }: { email: string }) {
16
+ const [open, setOpen] = useState(false);
17
+
18
+ return (
19
+ <Workspace email={email} pathname="/contacts">
20
+ {(data) => {
21
+ const columns: ColumnDef<ContactRow>[] = [
22
+ {
23
+ key: "name",
24
+ header: "Name",
25
+ cell: (row) => (
26
+ <span className="flex items-center gap-2">
27
+ <Avatar name={row.name} size="sm" />
28
+ <span className="truncate font-medium">{row.name}</span>
29
+ </span>
30
+ ),
31
+ },
32
+ {
33
+ key: "title",
34
+ header: "Title",
35
+ cell: (row) => (
36
+ <span className="text-muted-foreground">{row.title ?? "—"}</span>
37
+ ),
38
+ },
39
+ {
40
+ key: "company",
41
+ header: "Company",
42
+ cell: (row) => (
43
+ <span className="text-muted-foreground">
44
+ {data.companyName(row.companyId) ?? "—"}
45
+ </span>
46
+ ),
47
+ },
48
+ {
49
+ key: "email",
50
+ header: "Email",
51
+ cell: (row) =>
52
+ row.email ? (
53
+ <a
54
+ href={`mailto:${row.email}`}
55
+ onClick={(event) => event.stopPropagation()}
56
+ className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
57
+ >
58
+ {row.email}
59
+ </a>
60
+ ) : (
61
+ <span className="text-muted-foreground">—</span>
62
+ ),
63
+ },
64
+ {
65
+ key: "phone",
66
+ header: "Phone",
67
+ cell: (row) => (
68
+ <span className="text-muted-foreground">{row.phone ?? "—"}</span>
69
+ ),
70
+ },
71
+ {
72
+ key: "created",
73
+ header: "Added",
74
+ cell: (row) => (
75
+ <span className="text-muted-foreground">
76
+ {relativeTime(row.createdAt)}
77
+ </span>
78
+ ),
79
+ },
80
+ ];
81
+
82
+ const rows = [...data.contacts].sort((a, b) => a.name.localeCompare(b.name));
83
+
84
+ return (
85
+ <>
86
+ <PageHeader title="Contacts" count={rows.length}>
87
+ <Button size="sm" onClick={() => setOpen(true)}>
88
+ <Plus />
89
+ New contact
90
+ </Button>
91
+ </PageHeader>
92
+
93
+ <DataTable
94
+ rows={rows}
95
+ columns={columns}
96
+ empty={
97
+ <EmptyState
98
+ icon={<Users />}
99
+ title="No contacts yet"
100
+ description="The people you actually talk to. Add one and link it to a company to keep the account together."
101
+ action={
102
+ <Button size="sm" onClick={() => setOpen(true)}>
103
+ <Plus />
104
+ New contact
105
+ </Button>
106
+ }
107
+ />
108
+ }
109
+ />
110
+
111
+ <RecordDialog
112
+ open={open}
113
+ title="New contact"
114
+ submitLabel="Create contact"
115
+ onOpenChange={setOpen}
116
+ fields={[
117
+ { name: "name", label: "Name", required: true, placeholder: "Dana Whitfield" },
118
+ { name: "title", label: "Title", placeholder: "VP Operations" },
119
+ {
120
+ name: "companyId",
121
+ label: "Company",
122
+ options: data.companies
123
+ .slice()
124
+ .sort((a, b) => a.name.localeCompare(b.name))
125
+ .map((company) => ({ value: company.id, label: company.name })),
126
+ },
127
+ { name: "email", label: "Email", type: "email", placeholder: "dana@acme.com" },
128
+ { name: "phone", label: "Phone", type: "tel", placeholder: "+1 555 0100" },
129
+ ]}
130
+ onCreate={async (values) => {
131
+ await db.insert("Contact", values);
132
+ }}
133
+ />
134
+ </>
135
+ );
136
+ }}
137
+ </Workspace>
138
+ );
139
+ }
@@ -0,0 +1,17 @@
1
+ import React, { use } from "react";
2
+ import type { Metadata, PageProps } from "@pylonsync/react";
3
+ import { ContactsView } from "./contacts-view";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Contacts",
7
+ robots: "noindex",
8
+ };
9
+
10
+ export default function ContactsPage({ auth, response, serverData }: PageProps) {
11
+ if (!auth.user_id || auth.user_id.startsWith("guest_")) {
12
+ response.redirect("/login");
13
+ return null;
14
+ }
15
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
16
+ return <ContactsView email={me?.email ?? ""} />;
17
+ }