@graph8/sdk 0.373.0 → 0.374.0

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 (279) hide show
  1. package/README.md +3 -3
  2. package/blocks/_foundation/README.md +47 -0
  3. package/blocks/_foundation/block.json +13 -0
  4. package/blocks/_foundation/components/auth-gate.tsx +363 -0
  5. package/blocks/_foundation/server/auth.ts +815 -0
  6. package/blocks/campaigns/README.md +1 -1
  7. package/blocks/campaigns/block.json +34 -1
  8. package/blocks/campaigns/components/campaign-links.tsx +180 -0
  9. package/blocks/company-intelligence/components/research-run.tsx +6 -2
  10. package/blocks/crm/README.md +19 -0
  11. package/blocks/crm/block.json +205 -6
  12. package/blocks/crm/components/bulk-update.tsx +119 -0
  13. package/blocks/crm/components/contact-documents-tab.tsx +144 -0
  14. package/blocks/crm/components/contact-insights-tab.tsx +118 -0
  15. package/blocks/crm/components/contact-lookup-link.tsx +47 -6
  16. package/blocks/crm/components/contact-row-actions.tsx +43 -0
  17. package/blocks/crm/components/contact-tag-chips.tsx +113 -0
  18. package/blocks/crm/components/contacts-screen.tsx +46 -5
  19. package/blocks/crm/components/owner-assign.tsx +132 -0
  20. package/blocks/crm/components/record-open-links.tsx +79 -0
  21. package/blocks/crm/components/tag-assign.tsx +112 -0
  22. package/blocks/crm/components/tag-manager.tsx +178 -0
  23. package/blocks/crm/components/unlock-info-button.tsx +82 -0
  24. package/blocks/crm/components/verify-email-button.tsx +93 -0
  25. package/blocks/crm/hooks/use-contact-tags.ts +29 -0
  26. package/blocks/crm/lib/record-actions.ts +102 -0
  27. package/blocks/deals/README.md +1 -0
  28. package/blocks/deals/block.json +17 -0
  29. package/blocks/deals/components/create-deal-button.tsx +109 -0
  30. package/blocks/deals/components/deal-detail.tsx +5 -3
  31. package/blocks/dialer/components/call-log-screen.tsx +6 -1
  32. package/blocks/documents/README.md +86 -0
  33. package/blocks/documents/block.json +378 -0
  34. package/blocks/documents/components/account-bar.tsx +48 -0
  35. package/blocks/documents/components/correspondence-form.tsx +95 -0
  36. package/blocks/documents/components/correspondence-import.tsx +106 -0
  37. package/blocks/documents/components/correspondence-panel.tsx +191 -0
  38. package/blocks/documents/components/documents-screen.tsx +76 -0
  39. package/blocks/documents/components/library-document-detail.tsx +175 -0
  40. package/blocks/documents/components/library-document-form.tsx +105 -0
  41. package/blocks/documents/components/library-folder-form.tsx +74 -0
  42. package/blocks/documents/components/library-panel.tsx +235 -0
  43. package/blocks/documents/components/library-upload.tsx +160 -0
  44. package/blocks/documents/components/question-row.tsx +130 -0
  45. package/blocks/documents/components/questionnaire-detail.tsx +197 -0
  46. package/blocks/documents/components/questionnaires-panel.tsx +173 -0
  47. package/blocks/documents/components/record-documents-tab.tsx +109 -0
  48. package/blocks/documents/lib/documents-types.ts +252 -0
  49. package/blocks/documents/lib/download.ts +64 -0
  50. package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
  51. package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
  52. package/blocks/documents/recipes/organize-the-library.ts +25 -0
  53. package/blocks/email-signatures/README.md +123 -0
  54. package/blocks/email-signatures/block.json +341 -0
  55. package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
  56. package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
  57. package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
  58. package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
  59. package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
  60. package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
  61. package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
  62. package/blocks/email-signatures/components/signature-policy.tsx +130 -0
  63. package/blocks/email-signatures/components/signature-preview.tsx +119 -0
  64. package/blocks/email-signatures/components/signature-report.tsx +77 -0
  65. package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
  66. package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
  67. package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
  68. package/blocks/email-signatures/lib/signatures-types.ts +392 -0
  69. package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
  70. package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
  71. package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
  72. package/blocks/enrichment/README.md +22 -4
  73. package/blocks/enrichment/block.json +145 -5
  74. package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
  75. package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
  76. package/blocks/enrichment/components/bulk-enrich-panel.tsx +7 -7
  77. package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
  78. package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
  79. package/blocks/enrichment/components/enrichment-screen.tsx +34 -3
  80. package/blocks/enrichment/components/list-pipelines.tsx +97 -0
  81. package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
  82. package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
  83. package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
  84. package/blocks/enrichment/lib/enrichment-types.ts +12 -0
  85. package/blocks/enrichment/lib/pipeline-types.ts +234 -0
  86. package/blocks/forms/README.md +2 -0
  87. package/blocks/forms/components/forms-screen.tsx +6 -3
  88. package/blocks/inbox/README.md +40 -4
  89. package/blocks/inbox/block.json +130 -8
  90. package/blocks/inbox/components/blocked-senders.tsx +103 -0
  91. package/blocks/inbox/components/compose-message.tsx +193 -0
  92. package/blocks/inbox/components/inbox-screen.tsx +54 -6
  93. package/blocks/inbox/components/inbox-search.tsx +106 -0
  94. package/blocks/inbox/components/tag-manager.tsx +136 -0
  95. package/blocks/inbox/components/thread-actions.tsx +133 -0
  96. package/blocks/inbox/components/thread-view.tsx +2 -0
  97. package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
  98. package/blocks/inbox/lib/inbox-manage.ts +227 -0
  99. package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
  100. package/blocks/pages/block.json +10 -1
  101. package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
  102. package/blocks/programmatic-pages/README.md +82 -0
  103. package/blocks/programmatic-pages/block.json +292 -0
  104. package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
  105. package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
  106. package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
  107. package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
  108. package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
  109. package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
  110. package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
  111. package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
  112. package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
  113. package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
  114. package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
  115. package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
  116. package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
  117. package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
  118. package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
  119. package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
  120. package/blocks/quotes/README.md +25 -1
  121. package/blocks/quotes/block.json +123 -6
  122. package/blocks/quotes/components/manual-products.tsx +144 -0
  123. package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
  124. package/blocks/quotes/components/quote-documents.tsx +94 -0
  125. package/blocks/quotes/components/quote-evidence.tsx +95 -0
  126. package/blocks/quotes/components/quote-list.tsx +23 -5
  127. package/blocks/quotes/components/quote-settings.tsx +184 -0
  128. package/blocks/quotes/components/quote-templates.tsx +189 -0
  129. package/blocks/quotes/components/quote-viewer.tsx +16 -3
  130. package/blocks/quotes/components/quotes-screen.tsx +15 -5
  131. package/blocks/quotes/lib/quote-admin.ts +264 -0
  132. package/blocks/sequencer/README.md +55 -0
  133. package/blocks/sequencer/block.json +172 -5
  134. package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
  135. package/blocks/sequencer/components/sequence-builder.tsx +13 -3
  136. package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
  137. package/blocks/sequencer/components/sequence-detail.tsx +6 -0
  138. package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
  139. package/blocks/sequencer/components/sequence-enrollments.tsx +2 -1
  140. package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
  141. package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
  142. package/blocks/sequencer/components/sequence-templates.tsx +160 -0
  143. package/blocks/sequencer/components/sequences-screen.tsx +36 -6
  144. package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
  145. package/blocks/sequencer/components/step-editor.tsx +10 -1
  146. package/blocks/sequencer/components/step-templates.tsx +224 -0
  147. package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
  148. package/blocks/sequencer/lib/sequencer-utils.ts +4 -0
  149. package/blocks/signals/README.md +39 -0
  150. package/blocks/signals/block.json +287 -0
  151. package/blocks/signals/components/auto-seed-panel.tsx +111 -0
  152. package/blocks/signals/components/listener-form.tsx +184 -0
  153. package/blocks/signals/components/matched-posts.tsx +195 -0
  154. package/blocks/signals/components/post-engagements.tsx +114 -0
  155. package/blocks/signals/components/record-social-tab.tsx +49 -0
  156. package/blocks/signals/components/social-listeners-screen.tsx +250 -0
  157. package/blocks/signals/components/social-settings.tsx +128 -0
  158. package/blocks/signals/components/source-lists.tsx +83 -0
  159. package/blocks/signals/components/top-engagers.tsx +68 -0
  160. package/blocks/signals/components/x-integration.tsx +129 -0
  161. package/blocks/signals/lib/signals-types.ts +228 -0
  162. package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
  163. package/blocks/signals/recipes/spend-against-caps.ts +14 -0
  164. package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
  165. package/blocks/visitors/components/traffic-chart.tsx +3 -2
  166. package/blocks/web-analytics/README.md +102 -0
  167. package/blocks/web-analytics/block.json +292 -0
  168. package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
  169. package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
  170. package/blocks/web-analytics/components/audience-tab.tsx +135 -0
  171. package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
  172. package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
  173. package/blocks/web-analytics/components/overview-tab.tsx +102 -0
  174. package/blocks/web-analytics/components/pages-tab.tsx +148 -0
  175. package/blocks/web-analytics/components/ranked-table.tsx +103 -0
  176. package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
  177. package/blocks/web-analytics/components/report-card.tsx +71 -0
  178. package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
  179. package/blocks/web-analytics/hooks/use-report.ts +48 -0
  180. package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
  181. package/blocks/web-analytics/lib/page-path.ts +14 -0
  182. package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
  183. package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
  184. package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
  185. package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
  186. package/blocks/workbench/README.md +54 -2
  187. package/blocks/workbench/block.json +234 -5
  188. package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
  189. package/blocks/workbench/components/workbench-automation.tsx +36 -0
  190. package/blocks/workbench/components/workbench-connections.tsx +136 -0
  191. package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
  192. package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
  193. package/blocks/workbench/components/workbench-grid.tsx +70 -7
  194. package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
  195. package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
  196. package/blocks/workbench/components/workbench-schedule.tsx +191 -0
  197. package/blocks/workbench/components/workbench-screen.tsx +50 -17
  198. package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
  199. package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
  200. package/blocks/workbench/components/workbench-triggers.tsx +178 -0
  201. package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
  202. package/blocks/workbench/lib/api-status.ts +8 -0
  203. package/blocks/workbench/lib/automation-types.ts +257 -0
  204. package/dist/cli.js +257 -51
  205. package/dist/index.browser.js +1 -1
  206. package/dist/index.browser.js.map +1 -1
  207. package/dist/index.browser.mjs +1 -1
  208. package/dist/index.browser.mjs.map +1 -1
  209. package/dist/index.js +1 -1
  210. package/dist/index.js.map +1 -1
  211. package/dist/index.mjs +1 -1
  212. package/dist/index.mjs.map +1 -1
  213. package/dist/react.browser.js +1 -1
  214. package/dist/react.browser.js.map +1 -1
  215. package/dist/react.browser.mjs +1 -1
  216. package/dist/react.browser.mjs.map +1 -1
  217. package/dist/react.js +1 -1
  218. package/dist/react.js.map +1 -1
  219. package/dist/react.mjs +1 -1
  220. package/dist/react.mjs.map +1 -1
  221. package/dist/types/blocks/auth.d.ts +45 -0
  222. package/dist/types/blocks/create.d.ts +5 -2
  223. package/dist/types/version.d.ts +2 -2
  224. package/docs/api/forms.md +1 -1
  225. package/package.json +1 -1
  226. package/stacks/6sense/pages/contacts.tsx.tmpl +2 -0
  227. package/stacks/6sense.json +20 -0
  228. package/stacks/apollo/pages/calls.tsx.tmpl +9 -9
  229. package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
  230. package/stacks/apollo/pages/contacts.tsx.tmpl +6 -0
  231. package/stacks/apollo/pages/inbox-contact.tsx.tmpl +3 -3
  232. package/stacks/apollo/pages/lists.tsx.tmpl +14 -2
  233. package/stacks/apollo/pages/sequences.tsx.tmpl +8 -2
  234. package/stacks/apollo.json +33 -2
  235. package/stacks/clay/pages/contacts.tsx.tmpl +2 -0
  236. package/stacks/clay/pages/lists.tsx.tmpl +14 -2
  237. package/stacks/gong/pages/calls.tsx.tmpl +9 -9
  238. package/stacks/gong/pages/contacts.tsx.tmpl +2 -0
  239. package/stacks/hootsuite/pages/contacts.tsx.tmpl +2 -0
  240. package/stacks/hootsuite.json +10 -0
  241. package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
  242. package/stacks/hubspot/pages/companies.tsx.tmpl +2 -0
  243. package/stacks/hubspot/pages/contacts.tsx.tmpl +6 -0
  244. package/stacks/hubspot/pages/deals.tsx.tmpl +4 -0
  245. package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
  246. package/stacks/hubspot/pages/inbox.tsx.tmpl +2 -2
  247. package/stacks/hubspot/pages/sequences.tsx.tmpl +8 -2
  248. package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
  249. package/stacks/hubspot.json +45 -2
  250. package/stacks/intercom/pages/inbox-contact.tsx.tmpl +3 -3
  251. package/stacks/intercom.json +10 -0
  252. package/stacks/mailchimp/pages/contacts.tsx.tmpl +2 -0
  253. package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
  254. package/stacks/mailchimp.json +2 -1
  255. package/stacks/outreach/pages/calls.tsx.tmpl +9 -9
  256. package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
  257. package/stacks/outreach/pages/contacts.tsx.tmpl +2 -0
  258. package/stacks/outreach/pages/inbox-contact.tsx.tmpl +3 -3
  259. package/stacks/outreach/pages/sequences.tsx.tmpl +8 -2
  260. package/stacks/outreach.json +13 -2
  261. package/stacks/pipedrive/pages/contacts.tsx.tmpl +2 -0
  262. package/stacks/pipedrive/pages/inbox.tsx.tmpl +2 -2
  263. package/stacks/pipedrive.json +10 -0
  264. package/stacks/salesforce/pages/companies.tsx.tmpl +2 -0
  265. package/stacks/salesforce/pages/contacts.tsx.tmpl +6 -0
  266. package/stacks/salesforce/pages/deals.tsx.tmpl +4 -0
  267. package/stacks/salesforce.json +10 -0
  268. package/stacks/zoominfo/pages/contacts.tsx.tmpl +4 -0
  269. package/stacks/zoominfo/pages/lists.tsx.tmpl +14 -2
  270. package/stacks/zoominfo.json +20 -0
  271. package/styles/styles.css +1 -1
  272. package/templates/auth/auth-route.ts.tmpl +16 -0
  273. package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
  274. package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
  275. package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
  276. package/templates/auth/g8.auth.password.ts.tmpl +18 -0
  277. package/templates/auth/g8.auth.session.ts.tmpl +23 -0
  278. package/templates/auth/middleware.ts.tmpl +14 -0
  279. package/templates/next-app/README.md.tmpl +5 -0
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SignatureReport: signature and campaign engagement: how many impressions and clicks each mailbox and each campaign got.
5
+ *
6
+ * SDK operations:
7
+ * 1. getSignatureReport (GET /signatures/reports) read counts by event kind, mailbox, campaign and source, plus the limits of the numbers
8
+ * Mailbox and campaign names come from the shared hooks. The numbers are only what the API can see: it says when click
9
+ * tracking is not configured, and lists its own limitations (for example that a natively installed signature reports no
10
+ * impressions); the screen shows them instead of presenting a zero as a measurement. Events are kept for the number of days
11
+ * the report states.
12
+ */
13
+
14
+ import { Skeleton } from "@graph8/sdk/ui";
15
+ import { ApiError } from "@/g8/components/api-error";
16
+ import { EmptyState } from "@/g8/components/empty-state";
17
+ import { SectionCard } from "@/g8/components/screen/section-card";
18
+ import { StatStrip } from "@/g8/components/screen/stat-strip";
19
+ import { humanizeStatus as humanize } from "@/g8/components/screen/status-pill";
20
+ import { g8Get } from "@/g8/lib/g8-client";
21
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
22
+ import { useSignatureCampaigns, useSignatureMailboxes } from "../hooks/use-signatures-data";
23
+ import type { ReportMetric, SignatureReport as Report } from "../lib/signatures-types";
24
+
25
+ function totals(metrics: ReportMetric[], by: (m: ReportMetric) => string): [string, Map<string, number>][] {
26
+ const out = new Map<string, Map<string, number>>();
27
+ for (const m of metrics) {
28
+ const key = by(m);
29
+ const row = out.get(key) ?? new Map<string, number>();
30
+ row.set(m.kind, (row.get(m.kind) ?? 0) + m.count);
31
+ out.set(key, row);
32
+ }
33
+ return [...out].sort((a, b) => a[0].localeCompare(b[0]));
34
+ }
35
+
36
+ export function SignatureReport() {
37
+ const report = useG8Query((signal) => g8Get<Report>("/signatures/reports", {}, { signal }).then((r) => r.data), [], { key: ["signatures", "report"] });
38
+ const box = useSignatureMailboxes();
39
+ const campaigns = useSignatureCampaigns();
40
+ if (report.error && !report.data) return <ApiError error={report.error} title="Could not load the report" onRetry={() => void report.refetch()} />;
41
+ if (!report.data) return <Skeleton className="h-40 w-full" aria-label="Loading the report" />;
42
+
43
+ const r = report.data;
44
+ const metrics = r.metrics ?? [];
45
+ const kinds = [...new Set(metrics.map((m) => m.kind))].sort();
46
+ const email = (id: number | null | undefined): string => (id == null ? "No mailbox" : box.mailboxes.find((m) => m.id === id)?.email || "A mailbox that is no longer listed");
47
+ const campaign = (id: string | null | undefined): string => (id ? campaigns.data?.find((c) => c.id === id)?.data.name || "A campaign that is no longer listed" : "No campaign");
48
+ const byMailbox = totals(metrics, (m) => email(m.mailbox_id));
49
+ const byCampaign = totals(metrics.filter((m) => m.campaign_id), (m) => campaign(m.campaign_id));
50
+ const grid = (rows: [string, Map<string, number>][], first: string) => (
51
+ <table className="w-full text-sm">
52
+ <thead><tr className="text-muted-foreground text-left text-xs"><th className="py-1 font-normal">{first}</th>{kinds.map((k) => <th key={k} className="py-1 text-right font-normal">{humanize(k)}</th>)}</tr></thead>
53
+ <tbody>{rows.map(([name, counts]) => <tr key={name} className="border-t"><td className="py-1.5">{name}</td>{kinds.map((k) => <td key={k} className="py-1.5 text-right tabular-nums">{(counts.get(k) ?? 0).toLocaleString()}</td>)}</tr>)}</tbody>
54
+ </table>
55
+ );
56
+
57
+ return (
58
+ <div className="grid gap-4">
59
+ {!r.tracking_available ? <p role="note" className="rounded-md border p-3 text-sm">Click tracking is not set up for this workspace, so clicks are not counted. Impressions that the API can see still are.</p> : null}
60
+ {metrics.length === 0 ? (
61
+ <EmptyState title="No signature activity yet" description={`Impressions and clicks appear once managed signatures go out. Events are kept for ${r.retention_days ?? "a limited number of"} days.`} />
62
+ ) : (
63
+ <>
64
+ <StatStrip items={kinds.map((k) => ({ label: humanize(k), value: metrics.filter((m) => m.kind === k).reduce((n, m) => n + m.count, 0).toLocaleString() }))} />
65
+ <SectionCard title="By mailbox">{grid(byMailbox, "Mailbox")}</SectionCard>
66
+ {byCampaign.length > 0 ? <SectionCard title="By campaign">{grid(byCampaign, "Campaign")}</SectionCard> : null}
67
+ </>
68
+ )}
69
+ {(r.limitations ?? []).length > 0 ? (
70
+ <SectionCard title="How to read these numbers">
71
+ <ul className="text-muted-foreground list-disc space-y-1 pl-5 text-sm">{(r.limitations ?? []).map((l) => <li key={l}>{l}</li>)}</ul>
72
+ {r.retention_days ? <p className="text-muted-foreground mt-2 text-xs">Events are kept for {r.retention_days} days.</p> : null}
73
+ </SectionCard>
74
+ ) : null}
75
+ </div>
76
+ );
77
+ }
@@ -0,0 +1,112 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SignatureTemplateDetails: one template's facts and its two consequential actions: make it the organisation default,
5
+ * and archive it.
6
+ *
7
+ * SDK operations, in order (policy, member and mailbox reads come from the shared hooks):
8
+ * 1. setDefaultSignatureTemplate (POST /signatures/templates/{template_id}/set-default) write sends the POLICY version (not the template's);
9
+ * it changes the signature of every mailbox that follows the organisation default, so ConfirmSpendDialog (external) first, sized from those mailboxes.
10
+ * 2. archiveSignatureTemplate (DELETE /signatures/templates/{template_id}?version) destructive hides the template from the library; ConfirmSpendDialog (destructive) first.
11
+ * The API refuses (409) while the template is the default, a department default or assigned to a mailbox, so the button
12
+ * is disabled with that reason when the screen can already see it; already-installed signatures are untouched.
13
+ * Both send the version that was read, and a stale one answers 409 (shown, with the lists refreshed).
14
+ */
15
+
16
+ import { useState } from "react";
17
+ import { Archive, Star } from "lucide-react";
18
+ import { Button } from "@graph8/sdk/ui";
19
+ import { ApiError } from "@/g8/components/api-error";
20
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
21
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
22
+ import { StatusPill } from "@/g8/components/screen/status-pill";
23
+ import { g8Delete, g8Post } from "@/g8/lib/g8-client";
24
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
25
+ import { plural } from "@/g8/lib/plural";
26
+ import { useSignatureDeployments, useSignatureMembers, useSignaturePolicy } from "../hooks/use-signatures-data";
27
+ import { BLOCK_KINDS, BRAND_SOURCES, LAYOUTS, isConflict, isManaged, templateName } from "../lib/signatures-types";
28
+ import type { SignatureTemplate } from "../lib/signatures-types";
29
+
30
+ export interface SignatureTemplateDetailsProps {
31
+ template: SignatureTemplate;
32
+ /** Called after the template was archived, so the screen can close it. */
33
+ onArchived?: () => void;
34
+ }
35
+
36
+ export function SignatureTemplateDetails({ template, onArchived }: SignatureTemplateDetailsProps) {
37
+ const policy = useSignaturePolicy();
38
+ const members = useSignatureMembers();
39
+ const deployments = useSignatureDeployments();
40
+ const [dialog, setDialog] = useState<"default" | "archive" | null>(null);
41
+ const name = templateName(template);
42
+ const settings = policy.data?.data;
43
+ const isDefault = settings?.default_template_id === template.id;
44
+ const departments = Object.entries(settings?.department_templates ?? {}).filter(([, id]) => id === template.id).map(([d]) => d);
45
+ const rows = (deployments.data ?? []).filter((d) => isManaged(d));
46
+ const assignedTo = rows.filter((d) => d.assignment?.template_id === template.id).length;
47
+ const followsDefault = rows.filter((d) => {
48
+ if (d.assignment?.template_id) return false;
49
+ const dept = members.data?.find((m) => m.id === d.assignment?.member_id)?.department;
50
+ return !(dept && settings?.department_templates?.[dept]);
51
+ }).length;
52
+ const blockedBy = isDefault ? "It is the organisation default." : departments.length > 0 ? `It is the default for ${departments.join(", ")}.` : assignedTo > 0 ? `${plural(assignedTo, "mailbox")} use it.` : "";
53
+
54
+ const setDefault = useG8Mutation(async (): Promise<unknown> => (await g8Post(`/signatures/templates/${encodeURIComponent(template.id)}/set-default`, { version: policy.data?.version ?? 0 })).data, {
55
+ onSuccess: () => setDialog(null),
56
+ onError: () => { setDialog(null); if (policy.data) void policy.refetch(); },
57
+ invalidates: [["signatures", "policy"], ["signatures", "deployments"]],
58
+ });
59
+ const archive = useG8Mutation(async (): Promise<unknown> => (await g8Delete(`/signatures/templates/${encodeURIComponent(template.id)}`, { query: { version: template.version } })).data, {
60
+ onSuccess: () => { setDialog(null); onArchived?.(); },
61
+ onError: () => setDialog(null),
62
+ invalidates: [["signatures", "templates"], ["signatures", "template", template.id]],
63
+ });
64
+
65
+ return (
66
+ <div className="grid gap-4">
67
+ <KeyValueList columns={2} items={[
68
+ { label: "Name", value: name },
69
+ { label: "Layout", value: LAYOUTS.find((l) => l.value === template.data.layout)?.label ?? "" },
70
+ { label: "Font", value: template.data.font ?? "" },
71
+ { label: "Text colour", value: template.data.primary_color ?? "", mono: true },
72
+ { label: "Colour and font", value: BRAND_SOURCES.find((b) => b.value === template.data.brand_source)?.label ?? "", wide: true },
73
+ { label: "Blocks", value: (template.data.blocks ?? []).map((b) => BLOCK_KINDS.find((k) => k.value === b.kind)?.label ?? b.kind).join(", "), wide: true },
74
+ { label: "Version", value: String(template.version) },
75
+ { label: "Used by", value: `${plural(assignedTo, "mailbox")} directly` },
76
+ ]} />
77
+ <div className="flex flex-wrap items-center gap-2">
78
+ {isDefault ? <StatusPill tone="positive">Organisation default</StatusPill> : null}
79
+ {departments.map((d) => <StatusPill key={d} tone="info">Default for {d}</StatusPill>)}
80
+ </div>
81
+ {setDefault.error ? (isConflict(setDefault.error) ? <ApiError error={setDefault.error} title="The policy changed since you opened it. It was refreshed: try again" /> : <ApiError error={setDefault.error} title="Could not set the default" />) : null}
82
+ {archive.error ? (isConflict(archive.error) ? <ApiError error={archive.error} title="This template cannot be archived right now" /> : <ApiError error={archive.error} title="Could not archive the template" />) : null}
83
+ <div className="flex flex-wrap gap-2">
84
+ <Button type="button" variant="outline" disabled={isDefault || !policy.data} onClick={() => setDialog("default")}><Star className="size-4" />{isDefault ? "Already the default" : "Make organisation default"}</Button>
85
+ <Button type="button" variant="outline" disabled={blockedBy !== ""} onClick={() => setDialog("archive")}><Archive className="size-4" />Archive</Button>
86
+ </div>
87
+ {blockedBy ? <p className="text-muted-foreground text-xs">Archive is unavailable: {blockedBy} Give those mailboxes another template first.</p> : null}
88
+ <ConfirmSpendDialog
89
+ open={dialog === "default"}
90
+ tier="external"
91
+ title={`Make ${name} the organisation default?`}
92
+ affected={followsDefault}
93
+ description={<p>{plural(followsDefault, "mailbox")} that follow the organisation default will send this signature. Mailboxes with their own template, or a department default, are not changed.</p>}
94
+ confirmLabel="Make default"
95
+ confirming={setDefault.loading}
96
+ onCancel={() => setDialog(null)}
97
+ onConfirm={() => void setDefault.mutate(undefined)}
98
+ />
99
+ <ConfirmSpendDialog
100
+ open={dialog === "archive"}
101
+ tier="destructive"
102
+ title={`Archive ${name}?`}
103
+ affected={1}
104
+ description="The template leaves the library. Signatures already installed from it are not changed."
105
+ confirmLabel="Archive"
106
+ confirming={archive.loading}
107
+ onCancel={() => setDialog(null)}
108
+ onConfirm={() => void archive.mutate(undefined)}
109
+ />
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SignatureTemplateForm: create a signature template or edit one, with a live preview of the draft.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. getSignatureTemplate (GET /signatures/templates/{template_id}) read edit only: the FULL record is read before the form shows
8
+ * 2. createSignatureTemplate (POST /signatures/templates) write a template library entry; nothing reaches a mailbox until it is assigned
9
+ * 3. updateSignatureTemplate (PATCH /signatures/templates/{template_id}) write REPLACES the template with the draft and sends the version that was read
10
+ * The update replaces the whole template, so the draft is built from the loaded record (known keys only, because the API
11
+ * refuses any other) and a field the person clears is sent empty. A save with no change sends nothing. A 409 means someone else
12
+ * saved first: the person reloads and edits the newer version. After a save the template list and the template's own cache
13
+ * entry are refreshed, so reopening the form shows what was saved. The live preview (TemplatePreview) only renders.
14
+ */
15
+
16
+ import { useState } from "react";
17
+ import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
18
+ import { ApiError } from "@/g8/components/api-error";
19
+ import { g8Patch, g8Post } from "@/g8/lib/g8-client";
20
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
21
+ import { useSignatureTemplate } from "../hooks/use-signatures-data";
22
+ import { BRAND_SOURCES, FONTS, LAYOUTS, blankTemplate, draftFromTemplate, isConflict, sameTemplate, templateProblems } from "../lib/signatures-types";
23
+ import type { BrandSource, SignatureFont, SignatureLayout, SignatureTemplate, TemplateData } from "../lib/signatures-types";
24
+ import { SignatureBlockEditor } from "./signature-block-editor";
25
+ import { TemplatePreview } from "./signature-preview";
26
+
27
+ export interface SignatureTemplateFormProps {
28
+ /** Edit this template; omit to create a new one. */
29
+ template?: SignatureTemplate;
30
+ onSaved: (template: SignatureTemplate) => void;
31
+ onCancel?: () => void;
32
+ }
33
+
34
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm disabled:opacity-50";
35
+
36
+ /** Edit mode reads the full template first: saving a form seeded from a list row or a half-loaded record would overwrite real values. */
37
+ export function SignatureTemplateForm({ template, onSaved, onCancel }: SignatureTemplateFormProps) {
38
+ const full = useSignatureTemplate(template?.id);
39
+ if (!template) return <TemplateFields onSaved={onSaved} onCancel={onCancel} />;
40
+ if (full.error && !full.data) return <ApiError error={full.error} title="Could not load this template" onRetry={() => void full.refetch()} />;
41
+ if (!full.data || full.data.id !== template.id) return <Skeleton className="h-40 w-full" aria-label="Loading template" />;
42
+ return <TemplateFields key={`${full.data.id}:${full.data.version}`} template={full.data} onSaved={onSaved} onCancel={onCancel} onReload={() => void full.refetch()} />;
43
+ }
44
+
45
+ function TemplateFields({ template, onSaved, onCancel, onReload }: SignatureTemplateFormProps & { onReload?: () => void }) {
46
+ const original = template ? draftFromTemplate(template) : blankTemplate();
47
+ const [draft, setDraft] = useState<TemplateData>(original);
48
+ const problems = templateProblems(draft);
49
+ const changed = !sameTemplate(draft, original);
50
+ const set = (change: Partial<TemplateData>): void => setDraft({ ...draft, ...change });
51
+
52
+ const save = useG8Mutation(async (): Promise<SignatureTemplate> => {
53
+ if (template) {
54
+ if (!changed) return template;
55
+ return (await g8Patch<SignatureTemplate>(`/signatures/templates/${encodeURIComponent(template.id)}`, { version: template.version, data: draft })).data;
56
+ }
57
+ return (await g8Post<SignatureTemplate>("/signatures/templates", { data: draft })).data;
58
+ }, { onSuccess: onSaved, invalidates: (saved) => [["signatures", "templates"], ["signatures", "template", saved.id]] });
59
+
60
+ return (
61
+ <form className="grid gap-4" onSubmit={(e) => { e.preventDefault(); if (problems.length === 0) void save.mutate(undefined); }}>
62
+ <div><Label htmlFor="g8-sig-name" className="mb-1">Template name</Label><Input id="g8-sig-name" value={draft.name} placeholder="Sales team" onChange={(e) => set({ name: e.target.value })} /></div>
63
+ <div className="grid gap-3 sm:grid-cols-3">
64
+ <div>
65
+ <Label htmlFor="g8-sig-layout" className="mb-1">Layout</Label>
66
+ <select id="g8-sig-layout" className={SELECT} value={draft.layout} onChange={(e) => set({ layout: e.target.value as SignatureLayout })}>
67
+ {LAYOUTS.map((l) => <option key={l.value} value={l.value}>{l.label}</option>)}
68
+ </select>
69
+ </div>
70
+ <div>
71
+ <Label htmlFor="g8-sig-font" className="mb-1">Font</Label>
72
+ <select id="g8-sig-font" className={SELECT} value={draft.font} onChange={(e) => set({ font: e.target.value as SignatureFont })}>
73
+ {FONTS.map((f) => <option key={f} value={f}>{f}</option>)}
74
+ </select>
75
+ </div>
76
+ <div>
77
+ <Label htmlFor="g8-sig-color" className="mb-1">Text colour</Label>
78
+ <input id="g8-sig-color" type="color" className="border-input h-9 w-full cursor-pointer rounded-md border bg-transparent p-1" value={draft.primary_color} onChange={(e) => set({ primary_color: e.target.value })} />
79
+ </div>
80
+ </div>
81
+ <div>
82
+ <Label htmlFor="g8-sig-brand" className="mb-1">Colour and font source</Label>
83
+ <select id="g8-sig-brand" className={SELECT} value={draft.brand_source} onChange={(e) => set({ brand_source: e.target.value as BrandSource })}>
84
+ {BRAND_SOURCES.map((b) => <option key={b.value} value={b.value}>{b.label}</option>)}
85
+ </select>
86
+ </div>
87
+ <section aria-label="Blocks" className="grid gap-2">
88
+ <h3 className="text-sm font-medium">Blocks</h3>
89
+ <p className="text-muted-foreground text-xs">Placeholders such as {"{{member.name}}"} are filled from each employee. Add a fallback after a bar, {"{{member.title|Team}}"}, so a missing value never fails the signature.</p>
90
+ <SignatureBlockEditor blocks={draft.blocks} onChange={(blocks) => set({ blocks })} />
91
+ </section>
92
+ <section aria-label="Preview" className="grid gap-2">
93
+ <h3 className="text-sm font-medium">Preview</h3>
94
+ <TemplatePreview draft={draft} draftValid={problems.length === 0} />
95
+ </section>
96
+ {problems.length > 0 ? <ul role="alert" className="text-error list-disc pl-5 text-xs">{problems.map((p) => <li key={p}>{p}</li>)}</ul> : null}
97
+ {save.error && isConflict(save.error) ? (
98
+ <div className="grid gap-2">
99
+ <ApiError error={save.error} title="Someone else changed this template" />
100
+ {onReload ? <div><Button type="button" variant="outline" size="sm" onClick={onReload}>Load the newer version</Button></div> : null}
101
+ </div>
102
+ ) : save.error ? <ApiError error={save.error} title="Could not save the template" /> : null}
103
+ <div className="flex gap-2">
104
+ <Button type="submit" loading={save.loading} disabled={problems.length > 0 || (Boolean(template) && !changed)}>{template ? "Save changes" : "Create template"}</Button>
105
+ {onCancel ? <Button type="button" variant="outline" onClick={onCancel}>Cancel</Button> : null}
106
+ </div>
107
+ </form>
108
+ );
109
+ }
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Shared reads for the email-signatures block. Every screen part asks through these hooks, so one request serves them all
5
+ * (the cache key names the input) and a write refreshes every part that shows the record by invalidating the same key.
6
+ *
7
+ * SDK operations, all reads:
8
+ * listSignatureTemplates GET /signatures/templates every template; the API does not page it, so the UI says "All N"
9
+ * getSignatureTemplate GET /signatures/templates/{template_id} the FULL record an edit form is seeded from
10
+ * listSignatureMembers GET /signatures/members the directory employees a signature can be assigned to
11
+ * listSignatureDeployments GET /signatures/mailboxes each mailbox's assignment, status and revision
12
+ * getSignatureSettings GET /signatures/settings the policy and the version a policy write must send back
13
+ * listSignatureCampaigns GET /signatures/campaigns banner campaigns and the revision to edit one
14
+ * listMailboxes GET /mailboxes?limit=500 the mailboxes a signature can be assigned to (the API caps one read at 500)
15
+ *
16
+ * The signature lists take no paging parameters and return everything. The mailbox list is read once with the API's
17
+ * largest limit: a screen that renders it must show the foundation FirstNotice ("Showing the first 500 mailboxes") when
18
+ * `truncated` is true, so a long list never ends silently.
19
+ */
20
+
21
+ import { g8Get } from "@/g8/lib/g8-client";
22
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
23
+ import { mergeMailboxes } from "../lib/signatures-types";
24
+ import type { Deployment, MailboxRecord, SignatureCampaign, SignatureMailbox, SignatureMember, SignaturePolicy, SignatureTemplate } from "../lib/signatures-types";
25
+
26
+ export const MAILBOX_LIMIT = 500;
27
+
28
+ function rows<T>(envelope: { data?: unknown } | null | undefined): T[] {
29
+ const data = envelope?.data;
30
+ if (Array.isArray(data)) return data as T[];
31
+ const items = (data as { items?: unknown } | null | undefined)?.items;
32
+ return Array.isArray(items) ? (items as T[]) : [];
33
+ }
34
+
35
+ export function useSignatureTemplates() {
36
+ return useG8Query((signal) => g8Get("/signatures/templates", {}, { signal }).then((r) => rows<SignatureTemplate>(r)), [], { key: ["signatures", "templates"] });
37
+ }
38
+
39
+ /** The full template. `enabled` false holds the read until an id is known. */
40
+ export function useSignatureTemplate(id: string | undefined) {
41
+ return useG8Query(
42
+ (signal) => g8Get<SignatureTemplate>(`/signatures/templates/${encodeURIComponent(id ?? "")}`, {}, { signal }).then((r) => r.data),
43
+ [id],
44
+ { enabled: Boolean(id), key: ["signatures", "template", id ?? ""] },
45
+ );
46
+ }
47
+
48
+ export function useSignatureMembers() {
49
+ return useG8Query((signal) => g8Get("/signatures/members", {}, { signal }).then((r) => rows<SignatureMember>(r)), [], { key: ["signatures", "members"] });
50
+ }
51
+
52
+ export function useSignatureDeployments() {
53
+ return useG8Query((signal) => g8Get("/signatures/mailboxes", {}, { signal }).then((r) => rows<Deployment>(r)), [], { key: ["signatures", "deployments"] });
54
+ }
55
+
56
+ export function useSignaturePolicy() {
57
+ return useG8Query((signal) => g8Get<SignaturePolicy>("/signatures/settings", {}, { signal }).then((r) => r.data), [], { key: ["signatures", "policy"] });
58
+ }
59
+
60
+ export function useSignatureCampaigns() {
61
+ return useG8Query((signal) => g8Get("/signatures/campaigns", {}, { signal }).then((r) => rows<SignatureCampaign>(r)), [], { key: ["signatures", "campaigns"] });
62
+ }
63
+
64
+ export interface SignatureMailboxes {
65
+ mailboxes: SignatureMailbox[];
66
+ loading: boolean;
67
+ /** Set when the deployments read failed (nothing can be shown). */
68
+ error: unknown;
69
+ /** Set when only the mailbox list failed (for example a key without mailbox access): mailboxes that already have a signature still show. */
70
+ mailboxesError: unknown;
71
+ /** The mailbox read came back full (500): there may be more mailboxes than are listed. */
72
+ truncated: boolean;
73
+ refetch: () => void;
74
+ }
75
+
76
+ /** Every mailbox with its signature deployment: the list of mailboxes, merged with the deployments by mailbox id. */
77
+ export function useSignatureMailboxes(): SignatureMailboxes {
78
+ const deployments = useSignatureDeployments();
79
+ const list = useG8Query(
80
+ (signal) => g8Get("/mailboxes", { limit: MAILBOX_LIMIT }, { signal }).then((r) => rows<MailboxRecord>(r)),
81
+ [],
82
+ { key: ["signatures", "mailbox-list"] },
83
+ );
84
+ const records = list.data ?? [];
85
+ return {
86
+ mailboxes: mergeMailboxes(records, deployments.data ?? []),
87
+ loading: (deployments.loading && !deployments.data) || (list.loading && !list.data && !list.error),
88
+ error: deployments.error && !deployments.data ? deployments.error : undefined,
89
+ mailboxesError: list.error && !list.data ? list.error : undefined,
90
+ truncated: records.length >= MAILBOX_LIMIT,
91
+ refetch: () => { void deployments.refetch(); void list.refetch(); },
92
+ };
93
+ }