@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,166 @@
1
+ "use client";
2
+
3
+ /**
4
+ * EmailSignaturesScreen: the finished workspace for managed email signatures. No props needed.
5
+ *
6
+ * Composes: ScreenLayout (title, tabs, stats), Toolbar (search), SplitView + DetailPanel (the selected template, deep link
7
+ * `?id=`), FormSheet (new template via SignatureTemplateForm), TemplatePreview, SignatureAssignTemplate,
8
+ * SignatureTemplateDetails, SignatureMailboxes, SignatureCampaigns, SignaturePolicy and SignatureReport.
9
+ *
10
+ * Tabs (`?tab=`): Templates (list and detail), Mailboxes, Campaigns, Policy, Report. A template's detail (`?id=`) has Preview,
11
+ * Edit, Use on mailboxes and Details; `&view=full` shows it as a full page.
12
+ *
13
+ * User flows: see every template (search; the list is complete because the API does not page it); create one with blocks,
14
+ * placeholders and a live preview; open it and preview it as a real employee on a real mailbox, in full, compact or campaign
15
+ * form, or as plain text; edit it (sends only after the full record has loaded; a stale save is refused and explained);
16
+ * apply it to chosen mailboxes, a department or everyone (confirmed with the real number of mailboxes); make it the
17
+ * organisation default or archive it (both confirmed). On the Mailboxes tab: assign an employee to a mailbox (check the setup
18
+ * first), push, restore the old signature and change what a mailbox sends, one by one or in bulk, each confirmed. Campaigns
19
+ * add a banner for a time window and can be previewed and tested against a mailbox and recipients. The Policy tab turns
20
+ * management off, on or enforced, sets the default and department templates. The Report tab reads impressions and clicks.
21
+ * The API has no variables list, template delete (only archive), campaign delete, asset upload or employee self-service
22
+ * (see the README for the gaps).
23
+ *
24
+ * SDK operations: none directly. Every call lives in the composed parts, which confirm every change to what a mailbox sends.
25
+ */
26
+
27
+ import { useState } from "react";
28
+ import type { ReactNode } from "react";
29
+ import { PenLine, Plus } from "lucide-react";
30
+ import { Button, Skeleton } from "@graph8/sdk/ui";
31
+ import { ApiError } from "@/g8/components/api-error";
32
+ import { EmptyState } from "@/g8/components/empty-state";
33
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
34
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
35
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
36
+ import { SplitView } from "@/g8/components/screen/split-view";
37
+ import { StatusPill } from "@/g8/components/screen/status-pill";
38
+ import { Toolbar } from "@/g8/components/screen/toolbar";
39
+ import { useUrlState } from "@/g8/hooks/use-url-state";
40
+ import { plural } from "@/g8/lib/plural";
41
+ import { useSignatureDeployments, useSignaturePolicy, useSignatureTemplates } from "../hooks/use-signatures-data";
42
+ import { LAYOUTS, POLICY_MODES, isManaged, templateName } from "../lib/signatures-types";
43
+ import type { SignatureTemplate } from "../lib/signatures-types";
44
+ import { SignatureAssignTemplate } from "./signature-assign-template";
45
+ import { SignatureCampaigns } from "./signature-campaigns";
46
+ import { SignatureMailboxes } from "./signature-mailboxes";
47
+ import { SignaturePolicy } from "./signature-policy";
48
+ import { SignatureReport } from "./signature-report";
49
+ import { SignatureTemplateDetails } from "./signature-template-details";
50
+ import { SignatureTemplateForm } from "./signature-template-form";
51
+ import { TemplatePreview } from "./signature-preview";
52
+
53
+ export interface EmailSignaturesScreenProps {
54
+ renderRowActions?: (template: SignatureTemplate) => ReactNode;
55
+ renderDetailActions?: (template: SignatureTemplate) => ReactNode;
56
+ extraDetailTabs?: { id: string; label: string; render: (template: SignatureTemplate) => ReactNode }[];
57
+ toolbarActions?: ReactNode;
58
+ className?: string;
59
+ }
60
+
61
+ type Tab = "templates" | "mailboxes" | "campaigns" | "policy" | "report";
62
+ const TABS: Tab[] = ["templates", "mailboxes", "campaigns", "policy", "report"];
63
+
64
+ export function EmailSignaturesScreen({ renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, className }: EmailSignaturesScreenProps) {
65
+ const [id, setId] = useUrlState("id", "");
66
+ const [view, setView] = useUrlState("view", "");
67
+ const [tabParam, setTabParam] = useUrlState("tab", "templates");
68
+ const tab = (TABS.includes(tabParam as Tab) ? tabParam : "templates") as Tab;
69
+ const [search, setSearch] = useState("");
70
+ const [creating, setCreating] = useState(false);
71
+ const [savedVersion, setSavedVersion] = useState<{ id: string; version: number } | null>(null);
72
+ const list = useSignatureTemplates();
73
+ const deployments = useSignatureDeployments();
74
+ const policy = useSignaturePolicy();
75
+
76
+ const all = list.data ?? [];
77
+ const q = search.trim().toLowerCase();
78
+ const shown = all.filter((t) => !q || templateName(t).toLowerCase().includes(q));
79
+ const selected = all.find((t) => t.id === id);
80
+ const defaultId = policy.data?.data.default_template_id;
81
+ const closeDetail = (): void => { setId(null); setView(null); };
82
+ const rows = deployments.data ?? [];
83
+
84
+ const listBody = list.error && !list.data ? <ApiError error={list.error} title="Could not load templates" onRetry={() => void list.refetch()} /> : !list.data ? (
85
+ <div aria-busy="true" className="grid gap-2"><Skeleton className="h-14 w-full" /><Skeleton className="h-14 w-full" /><Skeleton className="h-14 w-full" /></div>
86
+ ) : shown.length === 0 ? (
87
+ <EmptyState icon={<PenLine className="size-8" />} title={all.length === 0 ? "No signature templates yet" : "No templates match"} description={all.length === 0 ? "A template is the shared design of your team's email signatures. Create one, then assign it to mailboxes." : "Try another search."} action={all.length === 0 ? <Button onClick={() => setCreating(true)}>Create your first template</Button> : undefined} />
88
+ ) : (
89
+ <>
90
+ <p role="status" className="text-muted-foreground mb-2 text-xs">{shown.length === all.length ? `All ${plural(all.length, "template")}` : `${shown.length.toLocaleString()} of ${plural(all.length, "template")}`}</p>
91
+ <ul className="grid gap-2">
92
+ {shown.map((t) => (
93
+ <li key={t.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 ${t.id === id ? "bg-muted" : ""}`}>
94
+ <button type="button" className="min-w-0 flex-1 text-left" onClick={() => setId(t.id)}>
95
+ <span className="block truncate font-medium">{templateName(t)}</span>
96
+ <span className="text-muted-foreground block truncate text-xs">{LAYOUTS.find((l) => l.value === t.data?.layout)?.label ?? "Layout unknown"} · {plural((t.data?.blocks ?? []).length, "block")} · version {t.version}</span>
97
+ </button>
98
+ {t.id === defaultId ? <StatusPill tone="positive">Default</StatusPill> : null}
99
+ {renderRowActions?.(t)}
100
+ </li>
101
+ ))}
102
+ </ul>
103
+ </>
104
+ );
105
+
106
+ const detail = selected ? (
107
+ <DetailPanel
108
+ title={templateName(selected)}
109
+ subtitle={LAYOUTS.find((l) => l.value === selected.data?.layout)?.label}
110
+ badges={selected.id === defaultId ? <StatusPill tone="positive">Organisation default</StatusPill> : undefined}
111
+ actions={renderDetailActions?.(selected)}
112
+ onClose={closeDetail}
113
+ onExpand={() => setView("full")}
114
+ onCollapse={view === "full" ? () => setView(null) : undefined}
115
+ tabs={[
116
+ { id: "preview", label: "Preview", content: <TemplatePreview key={`${selected.id}:${selected.version}`} templateId={selected.id} /> },
117
+ { id: "edit", label: "Edit", content: (
118
+ <div className="grid gap-3">
119
+ {savedVersion?.id === selected.id && savedVersion.version === selected.version ? <p role="status" className="text-sm">Template saved as version {selected.version}. To install it again on the mailboxes that use it, push them from the Mailboxes tab.</p> : null}
120
+ <SignatureTemplateForm key={selected.id} template={selected} onSaved={(saved) => setSavedVersion({ id: saved.id, version: saved.version })} />
121
+ </div>
122
+ ) },
123
+ { id: "use", label: "Use on mailboxes", content: <SignatureAssignTemplate key={selected.id} template={selected} /> },
124
+ { id: "details", label: "Details", content: <SignatureTemplateDetails template={selected} onArchived={closeDetail} /> },
125
+ ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
126
+ ]}
127
+ />
128
+ ) : null;
129
+
130
+ const templatesTab = (
131
+ <SplitView
132
+ list={<div className="grid gap-3"><Toolbar search={{ value: search, onChange: setSearch, placeholder: "Search templates" }} actions={toolbarActions} />{listBody}</div>}
133
+ detail={detail}
134
+ detailOpen={Boolean(selected)}
135
+ onCloseDetail={closeDetail}
136
+ expanded={view === "full"}
137
+ onExpandedChange={(e) => setView(e ? "full" : null)}
138
+ detailWidth="xl"
139
+ />
140
+ );
141
+
142
+ const mode = POLICY_MODES.find((m) => m.value === policy.data?.data.mode)?.label;
143
+ return (
144
+ <ScreenLayout
145
+ className={className}
146
+ title="Email signatures"
147
+ description="Design one signature for the team and keep every mailbox on it."
148
+ icon={<PenLine className="size-5" />}
149
+ actions={<Button size="sm" onClick={() => { setTabParam(null); setCreating(true); }}><Plus className="size-4" />New template</Button>}
150
+ tabs={[{ id: "templates", label: "Templates", count: list.data ? all.length : undefined }, { id: "mailboxes", label: "Mailboxes" }, { id: "campaigns", label: "Campaigns" }, { id: "policy", label: "Policy" }, { id: "report", label: "Report" }]}
151
+ activeTab={tab}
152
+ onTabChange={(next) => { setTabParam(next === "templates" ? null : next); closeDetail(); }}
153
+ stats={tab === "templates" ? [
154
+ { label: "Templates", value: list.data ? all.length.toLocaleString() : "..." },
155
+ { label: "Managed mailboxes", value: deployments.data ? rows.filter((d) => isManaged(d)).length.toLocaleString() : "..." },
156
+ { label: "Need attention", value: deployments.data ? rows.filter((d) => d.alert).length.toLocaleString() : "...", tone: rows.some((d) => d.alert) ? "negative" : undefined },
157
+ { label: "Management", value: mode ?? (policy.data ? "Not set" : policy.error ? "Unavailable" : "...") },
158
+ ] : undefined}
159
+ >
160
+ {tab === "templates" ? templatesTab : tab === "mailboxes" ? <SignatureMailboxes /> : tab === "campaigns" ? <SignatureCampaigns /> : tab === "policy" ? <SignaturePolicy /> : <SignatureReport />}
161
+ <FormSheet open={creating} onOpenChange={setCreating} width="xl" title="New signature template" description="Design the signature once. Nothing reaches a mailbox until you apply it.">
162
+ {creating ? <SignatureTemplateForm onCancel={() => setCreating(false)} onSaved={(created) => { setCreating(false); setId(created.id); }} /> : null}
163
+ </FormSheet>
164
+ </ScreenLayout>
165
+ );
166
+ }
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SignatureAssignTemplate: point mailboxes at a template. Choose the mailboxes one by one, every mailbox of a department, or
5
+ * every mailbox; the API then queues a re-install on each.
6
+ *
7
+ * SDK operations, in order (the mailbox, member and deployment reads come from the shared hooks):
8
+ * 1. assignSignatureTemplate (POST /signatures/templates/{template_id}/assign) write replaces the signature the chosen mailboxes SEND:
9
+ * ConfirmSpendDialog (external) first, sized from the mailboxes the choice really covers.
10
+ * Targets are exclusive (mailbox ids, a department, or all mailboxes) and every target must already have an employee
11
+ * assigned (Mailboxes tab); the API refuses the whole call otherwise, so those mailboxes are shown disabled with the reason.
12
+ * "All mailboxes" is only offered when every active mailbox is assigned and the list is complete. The result says how many
13
+ * mailboxes were queued; progress then shows in the Mailboxes tab.
14
+ */
15
+
16
+ import { useState } from "react";
17
+ import { Button } from "@graph8/sdk/ui";
18
+ import { ApiError } from "@/g8/components/api-error";
19
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
20
+ import { FirstNotice } from "@/g8/components/paging-notices";
21
+ import { StatusPill } from "@/g8/components/screen/status-pill";
22
+ import { g8Post } from "@/g8/lib/g8-client";
23
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
24
+ import { plural } from "@/g8/lib/plural";
25
+ import { MAILBOX_LIMIT, useSignatureMailboxes, useSignatureMembers } from "../hooks/use-signatures-data";
26
+ import { isManaged, templateName } from "../lib/signatures-types";
27
+ import type { SignatureTemplate } from "../lib/signatures-types";
28
+
29
+ export interface SignatureAssignTemplateProps {
30
+ template: SignatureTemplate;
31
+ onAssigned?: (count: number) => void;
32
+ }
33
+
34
+ type Mode = "mailboxes" | "department" | "all";
35
+ interface Result { count: number }
36
+
37
+ const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
38
+
39
+ export function SignatureAssignTemplate({ template, onAssigned }: SignatureAssignTemplateProps) {
40
+ const box = useSignatureMailboxes();
41
+ const members = useSignatureMembers();
42
+ const [mode, setMode] = useState<Mode>("mailboxes");
43
+ const [picked, setPicked] = useState<number[]>([]);
44
+ const [department, setDepartment] = useState("");
45
+ const [confirm, setConfirm] = useState(false);
46
+ const [done, setDone] = useState<Result | null>(null);
47
+
48
+ const assigned = box.mailboxes.filter((m) => isManaged(m.deployment));
49
+ const unassigned = box.mailboxes.filter((m) => !isManaged(m.deployment));
50
+ const deptOf = (memberId: string | undefined): string | null => members.data?.find((x) => x.id === memberId)?.department ?? null;
51
+ const departments = [...new Set(assigned.map((m) => deptOf(m.deployment?.assignment?.member_id)).filter((d): d is string => Boolean(d)))].sort();
52
+ const inDepartment = assigned.filter((m) => deptOf(m.deployment?.assignment?.member_id) === department);
53
+ const allowAll = unassigned.length === 0 && !box.truncated && !box.mailboxesError && box.mailboxes.length > 0;
54
+ const covered = mode === "mailboxes" ? picked.filter((id) => assigned.some((m) => m.id === id)).length : mode === "department" ? inDepartment.length : box.mailboxes.length;
55
+ const ready = covered > 0 && (mode !== "department" || department !== "") && (mode !== "all" || allowAll);
56
+
57
+ const apply = useG8Mutation(async (): Promise<Result> => {
58
+ const body = mode === "mailboxes" ? { mailbox_ids: picked.filter((id) => assigned.some((m) => m.id === id)) } : mode === "department" ? { department } : { all_mailboxes: true };
59
+ return (await g8Post<Result>(`/signatures/templates/${encodeURIComponent(template.id)}/assign`, body)).data;
60
+ }, {
61
+ onSuccess: (r) => { setConfirm(false); setDone(r); setPicked([]); onAssigned?.(r.count); },
62
+ onError: () => setConfirm(false),
63
+ invalidates: [["signatures", "deployments"]],
64
+ });
65
+
66
+ if (box.loading) return <p className="text-muted-foreground text-sm" aria-busy="true">Loading mailboxes</p>;
67
+ if (box.error) return <ApiError error={box.error} title="Could not load mailboxes" onRetry={box.refetch} />;
68
+
69
+ return (
70
+ <div className="grid gap-3">
71
+ <p className="text-muted-foreground text-sm">Choose which mailboxes send {templateName(template)}. Each mailbox keeps its employee; only the template changes.</p>
72
+ <fieldset className="grid gap-1 text-sm">
73
+ <legend className="mb-1 font-medium">Apply to</legend>
74
+ <label className="flex items-center gap-2"><input type="radio" name="g8-sig-target" checked={mode === "mailboxes"} onChange={() => setMode("mailboxes")} />Chosen mailboxes</label>
75
+ <label className="flex items-center gap-2"><input type="radio" name="g8-sig-target" checked={mode === "department"} onChange={() => setMode("department")} />Every mailbox in a department</label>
76
+ <label className="flex items-center gap-2"><input type="radio" name="g8-sig-target" checked={mode === "all"} disabled={!allowAll} onChange={() => setMode("all")} />Every mailbox{allowAll ? "" : unassigned.length > 0 ? ` (${plural(unassigned.length, "mailbox")} still without an employee)` : " (the full list could not be confirmed)"}</label>
77
+ </fieldset>
78
+ {box.mailboxesError ? <ApiError compact error={box.mailboxesError} title="Mailboxes without a signature are not listed" onRetry={box.refetch} /> : null}
79
+ {mode === "mailboxes" ? (
80
+ <ul className="grid gap-1" aria-label="Mailboxes">
81
+ {box.mailboxes.map((m) => {
82
+ const ok = isManaged(m.deployment);
83
+ return (
84
+ <li key={m.id} className="flex items-center gap-2 text-sm">
85
+ <input type="checkbox" id={`g8-sig-mb-${m.id}`} disabled={!ok} checked={picked.includes(m.id)} onChange={(e) => setPicked(e.target.checked ? [...picked, m.id] : picked.filter((id) => id !== m.id))} />
86
+ <label htmlFor={`g8-sig-mb-${m.id}`}>{m.email}</label>
87
+ {!ok ? <StatusPill tone="neutral">No employee assigned</StatusPill> : null}
88
+ </li>
89
+ );
90
+ })}
91
+ </ul>
92
+ ) : null}
93
+ {mode === "department" ? (
94
+ <div className="grid gap-1">
95
+ <select aria-label="Department" className={SELECT} value={department} onChange={(e) => setDepartment(e.target.value)}>
96
+ <option value="">Choose a department</option>
97
+ {departments.map((d) => <option key={d} value={d}>{d}</option>)}
98
+ </select>
99
+ {departments.length === 0 ? <p className="text-muted-foreground text-xs">No assigned employee has a department yet.</p> : null}
100
+ </div>
101
+ ) : null}
102
+ <FirstNotice shown={box.mailboxes.length} limit={MAILBOX_LIMIT} noun="mailboxes" force={box.truncated} />
103
+ {apply.error ? <ApiError error={apply.error} title="Could not apply the template" /> : null}
104
+ {done ? <p role="status" className="text-sm">Queued {plural(done.count, "mailbox")} to install {templateName(template)}. Follow progress in the Mailboxes tab.</p> : null}
105
+ <div><Button type="button" disabled={!ready} onClick={() => setConfirm(true)}>Apply to {plural(covered, "mailbox")}</Button></div>
106
+ <ConfirmSpendDialog
107
+ open={confirm}
108
+ tier="external"
109
+ title={`Apply ${templateName(template)} to ${plural(covered, "mailbox")}?`}
110
+ affected={covered}
111
+ description="These mailboxes will send this signature on new emails. Emails already sent do not change. A mailbox can be given its old signature back from the Mailboxes tab unless the policy is enforced."
112
+ confirmLabel="Apply template"
113
+ confirming={apply.loading}
114
+ onCancel={() => setConfirm(false)}
115
+ onConfirm={() => void apply.mutate(undefined)}
116
+ />
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SignatureBlockEditor: edit the ordered blocks of a signature template (kind, text, link, image, locked, required), with
5
+ * placeholders to insert, move up and down, remove and add. A pure form control: it calls no operation and owns no
6
+ * state, so SignatureTemplateForm keeps the draft and validates it with `templateProblems`.
7
+ */
8
+
9
+ import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
10
+ import { Button, Input, Label, Textarea } from "@graph8/sdk/ui";
11
+ import { BLOCK_KINDS, VARIABLES, nextBlockId, variableText } from "../lib/signatures-types";
12
+ import type { BlockKind, TemplateBlock } from "../lib/signatures-types";
13
+
14
+ export interface SignatureBlockEditorProps {
15
+ blocks: TemplateBlock[];
16
+ onChange: (blocks: TemplateBlock[]) => void;
17
+ disabled?: boolean;
18
+ }
19
+
20
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm disabled:opacity-50";
21
+
22
+ export function SignatureBlockEditor({ blocks, onChange, disabled }: SignatureBlockEditorProps) {
23
+ const patch = (i: number, change: Partial<TemplateBlock>): void => onChange(blocks.map((b, at) => (at === i ? { ...b, ...change } : b)));
24
+ const move = (i: number, by: number): void => {
25
+ const to = i + by;
26
+ if (to < 0 || to >= blocks.length) return;
27
+ const next = [...blocks];
28
+ [next[i], next[to]] = [next[to], next[i]];
29
+ onChange(next);
30
+ };
31
+ const add = (kind: BlockKind): void => onChange([...blocks, { id: nextBlockId(kind, blocks), kind, text: "", url: "", image: "", locked: false, required: false }]);
32
+
33
+ return (
34
+ <div className="grid gap-3">
35
+ <ol className="grid gap-3">
36
+ {blocks.map((b, i) => {
37
+ const hint = BLOCK_KINDS.find((k) => k.value === b.kind)?.hint;
38
+ return (
39
+ <li key={i} className="grid gap-2 rounded-md border p-3" aria-label={`Block ${i + 1}: ${b.id}`}>
40
+ <div className="flex flex-wrap items-end gap-2">
41
+ <div className="min-w-40 flex-1">
42
+ <Label htmlFor={`g8-sig-kind-${i}`} className="mb-1">Block {i + 1} type</Label>
43
+ <select id={`g8-sig-kind-${i}`} className={SELECT} value={b.kind} disabled={disabled} onChange={(e) => patch(i, { kind: e.target.value as BlockKind })}>
44
+ {BLOCK_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
45
+ </select>
46
+ </div>
47
+ <div className="w-40">
48
+ <Label htmlFor={`g8-sig-id-${i}`} className="mb-1">Block id</Label>
49
+ <Input id={`g8-sig-id-${i}`} value={b.id} disabled={disabled} onChange={(e) => patch(i, { id: e.target.value })} />
50
+ </div>
51
+ <div className="flex gap-1">
52
+ <Button type="button" size="sm" variant="ghost" aria-label={`Move block ${i + 1} up`} disabled={disabled || i === 0} onClick={() => move(i, -1)}><ArrowUp className="size-4" /></Button>
53
+ <Button type="button" size="sm" variant="ghost" aria-label={`Move block ${i + 1} down`} disabled={disabled || i === blocks.length - 1} onClick={() => move(i, 1)}><ArrowDown className="size-4" /></Button>
54
+ <Button type="button" size="sm" variant="ghost" aria-label={`Remove block ${i + 1}`} disabled={disabled} onClick={() => onChange(blocks.filter((_, at) => at !== i))}><Trash2 className="size-4" /></Button>
55
+ </div>
56
+ </div>
57
+ {hint ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
58
+ <div>
59
+ <Label htmlFor={`g8-sig-text-${i}`} className="mb-1">Text</Label>
60
+ <Textarea id={`g8-sig-text-${i}`} rows={3} value={b.text} disabled={disabled} onChange={(e) => patch(i, { text: e.target.value })} />
61
+ <select aria-label={`Insert a placeholder into block ${i + 1}`} className={`${SELECT} mt-1`} value="" disabled={disabled} onChange={(e) => e.target.value && patch(i, { text: `${b.text}${variableText(e.target.value)}` })}>
62
+ <option value="">Insert a placeholder</option>
63
+ {VARIABLES.map((v) => <option key={v.token} value={v.token}>{v.label} ({variableText(v.token)})</option>)}
64
+ </select>
65
+ </div>
66
+ <div className="grid gap-2 sm:grid-cols-2">
67
+ <div><Label htmlFor={`g8-sig-url-${i}`} className="mb-1">Link (optional)</Label><Input id={`g8-sig-url-${i}`} value={b.url} disabled={disabled} placeholder="https://" onChange={(e) => patch(i, { url: e.target.value })} /></div>
68
+ <div><Label htmlFor={`g8-sig-image-${i}`} className="mb-1">Image address (optional)</Label><Input id={`g8-sig-image-${i}`} value={b.image} disabled={disabled} placeholder="https://" onChange={(e) => patch(i, { image: e.target.value })} /></div>
69
+ </div>
70
+ <div className="flex flex-wrap gap-4 text-sm">
71
+ <label className="flex items-center gap-2"><input type="checkbox" checked={b.required} disabled={disabled} onChange={(e) => patch(i, e.target.checked ? { required: true, locked: true } : { required: false })} />Required (always shown)</label>
72
+ <label className="flex items-center gap-2"><input type="checkbox" checked={b.locked} disabled={disabled || b.required} onChange={(e) => patch(i, { locked: e.target.checked })} />Locked (employees cannot change it)</label>
73
+ </div>
74
+ </li>
75
+ );
76
+ })}
77
+ </ol>
78
+ <div className="flex flex-wrap items-center gap-2">
79
+ <select aria-label="Add a block" className={`${SELECT} w-56`} value="" disabled={disabled || blocks.length >= 30} onChange={(e) => e.target.value && add(e.target.value as BlockKind)}>
80
+ <option value="">Add a block</option>
81
+ {BLOCK_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
82
+ </select>
83
+ <span className="text-muted-foreground flex items-center gap-1 text-xs"><Plus className="size-3" />{blocks.length} of 30 blocks</span>
84
+ </div>
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SignatureCampaignForm: create or edit a signature banner campaign (an optional banner injected into managed signatures
5
+ * for a time window), preview its banner, and test who it would reach.
6
+ *
7
+ * SDK operations, in order (mailboxes and members come from the shared hooks):
8
+ * 1. previewSignatureCampaign (POST /signatures/campaigns/preview) write* renders the banner from the unsaved draft; with a mailbox and
9
+ * recipient addresses it also says whether the campaign would apply and which campaign wins on priority. Changes nothing.
10
+ * 2. createSignatureCampaign (POST /signatures/campaigns) write a campaign; it only applies once its status is Scheduled and the time is inside its dates
11
+ * 3. updateSignatureCampaign (PATCH /signatures/campaigns/{campaign_id}) write REPLACES the campaign and sends the revision that was read (409 if stale)
12
+ * *A POST, so the contract tiers it as a write.
13
+ * The API has no read-one operation: the campaign list row is the complete stored campaign, so an edit is seeded from it and
14
+ * the list is refreshed after every save. Dates are picked in the viewer's time zone and sent as timezone-aware instants,
15
+ * as the API requires. Emptied lists are sent as empty lists. A static native campaign must be sender-wide (no recipient
16
+ * rules), which is checked before the save is offered. Audience-list targeting (recipient_list_ids) has no list picker here, so
17
+ * the block does not depend on the lists block: a campaign that already targets lists keeps them on every save, and the
18
+ * person can remove that targeting; adding lists is done through the API (see the README).
19
+ */
20
+
21
+ import { useState } from "react";
22
+ import { Button, Input, Label, Textarea } from "@graph8/sdk/ui";
23
+ import { ApiError } from "@/g8/components/api-error";
24
+ import { SafeSignatureFrame } from "./signature-preview";
25
+ import { g8Patch, g8Post } from "@/g8/lib/g8-client";
26
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
27
+ import { plural } from "@/g8/lib/plural";
28
+ import { useSignatureMailboxes, useSignatureMembers } from "../hooks/use-signatures-data";
29
+ import { CAMPAIGN_STATUSES, fromLocalInput, isConflict, isManaged, splitList, toLocalInput } from "../lib/signatures-types";
30
+ import type { CampaignData, CampaignPreview, SignatureCampaign } from "../lib/signatures-types";
31
+
32
+ export interface SignatureCampaignFormProps {
33
+ /** Edit this campaign; omit to create one. */
34
+ campaign?: SignatureCampaign;
35
+ onSaved: (campaign: SignatureCampaign) => void;
36
+ onCancel?: () => void;
37
+ onReload?: () => void;
38
+ }
39
+
40
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm";
41
+
42
+ export function SignatureCampaignForm({ campaign, onSaved, onCancel, onReload }: SignatureCampaignFormProps) {
43
+ const d: CampaignData = campaign?.data ?? {};
44
+ const box = useSignatureMailboxes();
45
+ const members = useSignatureMembers();
46
+ const [name, setName] = useState(d.name ?? "");
47
+ const [blockId] = useState(d.block?.id ?? "banner");
48
+ const [text, setText] = useState(d.block?.text ?? "");
49
+ const [url, setUrl] = useState(d.block?.url ?? "");
50
+ const [image, setImage] = useState(d.block?.image ?? "");
51
+ const [starts, setStarts] = useState(toLocalInput(d.starts_at));
52
+ const [ends, setEnds] = useState(toLocalInput(d.ends_at));
53
+ const [status, setStatus] = useState(d.status ?? "draft");
54
+ const [priority, setPriority] = useState(String(d.priority ?? 0));
55
+ const [mailboxIds, setMailboxIds] = useState<number[]>(d.mailbox_ids ?? []);
56
+ const [domains, setDomains] = useState((d.recipient_domains ?? []).join(", "));
57
+ const [excluded, setExcluded] = useState((d.excluded_domains ?? []).join(", "));
58
+ const [external, setExternal] = useState(d.external_only ?? true);
59
+ const [depts, setDepts] = useState<string[]>(d.departments ?? []);
60
+ const [listIds, setListIds] = useState<number[]>(d.recipient_list_ids ?? []);
61
+ const [native, setNative] = useState(d.native_static ?? false);
62
+ const [testMailbox, setTestMailbox] = useState("");
63
+ const [testTo, setTestTo] = useState("");
64
+
65
+ const startIso = fromLocalInput(starts);
66
+ const endIso = fromLocalInput(ends);
67
+ const prio = Number(priority);
68
+ const departments = [...new Set([...(members.data ?? []).map((m) => m.department).filter((x): x is string => Boolean(x)), ...depts])].sort();
69
+ const rules = splitList(domains).length + splitList(excluded).length + listIds.length > 0 || external;
70
+ const problems = [
71
+ name.trim() === "" ? "Give the campaign a name." : "",
72
+ !startIso || !endIso ? "Choose when it starts and ends." : startIso >= endIso ? "It must end after it starts." : "",
73
+ !Number.isInteger(prio) || prio < -1000 || prio > 1000 ? "Priority is a whole number from -1000 to 1000." : "",
74
+ native && rules ? "A static native campaign must go to every recipient: turn off external only and remove the recipient rules." : "",
75
+ ].filter(Boolean);
76
+
77
+ const data = (): Record<string, unknown> => ({
78
+ name: name.trim(),
79
+ block: { id: blockId, kind: "banner", text, url, image, locked: false, required: false },
80
+ starts_at: startIso, ends_at: endIso, status, priority: prio,
81
+ mailbox_ids: mailboxIds, recipient_domains: splitList(domains), excluded_domains: splitList(excluded),
82
+ external_only: external, departments: depts, recipient_list_ids: listIds, native_static: native,
83
+ });
84
+
85
+ const save = useG8Mutation(async (): Promise<SignatureCampaign> => (campaign
86
+ ? await g8Patch<SignatureCampaign>(`/signatures/campaigns/${encodeURIComponent(campaign.id)}`, { version: campaign.revision, data: data() })
87
+ : await g8Post<SignatureCampaign>("/signatures/campaigns", { data: data() })).data, {
88
+ onSuccess: onSaved,
89
+ invalidates: [["signatures", "campaigns"]],
90
+ });
91
+ const preview = useG8Mutation(async (): Promise<CampaignPreview> => (await g8Post<CampaignPreview>("/signatures/campaigns/preview", {
92
+ data: data(),
93
+ ...(testMailbox ? { mailbox_id: Number(testMailbox), recipients: splitList(testTo), ...(campaign ? { campaign_id: campaign.id } : {}) } : {}),
94
+ })).data);
95
+ const toggle = <T,>(list: T[], v: T, on: boolean): T[] => (on ? [...list, v] : list.filter((x) => x !== v));
96
+ const match = preview.data?.matching as { matches?: boolean; selected_campaign_id?: string | null; limitation?: string } | null | undefined;
97
+
98
+ return (
99
+ <form className="grid gap-4" onSubmit={(e) => { e.preventDefault(); if (problems.length === 0) void save.mutate(undefined); }}>
100
+ <div><Label htmlFor="g8-sig-cname" className="mb-1">Campaign name</Label><Input id="g8-sig-cname" value={name} placeholder="Autumn webinar" onChange={(e) => setName(e.target.value)} /></div>
101
+ <fieldset className="grid gap-2"><legend className="mb-1 text-sm font-medium">Banner</legend>
102
+ <div><Label htmlFor="g8-sig-ctext" className="mb-1">Text (also the image description)</Label><Textarea id="g8-sig-ctext" rows={2} value={text} onChange={(e) => setText(e.target.value)} /></div>
103
+ <div className="grid gap-2 sm:grid-cols-2">
104
+ <div><Label htmlFor="g8-sig-curl" className="mb-1">Link</Label><Input id="g8-sig-curl" value={url} placeholder="https://" onChange={(e) => setUrl(e.target.value)} /></div>
105
+ <div><Label htmlFor="g8-sig-cimage" className="mb-1">Image address</Label><Input id="g8-sig-cimage" value={image} placeholder="https://" onChange={(e) => setImage(e.target.value)} /></div>
106
+ </div>
107
+ </fieldset>
108
+ <div className="grid gap-3 sm:grid-cols-2">
109
+ <div><Label htmlFor="g8-sig-cstart" className="mb-1">Starts</Label><Input id="g8-sig-cstart" type="datetime-local" value={starts} onChange={(e) => setStarts(e.target.value)} /></div>
110
+ <div><Label htmlFor="g8-sig-cend" className="mb-1">Ends</Label><Input id="g8-sig-cend" type="datetime-local" value={ends} onChange={(e) => setEnds(e.target.value)} /></div>
111
+ <div>
112
+ <Label htmlFor="g8-sig-cstatus" className="mb-1">Status</Label>
113
+ <select id="g8-sig-cstatus" className={SELECT} value={status} onChange={(e) => setStatus(e.target.value)}>
114
+ {CAMPAIGN_STATUSES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
115
+ {CAMPAIGN_STATUSES.some((s) => s.value === status) ? null : <option value={status}>{status}</option>}
116
+ </select>
117
+ <p className="text-muted-foreground mt-1 text-xs">Only a Scheduled campaign is added to signatures, and only between its dates.</p>
118
+ </div>
119
+ <div><Label htmlFor="g8-sig-cprio" className="mb-1">Priority</Label><Input id="g8-sig-cprio" type="number" value={priority} onChange={(e) => setPriority(e.target.value)} /><p className="text-muted-foreground mt-1 text-xs">When campaigns overlap, the higher number wins.</p></div>
120
+ </div>
121
+ <fieldset className="grid gap-2"><legend className="mb-1 text-sm font-medium">Who sees it</legend>
122
+ <p className="text-muted-foreground text-xs">Leave the mailboxes and departments empty to include everyone.</p>
123
+ {box.mailboxes.length > 0 ? (
124
+ <div className="grid max-h-32 gap-1 overflow-y-auto" role="group" aria-label="Mailboxes">
125
+ {box.mailboxes.map((m) => <label key={m.id} className="flex items-center gap-2 text-sm"><input type="checkbox" checked={mailboxIds.includes(m.id)} onChange={(e) => setMailboxIds(toggle(mailboxIds, m.id, e.target.checked))} />{m.email}</label>)}
126
+ </div>
127
+ ) : null}
128
+ {departments.length > 0 ? (
129
+ <div className="flex flex-wrap gap-3" role="group" aria-label="Departments">
130
+ {departments.map((x) => <label key={x} className="flex items-center gap-2 text-sm"><input type="checkbox" checked={depts.includes(x)} onChange={(e) => setDepts(toggle(depts, x, e.target.checked))} />{x}</label>)}
131
+ </div>
132
+ ) : null}
133
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={external} onChange={(e) => setExternal(e.target.checked)} />Only emails to people outside the company</label>
134
+ <div><Label htmlFor="g8-sig-cdomains" className="mb-1">Only these recipient domains</Label><Input id="g8-sig-cdomains" value={domains} placeholder="acme.com, example.org" onChange={(e) => setDomains(e.target.value)} /></div>
135
+ <div><Label htmlFor="g8-sig-cexcl" className="mb-1">Never these recipient domains</Label><Input id="g8-sig-cexcl" value={excluded} onChange={(e) => setExcluded(e.target.value)} /></div>
136
+ {listIds.length > 0 ? (
137
+ <div className="flex flex-wrap items-center gap-2 rounded-md border p-2 text-sm">
138
+ <span>Only people on {plural(listIds.length, "audience list")} (set through the API; kept when you save). They apply only to emails sent from a sequence.</span>
139
+ <Button type="button" size="sm" variant="outline" onClick={() => setListIds([])}>Remove list targeting</Button>
140
+ </div>
141
+ ) : null}
142
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={native} onChange={(e) => setNative(e.target.checked)} />Static native campaign (installed in the mail account, sender-wide)</label>
143
+ </fieldset>
144
+ <section aria-label="Test the campaign" className="grid gap-2 rounded-md border p-3">
145
+ <h3 className="text-sm font-medium">Preview and test</h3>
146
+ <div className="grid gap-2 sm:grid-cols-2">
147
+ <select aria-label="Test from mailbox" className={SELECT} value={testMailbox} onChange={(e) => setTestMailbox(e.target.value)}><option value="">Only render the banner</option>{box.mailboxes.filter((m) => isManaged(m.deployment)).map((m) => <option key={m.id} value={m.id}>{m.email}</option>)}</select>
148
+ <Input aria-label="Test recipients" value={testTo} placeholder="buyer@acme.com" disabled={testMailbox === ""} onChange={(e) => setTestTo(e.target.value)} />
149
+ </div>
150
+ <div><Button type="button" size="sm" variant="outline" disabled={problems.length > 0} loading={preview.loading} onClick={() => void preview.mutate(undefined)}>Preview</Button></div>
151
+ {preview.error ? <ApiError error={preview.error} title="Could not render the preview" /> : null}
152
+ {preview.data ? <SafeSignatureFrame html={preview.data.html} title="Banner preview" className="border-border h-32 w-full rounded-md border bg-white" /> : null}
153
+ {match ? <p role="status" className="text-sm">{match.matches ? "This campaign would be added to that email." : "This campaign would not be added to that email."} {match.limitation}</p> : null}
154
+ </section>
155
+ {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}
156
+ {save.error && isConflict(save.error) ? (
157
+ <div className="grid gap-2"><ApiError error={save.error} title="Someone else changed this campaign" />{onReload ? <div><Button type="button" size="sm" variant="outline" onClick={onReload}>Load the newer version</Button></div> : null}</div>
158
+ ) : save.error ? <ApiError error={save.error} title="Could not save the campaign" /> : null}
159
+ <div className="flex gap-2">
160
+ <Button type="submit" loading={save.loading} disabled={problems.length > 0}>{campaign ? "Save changes" : "Create campaign"}</Button>
161
+ {onCancel ? <Button type="button" variant="outline" onClick={onCancel}>Cancel</Button> : null}
162
+ </div>
163
+ </form>
164
+ );
165
+ }
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SignatureCampaigns: the banner campaigns that are added to managed signatures for a time window: the list with status,
5
+ * dates and priority, and a sheet to create or edit one.
6
+ *
7
+ * SDK operations: none directly. The list comes from the shared hook (listSignatureCampaigns, GET /signatures/campaigns,
8
+ * read, not paged: the UI says "All N") and the writes and preview live in SignatureCampaignForm
9
+ * (createSignatureCampaign, updateSignatureCampaign, previewSignatureCampaign). The API has no delete: stop a campaign by
10
+ * setting its status to Paused (or Draft), which keeps its report numbers.
11
+ */
12
+
13
+ import { useState } from "react";
14
+ import { Megaphone, Plus } from "lucide-react";
15
+ import { Button, Skeleton } from "@graph8/sdk/ui";
16
+ import { ApiError } from "@/g8/components/api-error";
17
+ import { EmptyState } from "@/g8/components/empty-state";
18
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
19
+ import { StatusPill, humanizeStatus, toneForStatus } from "@/g8/components/screen/status-pill";
20
+ import { plural } from "@/g8/lib/plural";
21
+ import { useSignatureCampaigns } from "../hooks/use-signatures-data";
22
+ import { formatWhen } from "../lib/signatures-types";
23
+ import type { SignatureCampaign } from "../lib/signatures-types";
24
+ import { SignatureCampaignForm } from "./signature-campaign-form";
25
+
26
+ export function SignatureCampaigns() {
27
+ const list = useSignatureCampaigns();
28
+ const [editing, setEditing] = useState<SignatureCampaign | "new" | null>(null);
29
+ const rows = list.data ?? [];
30
+ // The edit form is seeded from the campaign as the list holds it now (the API has no read-one), never from a stale copy.
31
+ const current = editing && editing !== "new" ? rows.find((c) => c.id === editing.id) ?? editing : editing;
32
+
33
+ const body = list.error && !list.data ? <ApiError error={list.error} title="Could not load campaigns" onRetry={() => void list.refetch()} /> : !list.data ? (
34
+ <div aria-busy="true" className="grid gap-2"><Skeleton className="h-14 w-full" /><Skeleton className="h-14 w-full" /></div>
35
+ ) : rows.length === 0 ? (
36
+ <EmptyState icon={<Megaphone className="size-8" />} title="No campaigns yet" description="A campaign adds a banner to signatures for a time window, for example a webinar or a launch." action={<Button onClick={() => setEditing("new")}>Create a campaign</Button>} />
37
+ ) : (
38
+ <>
39
+ <p role="status" className="text-muted-foreground mb-2 text-xs">All {plural(rows.length, "campaign")}</p>
40
+ <ul className="grid gap-2">
41
+ {rows.map((c) => (
42
+ <li key={c.id} className="flex flex-wrap items-center justify-between gap-2 rounded-md border p-3">
43
+ <div className="min-w-0 flex-1">
44
+ <span className="block truncate font-medium">{c.data.name || "Untitled campaign"}</span>
45
+ <span className="text-muted-foreground block text-xs">{formatWhen(c.data.starts_at)} to {formatWhen(c.data.ends_at)} · priority {c.data.priority ?? 0}</span>
46
+ </div>
47
+ <StatusPill tone={toneForStatus(c.data.status)}>{humanizeStatus(c.data.status)}</StatusPill>
48
+ <Button size="sm" variant="outline" aria-label={`Edit ${c.data.name || "campaign"}`} onClick={() => setEditing(c)}>Edit</Button>
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ </>
53
+ );
54
+
55
+ return (
56
+ <div className="grid gap-3">
57
+ <div className="flex items-center justify-between gap-2">
58
+ <p className="text-muted-foreground text-sm">Banners added to managed signatures between two dates.</p>
59
+ <Button size="sm" onClick={() => setEditing("new")}><Plus className="size-4" />New campaign</Button>
60
+ </div>
61
+ {body}
62
+ <FormSheet open={editing !== null} onOpenChange={(o) => !o && setEditing(null)} width="xl" title={editing === "new" ? "New campaign" : "Edit campaign"} description="A banner shown in signatures during its dates.">
63
+ {current ? (
64
+ <SignatureCampaignForm
65
+ key={current === "new" ? "new" : `${current.id}:${current.revision}`}
66
+ campaign={current === "new" ? undefined : current}
67
+ onSaved={() => setEditing(null)}
68
+ onCancel={() => setEditing(null)}
69
+ onReload={() => void list.refetch()}
70
+ />
71
+ ) : null}
72
+ </FormSheet>
73
+ </div>
74
+ );
75
+ }