@pylonsync/create-pylon 0.3.344 → 0.3.345

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
@@ -0,0 +1,38 @@
1
+ import React, { use } from "react";
2
+ import type { Metadata, PageProps } from "@pylonsync/react";
3
+ import { PipelineView } from "./pipeline-view";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Pipeline",
7
+ robots: "noindex",
8
+ };
9
+
10
+ /**
11
+ * `/` — the pipeline board.
12
+ *
13
+ * Server-side auth gate only. Reading `auth` opts this render out of caching,
14
+ * which is correct: every screen in this app is private.
15
+ *
16
+ * The signed-in email comes from the User row, not from `auth` — `PageAuth` is
17
+ * `{ user_id, is_admin, tenant_id, roles }` and carries no email.
18
+ */
19
+ export default function PipelinePage({
20
+ auth,
21
+ response,
22
+ searchParams,
23
+ serverData,
24
+ }: PageProps) {
25
+ if (!auth.user_id || auth.user_id.startsWith("guest_")) {
26
+ response.redirect("/login");
27
+ return null;
28
+ }
29
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
30
+
31
+ return (
32
+ <PipelineView
33
+ email={me?.email ?? ""}
34
+ // The ⌘K "New deal" action navigates here with ?new=deal.
35
+ openNew={searchParams?.new === "deal"}
36
+ />
37
+ );
38
+ }
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { callFn, useRouter } from "@pylonsync/react";
5
+ import { Plus } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Kbd } from "@/components/kbd";
8
+ import { PageHeader } from "@/components/page-header";
9
+ import { MetricsBar } from "@/components/metrics-bar";
10
+ import { PipelineBoard } from "@/components/pipeline-board";
11
+ import { BoardSkeleton } from "@/components/board-skeleton";
12
+ import { DealDialog } from "@/components/deal-dialog";
13
+ import { Workspace } from "./workspace";
14
+
15
+ export function PipelineView({
16
+ email,
17
+ openNew,
18
+ }: {
19
+ email: string;
20
+ openNew?: boolean;
21
+ }) {
22
+ const router = useRouter();
23
+ const [dialogOpen, setDialogOpen] = useState(Boolean(openNew));
24
+
25
+ // "c" creates a deal, the way an issue tracker does. Ignored while typing so
26
+ // it never swallows a character in the search box or the activity composer.
27
+ useEffect(() => {
28
+ function onKey(event: KeyboardEvent) {
29
+ const target = event.target as HTMLElement | null;
30
+ const typing =
31
+ !!target &&
32
+ (target.tagName === "INPUT" ||
33
+ target.tagName === "TEXTAREA" ||
34
+ target.isContentEditable);
35
+ if (event.key === "c" && !typing && !event.metaKey && !event.ctrlKey) {
36
+ event.preventDefault();
37
+ setDialogOpen(true);
38
+ }
39
+ }
40
+ window.addEventListener("keydown", onKey);
41
+ return () => window.removeEventListener("keydown", onKey);
42
+ }, []);
43
+
44
+ return (
45
+ <Workspace email={email} pathname="/">
46
+ {(data) => (
47
+ <>
48
+ <PageHeader title="Pipeline" count={data.deals.length}>
49
+ <Button size="sm" onClick={() => setDialogOpen(true)}>
50
+ <Plus />
51
+ New deal
52
+ <Kbd className="ml-1 border-primary-foreground/25 bg-primary-foreground/10 text-primary-foreground/70">
53
+ c
54
+ </Kbd>
55
+ </Button>
56
+ </PageHeader>
57
+
58
+ <MetricsBar deals={data.deals} />
59
+
60
+ <div className="min-h-0 flex-1">
61
+ {data.loading ? (
62
+ <BoardSkeleton />
63
+ ) : (
64
+ <PipelineBoard
65
+ deals={data.deals}
66
+ companyName={data.companyName}
67
+ ownerName={data.ownerName}
68
+ onOpen={(id) => router.push(`/deals/${id}`)}
69
+ onMove={(dealId, stage) => {
70
+ // Through the mutation, not a bare update: moving a deal also
71
+ // writes a history entry, and the stage is validated
72
+ // server-side.
73
+ void callFn("moveDeal", { dealId, stage });
74
+ }}
75
+ />
76
+ )}
77
+ </div>
78
+
79
+ <DealDialog
80
+ open={dialogOpen}
81
+ companies={data.companies}
82
+ onOpenChange={setDialogOpen}
83
+ onCreate={async (draft) => {
84
+ await callFn("createDeal", {
85
+ title: draft.title,
86
+ value: draft.value,
87
+ stage: draft.stage,
88
+ ...(draft.companyId ? { companyId: draft.companyId } : {}),
89
+ ...(draft.closeDate
90
+ ? { closeDate: new Date(draft.closeDate).toISOString() }
91
+ : {}),
92
+ });
93
+ }}
94
+ />
95
+ </>
96
+ )}
97
+ </Workspace>
98
+ );
99
+ }
@@ -0,0 +1,12 @@
1
+ import type { Robots } from "@pylonsync/react";
2
+
3
+ // app/robots.ts → served at /robots.txt. The default export may also be async.
4
+ const SITE = process.env.SITE_URL ?? "http://localhost:4321";
5
+
6
+ export default function robots(): Robots {
7
+ return {
8
+ // Keep the authenticated app and the API out of the index.
9
+ rules: { userAgent: "*", allow: "/", disallow: ["/dashboard", "/api/"] },
10
+ sitemap: `${SITE}/sitemap.xml`,
11
+ };
12
+ }
@@ -0,0 +1,27 @@
1
+ import type { Sitemap } from "@pylonsync/react";
2
+
3
+ // app/sitemap.ts → served at /sitemap.xml. The default export can be async, so
4
+ // it can enumerate dynamic pages from your database. Point SITE_URL at your
5
+ // domain in production.
6
+ const SITE = process.env.SITE_URL ?? "http://localhost:4321";
7
+
8
+ export default async function sitemap(): Promise<Sitemap> {
9
+ // Only public pages belong here — /dashboard is private (and noindex), so
10
+ // it's intentionally left out.
11
+ const staticRoutes: Sitemap = [
12
+ { url: `${SITE}/`, changeFrequency: "weekly", priority: 1 },
13
+ { url: `${SITE}/login`, changeFrequency: "yearly", priority: 0.3 },
14
+ { url: `${SITE}/signup`, changeFrequency: "yearly", priority: 0.5 },
15
+ ];
16
+
17
+ // The export is async, so you can enumerate dynamic pages from a DB read:
18
+ //
19
+ // const posts = await fetchPublishedPosts();
20
+ // const postRoutes: Sitemap = posts.map((p) => ({
21
+ // url: `${SITE}/blog/${p.slug}`,
22
+ // lastModified: p.updatedAt,
23
+ // }));
24
+ // return [...staticRoutes, ...postRoutes];
25
+
26
+ return staticRoutes;
27
+ }
@@ -0,0 +1,231 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useMemo, useRef, useState } from "react";
4
+ import { callFn, db, useRouter } from "@pylonsync/react";
5
+ import { useAuth } from "@pylonsync/client";
6
+ import { Sidebar } from "@/components/sidebar";
7
+ import { CommandPalette } from "@/components/command-palette";
8
+ import type { SearchItem } from "@/lib/search";
9
+
10
+ export interface CompanyRow {
11
+ id: string;
12
+ name: string;
13
+ domain?: string | null;
14
+ industry?: string | null;
15
+ size?: string | null;
16
+ createdAt?: string | null;
17
+ }
18
+ export interface ContactRow {
19
+ id: string;
20
+ name: string;
21
+ email?: string | null;
22
+ phone?: string | null;
23
+ title?: string | null;
24
+ companyId?: string | null;
25
+ createdAt?: string | null;
26
+ }
27
+ export interface DealRow {
28
+ id: string;
29
+ title: string;
30
+ value?: number | null;
31
+ stage: string;
32
+ companyId?: string | null;
33
+ contactId?: string | null;
34
+ closeDate?: string | null;
35
+ ownerId?: string | null;
36
+ createdAt?: string | null;
37
+ updatedAt?: string | null;
38
+ }
39
+ export interface ActivityRow {
40
+ id: string;
41
+ kind: string;
42
+ body: string;
43
+ dealId?: string | null;
44
+ contactId?: string | null;
45
+ ownerId?: string | null;
46
+ createdAt?: string | null;
47
+ }
48
+ export interface UserRow {
49
+ id: string;
50
+ email: string;
51
+ displayName?: string | null;
52
+ }
53
+
54
+ export interface WorkspaceData {
55
+ companies: CompanyRow[];
56
+ contacts: ContactRow[];
57
+ deals: DealRow[];
58
+ activities: ActivityRow[];
59
+ companyName: (id: string | null | undefined) => string | null;
60
+ ownerName: (id: string | null | undefined) => string | null;
61
+ loading: boolean;
62
+ }
63
+
64
+ /**
65
+ * The app shell, and the ONLY place that touches `db`.
66
+ *
67
+ * Every screen below receives plain data through the render prop and reports
68
+ * changes through callbacks, which is what keeps the components in
69
+ * `components/` renderable from fixtures in a test with nothing mocked.
70
+ *
71
+ * Each `db.useQuery` is live: when a teammate moves a deal, the row arrives
72
+ * here and the board re-renders. Your own writes take exactly the same path,
73
+ * so there's one behaviour to reason about rather than two.
74
+ */
75
+ export function Workspace({
76
+ email,
77
+ pathname,
78
+ children,
79
+ }: {
80
+ email: string;
81
+ pathname: string;
82
+ children: (data: WorkspaceData) => React.ReactNode;
83
+ }) {
84
+ const router = useRouter();
85
+ const { signOut } = useAuth();
86
+ const { data: companies, loading: loadingCompanies } =
87
+ db.useQuery<CompanyRow>("Company");
88
+ const { data: contacts } = db.useQuery<ContactRow>("Contact");
89
+ const { data: deals } = db.useQuery<DealRow>("Deal");
90
+ const { data: activities } = db.useQuery<ActivityRow>("Activity");
91
+ const { data: users } = db.useQuery<UserRow>("User");
92
+
93
+ const [paletteOpen, setPaletteOpen] = useState(false);
94
+ const seeded = useRef(false);
95
+
96
+ const companyList = companies ?? [];
97
+ const contactList = contacts ?? [];
98
+ const dealList = deals ?? [];
99
+
100
+ // A brand-new workspace gets a realistic pipeline once, so the app never
101
+ // opens on an empty board. `seedWorkspace` no-ops when anything exists, and
102
+ // the ref stops a re-render from firing a second call mid-flight.
103
+ useEffect(() => {
104
+ if (loadingCompanies || seeded.current) return;
105
+ if (companyList.length > 0) {
106
+ seeded.current = true;
107
+ return;
108
+ }
109
+ seeded.current = true;
110
+ callFn("seedWorkspace", {}).catch(() => {
111
+ // A failed seed leaves an empty workspace, which is usable — every view
112
+ // has an empty state that offers to create the first record.
113
+ seeded.current = false;
114
+ });
115
+ }, [loadingCompanies, companyList.length]);
116
+
117
+ // ⌘K / Ctrl+K anywhere, and "/" when you're not already typing.
118
+ useEffect(() => {
119
+ function onKey(event: KeyboardEvent) {
120
+ const target = event.target as HTMLElement | null;
121
+ const typing =
122
+ !!target &&
123
+ (target.tagName === "INPUT" ||
124
+ target.tagName === "TEXTAREA" ||
125
+ target.isContentEditable);
126
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
127
+ event.preventDefault();
128
+ setPaletteOpen((open) => !open);
129
+ } else if (event.key === "/" && !typing) {
130
+ event.preventDefault();
131
+ setPaletteOpen(true);
132
+ }
133
+ }
134
+ window.addEventListener("keydown", onKey);
135
+ return () => window.removeEventListener("keydown", onKey);
136
+ }, []);
137
+
138
+ const byId = useMemo(() => {
139
+ const companyById = new Map(companyList.map((c) => [c.id, c] as const));
140
+ const userById = new Map((users ?? []).map((u) => [u.id, u] as const));
141
+ return { companyById, userById };
142
+ }, [companyList, users]);
143
+
144
+ const searchIndex = useMemo<SearchItem[]>(
145
+ () => [
146
+ ...dealList.map((deal) => ({
147
+ id: `deal:${deal.id}`,
148
+ type: "deal" as const,
149
+ title: deal.title,
150
+ subtitle: byId.companyById.get(deal.companyId ?? "")?.name,
151
+ href: `/deals/${deal.id}`,
152
+ })),
153
+ ...companyList.map((company) => ({
154
+ id: `company:${company.id}`,
155
+ type: "company" as const,
156
+ title: company.name,
157
+ subtitle: company.domain ?? undefined,
158
+ href: "/companies",
159
+ keywords: company.industry ?? undefined,
160
+ })),
161
+ ...contactList.map((contact) => ({
162
+ id: `contact:${contact.id}`,
163
+ type: "contact" as const,
164
+ title: contact.name,
165
+ subtitle: byId.companyById.get(contact.companyId ?? "")?.name,
166
+ href: "/contacts",
167
+ keywords: `${contact.email ?? ""} ${contact.title ?? ""}`,
168
+ })),
169
+ ],
170
+ [dealList, companyList, contactList, byId],
171
+ );
172
+
173
+ const data: WorkspaceData = {
174
+ companies: companyList,
175
+ contacts: contactList,
176
+ deals: dealList,
177
+ activities: activities ?? [],
178
+ companyName: (id) => byId.companyById.get(id ?? "")?.name ?? null,
179
+ ownerName: (id) => {
180
+ const user = byId.userById.get(id ?? "");
181
+ return user?.displayName || user?.email || null;
182
+ },
183
+ loading: loadingCompanies,
184
+ };
185
+
186
+ return (
187
+ <div className="flex h-screen overflow-hidden">
188
+ <Sidebar
189
+ workspace="CRM"
190
+ email={email}
191
+ pathname={pathname}
192
+ counts={{
193
+ "/companies": companyList.length,
194
+ "/contacts": contactList.length,
195
+ }}
196
+ onOpenCommand={() => setPaletteOpen(true)}
197
+ onSignOut={async () => {
198
+ await signOut();
199
+ // Full navigation so the SSR runtime re-resolves auth from the
200
+ // cookie and the login page renders server-side.
201
+ window.location.assign("/login");
202
+ }}
203
+ />
204
+ <main className="flex min-w-0 flex-1 flex-col">{children(data)}</main>
205
+
206
+ <CommandPalette
207
+ open={paletteOpen}
208
+ items={searchIndex}
209
+ actions={[
210
+ {
211
+ id: "new-deal",
212
+ label: "New deal",
213
+ run: () => router.push("/?new=deal"),
214
+ },
215
+ {
216
+ id: "go-companies",
217
+ label: "Go to Companies",
218
+ run: () => router.push("/companies"),
219
+ },
220
+ {
221
+ id: "go-contacts",
222
+ label: "Go to Contacts",
223
+ run: () => router.push("/contacts"),
224
+ },
225
+ ]}
226
+ onClose={() => setPaletteOpen(false)}
227
+ onSelect={(item) => router.push(item.href)}
228
+ />
229
+ </div>
230
+ );
231
+ }
@@ -0,0 +1,171 @@
1
+ import {
2
+ entity,
3
+ field,
4
+ policy,
5
+ auth,
6
+ buildManifest,
7
+ discoverAppRoutes,
8
+ font,
9
+ } from "@pylonsync/sdk";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // crm — a sales CRM for a small team: companies, contacts, deals moving through
13
+ // a pipeline, and an activity log.
14
+ //
15
+ // The realtime hook: the board subscribes to Deal, so when a teammate drags a
16
+ // deal to "Won" it moves on everyone's board instantly. No refresh, and no
17
+ // standup spent reconciling two people's idea of the forecast.
18
+ //
19
+ // • Company — the account. Contacts and deals hang off it.
20
+ // • Contact — a person at a company. Holds email/phone, so never public.
21
+ // • Deal — the opportunity: value, stage, expected close. The pipeline.
22
+ // • Activity — a note/call/email/meeting logged against a deal or contact.
23
+ // • User — a team member (email/password).
24
+ //
25
+ // TRUST MODEL: an internal tool for ONE team. Every entity is readable and
26
+ // writable by any SIGNED-IN user, and by nobody else — a CRM's whole point is a
27
+ // shared pipeline. `ownerId` records who created a row (for "my deals" and
28
+ // accountability) without partitioning access. For per-rep isolation, tighten
29
+ // allowRead to "auth.userId == data.ownerId" and add a manager role.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ // Signed-in team members only. Deliberately not `"true"`: an anonymous visitor
33
+ // must never read the customer list, and `pylon lint` flags wide-open policies
34
+ // for exactly this reason.
35
+ const TEAM = "auth.userId != null";
36
+
37
+ const Company = entity(
38
+ "Company",
39
+ {
40
+ name: field.string(),
41
+ domain: field.string().optional(),
42
+ industry: field.string().optional(),
43
+ // Free text ("1-10", "50-200") rather than an enum — headcount bands vary
44
+ // by who you ask, and a CRM shouldn't argue with its user about it.
45
+ size: field.string().optional(),
46
+ notes: field.string().optional(),
47
+ ownerId: field.id("User").readonly().optional(),
48
+ createdAt: field.datetime().defaultNow(),
49
+ },
50
+ { indexes: [{ name: "by_name", fields: ["name"], unique: false }] },
51
+ );
52
+
53
+ const Contact = entity(
54
+ "Contact",
55
+ {
56
+ companyId: field.id("Company").optional(),
57
+ name: field.string(),
58
+ email: field.string().optional(),
59
+ phone: field.string().optional(),
60
+ title: field.string().optional(),
61
+ ownerId: field.id("User").readonly().optional(),
62
+ createdAt: field.datetime().defaultNow(),
63
+ },
64
+ { indexes: [{ name: "by_company", fields: ["companyId"], unique: false }] },
65
+ );
66
+
67
+ // `stage` drives the board; PIPELINE in lib/pipeline.ts owns the ordered stages
68
+ // and which of them count as closed.
69
+ const Deal = entity(
70
+ "Deal",
71
+ {
72
+ title: field.string(),
73
+ companyId: field.id("Company").optional(),
74
+ contactId: field.id("Contact").optional(),
75
+ // Whole currency units, not cents — a forecast is read by humans, and this
76
+ // keeps the card, the column total, and the input in one unit.
77
+ value: field.number().default(0),
78
+ stage: field.string().default("lead"),
79
+ closeDate: field.datetime().optional(),
80
+ ownerId: field.id("User").readonly().optional(),
81
+ createdAt: field.datetime().defaultNow(),
82
+ updatedAt: field.datetime().defaultNow(),
83
+ },
84
+ { indexes: [{ name: "by_stage", fields: ["stage"], unique: false }] },
85
+ );
86
+
87
+ // The timeline. Attached to a deal, a contact, or both — logging a call against
88
+ // a contact shouldn't require inventing a deal to hang it on.
89
+ const Activity = entity(
90
+ "Activity",
91
+ {
92
+ // "note" | "call" | "email" | "meeting" — see ACTIVITY_KINDS.
93
+ kind: field.string().default("note"),
94
+ body: field.string(),
95
+ dealId: field.id("Deal").optional(),
96
+ contactId: field.id("Contact").optional(),
97
+ companyId: field.id("Company").optional(),
98
+ ownerId: field.id("User").readonly().optional(),
99
+ createdAt: field.datetime().defaultNow(),
100
+ },
101
+ { indexes: [{ name: "by_deal", fields: ["dealId"], unique: false }] },
102
+ );
103
+
104
+ const User = entity(
105
+ "User",
106
+ {
107
+ email: field.string(),
108
+ displayName: field.string().optional(),
109
+ passwordHash: field.string().serverOnly().optional(),
110
+ avatarColor: field.string().optional(),
111
+ emailVerified: field.datetime().optional(),
112
+ createdAt: field.datetime().defaultNow(),
113
+ },
114
+ { indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
115
+ );
116
+
117
+ const teamPolicy = (name: string, entityName: string) =>
118
+ policy({
119
+ name,
120
+ entity: entityName,
121
+ allowRead: TEAM,
122
+ allowInsert: TEAM,
123
+ allowUpdate: TEAM,
124
+ allowDelete: TEAM,
125
+ });
126
+
127
+ // Team members read each other's rows so a deal can show who owns it. Nobody
128
+ // writes User through the client — auth owns that, and passwordHash is
129
+ // serverOnly regardless.
130
+ const userPolicy = policy({
131
+ name: "user_team_read",
132
+ entity: "User",
133
+ allowRead: TEAM,
134
+ allowInsert: "false",
135
+ allowUpdate: "false",
136
+ allowDelete: "false",
137
+ });
138
+
139
+ const manifest = buildManifest({
140
+ name: "__APP_NAME__",
141
+ version: "0.1.0",
142
+ entities: [Company, Contact, Deal, Activity, User],
143
+ queries: [],
144
+ actions: [],
145
+ policies: [
146
+ teamPolicy("company_team", "Company"),
147
+ teamPolicy("contact_team", "Contact"),
148
+ teamPolicy("deal_team", "Deal"),
149
+ teamPolicy("activity_team", "Activity"),
150
+ userPolicy,
151
+ ],
152
+ auth: auth(),
153
+ // Self-hosted Inter: the build fetches the woff2, serves it same-origin (no
154
+ // third-party request, no FOUT), preloads it, and synthesizes a size-adjusted
155
+ // fallback so there's no layout shift.
156
+ fonts: [
157
+ font({
158
+ family: "Inter",
159
+ variable: "--font-sans",
160
+ weights: ["400", "500", "600", "700"],
161
+ subsets: ["latin"],
162
+ display: "swap",
163
+ preload: true,
164
+ }),
165
+ ],
166
+ routes: await discoverAppRoutes(),
167
+ });
168
+
169
+ console.log(JSON.stringify(manifest, null, 2));
170
+
171
+ export default manifest;
@@ -0,0 +1,4 @@
1
+ [test]
2
+ # Register a DOM (happy-dom) before any test runs so React component tests can
3
+ # render under `pylon test` / `bun test`. Harmless for pure-logic tests.
4
+ preload = ["./tests/setup.ts"]