@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
@@ -0,0 +1,147 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState } from "react";
4
+ import { callFn, useRouter } from "@pylonsync/react";
5
+ import { Inbox, Plus } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Select } from "@/components/ui/select";
8
+ import { Kbd } from "@/components/kbd";
9
+ import { PageHeader } from "@/components/page-header";
10
+ import { EmptyState } from "@/components/empty-state";
11
+ import { TicketList } from "@/components/ticket-list";
12
+ import { NewTicketDialog } from "@/components/new-ticket-dialog";
13
+ import { STATUSES, applyFilter, counts, type Filter } from "@/lib/tickets";
14
+ import { Workspace } from "./workspace";
15
+
16
+ export function InboxView({
17
+ email,
18
+ openNew,
19
+ initialFilter,
20
+ }: {
21
+ email: string;
22
+ openNew?: boolean;
23
+ initialFilter?: string;
24
+ }) {
25
+ const router = useRouter();
26
+ const [dialogOpen, setDialogOpen] = useState(Boolean(openNew));
27
+ const [status, setStatus] = useState<string>("open");
28
+ const [unassigned, setUnassigned] = useState(initialFilter === "unassigned");
29
+
30
+ // "c" opens a ticket, the way an issue tracker does. Ignored while typing so
31
+ // it never swallows a character in the reply box.
32
+ useEffect(() => {
33
+ function onKey(event: KeyboardEvent) {
34
+ const target = event.target as HTMLElement | null;
35
+ const typing =
36
+ !!target &&
37
+ (target.tagName === "INPUT" ||
38
+ target.tagName === "TEXTAREA" ||
39
+ target.isContentEditable);
40
+ if (event.key === "c" && !typing && !event.metaKey && !event.ctrlKey) {
41
+ event.preventDefault();
42
+ setDialogOpen(true);
43
+ }
44
+ }
45
+ window.addEventListener("keydown", onKey);
46
+ return () => window.removeEventListener("keydown", onKey);
47
+ }, []);
48
+
49
+ return (
50
+ <Workspace email={email} pathname="/">
51
+ {(data) => {
52
+ const filter: Filter = {
53
+ ...(status === "all" ? {} : { status }),
54
+ ...(unassigned ? { unassigned: true } : {}),
55
+ };
56
+ const visible = applyFilter(data.tickets, filter);
57
+ const queue = counts(data.tickets);
58
+
59
+ return (
60
+ <>
61
+ <PageHeader title="Inbox" count={visible.length}>
62
+ <div className="w-32">
63
+ <Select
64
+ aria-label="Status"
65
+ value={status}
66
+ onChange={(event) => setStatus(event.target.value)}
67
+ className="h-7 text-[12px]"
68
+ >
69
+ <option value="all">All statuses</option>
70
+ {STATUSES.map((s) => (
71
+ <option key={s.id} value={s.id}>
72
+ {s.label}
73
+ </option>
74
+ ))}
75
+ </Select>
76
+ </div>
77
+ <Button
78
+ size="sm"
79
+ variant={unassigned ? "default" : "secondary"}
80
+ onClick={() => setUnassigned((on) => !on)}
81
+ title="Show only tickets with no agent"
82
+ >
83
+ Unassigned
84
+ <span className="tabular ml-1 opacity-70">{queue.unassigned}</span>
85
+ </Button>
86
+ <Button size="sm" onClick={() => setDialogOpen(true)}>
87
+ <Plus />
88
+ New ticket
89
+ <Kbd className="ml-1 border-primary-foreground/25 bg-primary-foreground/10 text-primary-foreground/70">
90
+ c
91
+ </Kbd>
92
+ </Button>
93
+ </PageHeader>
94
+
95
+ {visible.length === 0 ? (
96
+ <EmptyState
97
+ icon={<Inbox />}
98
+ title={
99
+ data.loading
100
+ ? "Loading…"
101
+ : data.tickets.length === 0
102
+ ? "No tickets yet"
103
+ : "Nothing matches this filter"
104
+ }
105
+ description={
106
+ data.tickets.length === 0
107
+ ? "When a customer writes in, their ticket lands here. You can also open one yourself after a phone call."
108
+ : "Try a different status, or clear the unassigned filter."
109
+ }
110
+ action={
111
+ data.tickets.length === 0 ? (
112
+ <Button size="sm" onClick={() => setDialogOpen(true)}>
113
+ <Plus />
114
+ New ticket
115
+ </Button>
116
+ ) : undefined
117
+ }
118
+ />
119
+ ) : (
120
+ <TicketList
121
+ tickets={visible}
122
+ customerName={data.customerName}
123
+ assigneeName={data.agentName}
124
+ onSelect={(id) => router.push(`/tickets/${id}`)}
125
+ />
126
+ )}
127
+
128
+ <NewTicketDialog
129
+ open={dialogOpen}
130
+ customers={data.customers}
131
+ onOpenChange={setDialogOpen}
132
+ onCreate={async (draft) => {
133
+ const result = await callFn<{ id: string }>("createTicket", {
134
+ subject: draft.subject,
135
+ body: draft.body,
136
+ priority: draft.priority,
137
+ ...(draft.customerId ? { customerId: draft.customerId } : {}),
138
+ });
139
+ if (result?.id) router.push(`/tickets/${result.id}`);
140
+ }}
141
+ />
142
+ </>
143
+ );
144
+ }}
145
+ </Workspace>
146
+ );
147
+ }
@@ -0,0 +1,31 @@
1
+ import React from "react";
2
+
3
+ interface LayoutProps {
4
+ children: React.ReactNode;
5
+ }
6
+
7
+ /**
8
+ * The document shell. The app chrome — sidebar, command palette, shortcuts —
9
+ * lives in `app/workspace.tsx` instead, because it needs the synced data; this
10
+ * layer only sets up the page.
11
+ *
12
+ * `class="dark"` makes dark the default theme. The light tokens in globals.css
13
+ * are complete and tested, so removing the class flips the whole app; swap it
14
+ * for a stored preference when you want a toggle.
15
+ */
16
+ export default function RootLayout({ children }: LayoutProps) {
17
+ return (
18
+ <html lang="en" className="dark">
19
+ <head>
20
+ <meta charSet="utf-8" />
21
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
22
+ <title>__APP_NAME__</title>
23
+ {/* Tailwind is compiled by Pylon from app/globals.css and the
24
+ stylesheet link is injected here automatically. Inter is declared in
25
+ app.ts (fonts: [...]) and self-hosted by the build — @font-face, the
26
+ preload link, and a size-adjusted fallback are injected too. */}
27
+ </head>
28
+ <body className="h-full">{children}</body>
29
+ </html>
30
+ );
31
+ }
@@ -0,0 +1,122 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import {
5
+ ApiError,
6
+ passwordLogin,
7
+ passwordRegister,
8
+ persistSession,
9
+ } from "@pylonsync/client";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Input } from "@/components/ui/input";
12
+ import { Label } from "@/components/ui/label";
13
+
14
+ /**
15
+ * One form, two modes. Calls the built-in auth API (`/api/auth/password/*`),
16
+ * then `persistSession` writes the token so the sync engine and `callFn`
17
+ * authenticate as this user on the next load.
18
+ *
19
+ * The navigation is a full `assign`, not a client route change, so the SSR
20
+ * runtime re-resolves auth from the HttpOnly cookie and the first paint of the
21
+ * workspace is already signed in.
22
+ */
23
+ export function AuthForm() {
24
+ const [mode, setMode] = useState<"login" | "signup">("login");
25
+ const [email, setEmail] = useState("");
26
+ const [password, setPassword] = useState("");
27
+ const [error, setError] = useState<string | null>(null);
28
+ const [pending, setPending] = useState(false);
29
+
30
+ async function onSubmit(event: React.FormEvent) {
31
+ event.preventDefault();
32
+ setError(null);
33
+ setPending(true);
34
+ try {
35
+ const session =
36
+ mode === "login"
37
+ ? await passwordLogin({ email, password })
38
+ : await passwordRegister({ email, password });
39
+ persistSession(session);
40
+ window.location.assign("/");
41
+ } catch (err) {
42
+ setError(messageFor(err));
43
+ setPending(false);
44
+ }
45
+ }
46
+
47
+ return (
48
+ <div className="space-y-5">
49
+ <form onSubmit={onSubmit} className="space-y-3.5">
50
+ <div className="space-y-1.5">
51
+ <Label htmlFor="email">Email</Label>
52
+ <Input
53
+ id="email"
54
+ type="email"
55
+ value={email}
56
+ onChange={(event) => setEmail(event.target.value)}
57
+ required
58
+ autoComplete="email"
59
+ placeholder="you@company.com"
60
+ />
61
+ </div>
62
+ <div className="space-y-1.5">
63
+ <Label htmlFor="password">Password</Label>
64
+ <Input
65
+ id="password"
66
+ type="password"
67
+ value={password}
68
+ onChange={(event) => setPassword(event.target.value)}
69
+ required
70
+ autoComplete={mode === "login" ? "current-password" : "new-password"}
71
+ placeholder={mode === "login" ? "Your password" : "At least 10 characters"}
72
+ />
73
+ </div>
74
+ {error ? (
75
+ <p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-[12px] leading-snug text-destructive">
76
+ {error}
77
+ </p>
78
+ ) : null}
79
+ <Button type="submit" disabled={pending} className="w-full">
80
+ {pending ? "…" : mode === "login" ? "Sign in" : "Create account"}
81
+ </Button>
82
+ </form>
83
+
84
+ <p className="text-center text-[12px] text-muted-foreground">
85
+ {mode === "login" ? "First time here?" : "Already have an account?"}{" "}
86
+ <button
87
+ type="button"
88
+ onClick={() => {
89
+ setMode(mode === "login" ? "signup" : "login");
90
+ setError(null);
91
+ }}
92
+ className="font-medium text-foreground underline underline-offset-2"
93
+ >
94
+ {mode === "login" ? "Create an account" : "Sign in"}
95
+ </button>
96
+ </p>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ // `ApiError` carries a stable `.code`, so branch on the code rather than the
102
+ // message — the copy here is ours, the message is the framework's.
103
+ function messageFor(err: unknown): string {
104
+ if (err instanceof ApiError) {
105
+ switch (err.code) {
106
+ case "INVALID_CREDENTIALS":
107
+ return "Wrong email or password.";
108
+ case "USER_EXISTS":
109
+ return "That email is already registered — sign in instead.";
110
+ case "WEAK_PASSWORD":
111
+ return "Pick a longer password — at least 10 characters.";
112
+ case "PWNED_PASSWORD":
113
+ return "That password has appeared in a known breach. Choose another.";
114
+ case "RATE_LIMITED":
115
+ return "Too many attempts — try again in a minute.";
116
+ default:
117
+ return err.message;
118
+ }
119
+ }
120
+ if (err instanceof Error) return err.message;
121
+ return "Something went wrong. Try again.";
122
+ }
@@ -0,0 +1,38 @@
1
+ import React from "react";
2
+ import type { Metadata, PageProps } from "@pylonsync/react";
3
+ import { AuthForm } from "./auth-form";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Sign in",
7
+ robots: "noindex",
8
+ };
9
+
10
+ export default function LoginPage({ auth, response }: PageProps) {
11
+ // Already signed in? Nothing to do here.
12
+ if (auth.user_id && !auth.user_id.startsWith("guest_")) {
13
+ response.redirect("/");
14
+ return null;
15
+ }
16
+
17
+ return (
18
+ <main className="flex min-h-screen items-center justify-center px-6">
19
+ <div className="w-full max-w-[320px]">
20
+ <div className="mb-7 flex items-center gap-2">
21
+ <span className="flex size-7 items-center justify-center rounded-md bg-primary text-[12px] font-bold text-primary-foreground">
22
+ C
23
+ </span>
24
+ <span className="text-[15px] font-semibold tracking-tight">CRM</span>
25
+ </div>
26
+ <h1 className="text-[19px] font-semibold tracking-tight">
27
+ Sign in to your workspace
28
+ </h1>
29
+ <p className="mt-1 text-[13px] text-muted-foreground">
30
+ Your team shares one pipeline. Anyone with an account sees it.
31
+ </p>
32
+ <div className="mt-6">
33
+ <AuthForm />
34
+ </div>
35
+ </div>
36
+ </main>
37
+ );
38
+ }
@@ -0,0 +1,29 @@
1
+ import React from "react";
2
+ import { Link, useRouter, type NotFoundProps } from "@pylonsync/react";
3
+ import { Button } from "@/components/ui/button";
4
+
5
+ // `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL (and when
6
+ // a page calls `response.notFound()`). It's HYDRATED, so it's interactive:
7
+ // the buttons below use the client router. Not-found boundaries receive the
8
+ // standard page props (and, matching Next, no `reset`).
9
+ export default function NotFound(_props: NotFoundProps) {
10
+ const router = useRouter();
11
+ return (
12
+ <div className="space-y-6">
13
+ <section>
14
+ <h1 className="text-2xl font-semibold tracking-tight">404</h1>
15
+ <p className="mt-2 text-muted-foreground">
16
+ We couldn&apos;t find that page.
17
+ </p>
18
+ </section>
19
+ <div className="flex items-center gap-3">
20
+ <Button onClick={() => router.back()} variant="outline">
21
+ ← Go back
22
+ </Button>
23
+ <Button asChild>
24
+ <Link href="/">Home</Link>
25
+ </Button>
26
+ </div>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,38 @@
1
+ import React, { use } from "react";
2
+ import type { Metadata, PageProps } from "@pylonsync/react";
3
+ import { InboxView } from "./inbox-view";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Inbox",
7
+ robots: "noindex",
8
+ };
9
+
10
+ /**
11
+ * `/` — the support queue.
12
+ *
13
+ * Server-side auth gate only. Reading `auth` opts this render out of caching,
14
+ * which is correct: every screen here 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 InboxPage({
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
+ <InboxView
33
+ email={me?.email ?? ""}
34
+ openNew={searchParams?.new === "ticket"}
35
+ initialFilter={searchParams?.filter}
36
+ />
37
+ );
38
+ }
@@ -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,23 @@
1
+ import React, { use } from "react";
2
+ import type { Metadata, PageProps } from "@pylonsync/react";
3
+ import { TicketView } from "./ticket-view";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Ticket",
7
+ robots: "noindex",
8
+ };
9
+
10
+ /** `app/tickets/[id]/page.tsx` -> `/tickets/:id`. */
11
+ export default function TicketPage({
12
+ auth,
13
+ response,
14
+ params,
15
+ serverData,
16
+ }: PageProps<{ id: string }>) {
17
+ if (!auth.user_id || auth.user_id.startsWith("guest_")) {
18
+ response.redirect("/login");
19
+ return null;
20
+ }
21
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
22
+ return <TicketView email={me?.email ?? ""} ticketId={params.id} />;
23
+ }
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { Link, callFn, useRouter } from "@pylonsync/react";
5
+ import { ArrowLeft } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Select } from "@/components/ui/select";
8
+ import { PageHeader } from "@/components/page-header";
9
+ import { EmptyState } from "@/components/empty-state";
10
+ import { Thread } from "@/components/thread";
11
+ import { PriorityBadge } from "@/components/priority-badge";
12
+ import { SlaIndicator } from "@/components/sla-indicator";
13
+ import { Avatar } from "@/components/avatar";
14
+ import { relativeTime } from "@/lib/format";
15
+ import { PRIORITIES, STATUSES, ticketNumber } from "@/lib/tickets";
16
+ import { Workspace } from "../../workspace";
17
+
18
+ export function TicketView({
19
+ email,
20
+ ticketId,
21
+ }: {
22
+ email: string;
23
+ ticketId: string;
24
+ }) {
25
+ const router = useRouter();
26
+
27
+ return (
28
+ <Workspace email={email} pathname="/">
29
+ {(data) => {
30
+ const ticket = data.tickets.find((t) => t.id === ticketId);
31
+
32
+ if (!ticket) {
33
+ return (
34
+ <>
35
+ <PageHeader title="Ticket" />
36
+ <EmptyState
37
+ title={data.loading ? "Loading…" : "Ticket not found"}
38
+ description={
39
+ data.loading
40
+ ? undefined
41
+ : "It may have been deleted, or the link is wrong."
42
+ }
43
+ action={
44
+ data.loading ? undefined : (
45
+ <Button size="sm" variant="secondary" asChild>
46
+ <Link href="/">Back to inbox</Link>
47
+ </Button>
48
+ )
49
+ }
50
+ />
51
+ </>
52
+ );
53
+ }
54
+
55
+ const customer = data.customers.find((c) => c.id === ticket.customerId);
56
+ const messages = data.messages.filter((m) => m.ticketId === ticket.id);
57
+
58
+ return (
59
+ <>
60
+ <PageHeader title={ticket.subject}>
61
+ <SlaIndicator ticket={ticket} />
62
+ <div className="w-28">
63
+ <Select
64
+ aria-label="Priority"
65
+ value={ticket.priority}
66
+ className="h-7 text-[12px]"
67
+ onChange={(event) =>
68
+ void callFn("setTicketState", {
69
+ ticketId: ticket.id,
70
+ priority: event.target.value,
71
+ })
72
+ }
73
+ >
74
+ {PRIORITIES.map((p) => (
75
+ <option key={p.id} value={p.id}>
76
+ {p.label}
77
+ </option>
78
+ ))}
79
+ </Select>
80
+ </div>
81
+ <div className="w-28">
82
+ <Select
83
+ aria-label="Status"
84
+ value={ticket.status}
85
+ className="h-7 text-[12px]"
86
+ onChange={(event) =>
87
+ void callFn("setTicketState", {
88
+ ticketId: ticket.id,
89
+ status: event.target.value,
90
+ })
91
+ }
92
+ >
93
+ {STATUSES.map((s) => (
94
+ <option key={s.id} value={s.id}>
95
+ {s.label}
96
+ </option>
97
+ ))}
98
+ </Select>
99
+ </div>
100
+ </PageHeader>
101
+
102
+ <div className="flex items-center gap-3 border-b border-border px-6 py-2.5 text-[12px] text-muted-foreground">
103
+ <Link
104
+ href="/"
105
+ className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
106
+ >
107
+ <ArrowLeft className="size-3.5" />
108
+ Inbox
109
+ </Link>
110
+ <span aria-hidden="true">·</span>
111
+ <span className="tabular">
112
+ {ticketNumber(ticket.id, ticket.createdAt)}
113
+ </span>
114
+ <span aria-hidden="true">·</span>
115
+ <PriorityBadge priority={ticket.priority} />
116
+ {customer ? (
117
+ <>
118
+ <span aria-hidden="true">·</span>
119
+ <span className="flex items-center gap-1.5">
120
+ <Avatar name={customer.name} size="sm" />
121
+ {customer.name}
122
+ {customer.company ? ` · ${customer.company}` : ""}
123
+ </span>
124
+ </>
125
+ ) : null}
126
+ <span aria-hidden="true">·</span>
127
+ <span>Opened {relativeTime(ticket.createdAt)}</span>
128
+
129
+ <div className="ml-auto w-40">
130
+ <Select
131
+ aria-label="Assignee"
132
+ value={ticket.assigneeId ?? ""}
133
+ className="h-7 text-[12px]"
134
+ onChange={(event) =>
135
+ void callFn("setTicketState", {
136
+ ticketId: ticket.id,
137
+ assigneeId: event.target.value,
138
+ })
139
+ }
140
+ >
141
+ <option value="">Unassigned</option>
142
+ {data.agents.map((agent) => (
143
+ <option key={agent.id} value={agent.id}>
144
+ {agent.displayName || agent.email}
145
+ </option>
146
+ ))}
147
+ </Select>
148
+ </div>
149
+ </div>
150
+
151
+ <Thread
152
+ messages={messages}
153
+ authorName={data.agentName}
154
+ customerName={customer?.name ?? null}
155
+ onSend={(body, internal) =>
156
+ callFn("replyToTicket", { ticketId: ticket.id, body, internal })
157
+ }
158
+ />
159
+ </>
160
+ );
161
+ }}
162
+ </Workspace>
163
+ );
164
+ }