@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,227 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Pure helpers for the inbox management parts: composing a new message, managing tags and blocked senders,
5
+ * server-side search, and the per-thread actions (read state, unassign, remove a tag). No SDK calls here.
6
+ *
7
+ * Several of these endpoints answer with an open `{ items: [...] }` payload (no response schema in the
8
+ * contract), so every reader below is defensive: it accepts a bare list, `{ items }`, or the named list the
9
+ * route actually uses, and skips rows it cannot make sense of.
10
+ */
11
+ import type { InboxChannel, InboxTag, InboxThread } from "./inbox-types";
12
+ import { stripHtml } from "./inbox-types";
13
+
14
+ /** Rows from a bare list, `{ data: [...] }`, `{ items: [...] }` or a named list such as `{ tags: [...] }`. */
15
+ export function rowsFrom<T = Record<string, unknown>>(payload: unknown, ...names: string[]): T[] {
16
+ const list = (v: unknown): T[] | null => (Array.isArray(v) ? (v as T[]) : null);
17
+ const direct = list(payload);
18
+ if (direct) return direct;
19
+ const obj = (payload ?? {}) as Record<string, unknown>;
20
+ const inner = (obj.data && typeof obj.data === "object" ? (obj.data as Record<string, unknown>) : obj) as Record<string, unknown>;
21
+ return list(inner) ?? list(inner.items) ?? names.map((n) => list(inner[n])).find(Boolean) ?? list(obj.items) ?? [];
22
+ }
23
+
24
+ // --- Compose ----------------------------------------------------------------------------------------------------
25
+
26
+ export interface MailboxChoice {
27
+ /** The address the message is sent from (`from_mailbox`). */
28
+ address: string;
29
+ label: string;
30
+ /** False when the caller may see the mailbox but not send as it; `reason` says why. */
31
+ canSend: boolean;
32
+ reason?: string;
33
+ }
34
+
35
+ /** Sender choices from `GET /inbox/mailboxes/paginated` (rows carry `can_send` / `send_blocked_reason`). */
36
+ export function mailboxChoices(payload: unknown): MailboxChoice[] {
37
+ const out: MailboxChoice[] = [];
38
+ const seen = new Set<string>();
39
+ for (const row of rowsFrom<Record<string, unknown>>(payload, "mailboxes")) {
40
+ const address = String(row?.mailbox ?? row?.email ?? "").trim();
41
+ if (!address || seen.has(address.toLowerCase())) continue;
42
+ seen.add(address.toLowerCase());
43
+ const canSend = row.can_send !== false;
44
+ const reason = typeof row.send_blocked_reason === "string" ? row.send_blocked_reason : undefined;
45
+ out.push({ address, label: address, canSend, reason: canSend ? undefined : (reason ?? "not allowed to send as this mailbox") });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ const EMAIL = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
51
+ export const isEmail = (value: string): boolean => EMAIL.test(value.trim());
52
+
53
+ /** Split "a@x.com, b@y.com; c@z.com" into trimmed, de-duplicated addresses (case-insensitive). */
54
+ export function splitAddresses(value: string): string[] {
55
+ const seen = new Set<string>();
56
+ const out: string[] = [];
57
+ for (const part of value.split(/[,;\n]/)) {
58
+ const v = part.trim();
59
+ if (v && !seen.has(v.toLowerCase())) {
60
+ seen.add(v.toLowerCase());
61
+ out.push(v);
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** A phone number the SMS route will accept: E.164 (`+` then 8 to 15 digits). */
68
+ export const isPhone = (value: string): boolean => /^\+[1-9]\d{7,14}$/.test(value.replace(/[\s().-]/g, ""));
69
+ export const normalizePhone = (value: string): string => value.replace(/[\s().-]/g, "");
70
+
71
+ const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
72
+
73
+ /** The compose API takes an HTML body; what people type is plain text. Paragraphs become `<p>`, newlines `<br>`. */
74
+ export function textToHtml(text: string): string {
75
+ return text
76
+ .trim()
77
+ .split(/\n{2,}/)
78
+ .map((p) => `<p>${escapeHtml(p).replace(/\n/g, "<br>")}</p>`)
79
+ .join("");
80
+ }
81
+
82
+ export interface EmailComposeInput {
83
+ from: string;
84
+ to: string;
85
+ cc?: string;
86
+ subject: string;
87
+ body: string;
88
+ }
89
+
90
+ /** What is still missing before an email may be sent, in the order the form asks for it. Empty means ready. */
91
+ export function emailComposeProblems(input: EmailComposeInput): string[] {
92
+ const problems: string[] = [];
93
+ if (!input.from.trim()) problems.push("Choose the mailbox to send from.");
94
+ const to = splitAddresses(input.to);
95
+ if (to.length === 0) problems.push("Add at least one recipient.");
96
+ else if (to.some((a) => !isEmail(a))) problems.push("One of the recipient addresses is not valid.");
97
+ if (splitAddresses(input.cc ?? "").some((a) => !isEmail(a))) problems.push("One of the Cc addresses is not valid.");
98
+ if (!input.subject.trim()) problems.push("Add a subject.");
99
+ if (!input.body.trim()) problems.push("Write the message.");
100
+ return problems;
101
+ }
102
+
103
+ export interface SmsComposeInput {
104
+ from: string;
105
+ to: string;
106
+ body: string;
107
+ }
108
+
109
+ export function smsComposeProblems(input: SmsComposeInput): string[] {
110
+ const problems: string[] = [];
111
+ if (!input.from.trim()) problems.push("Choose the number to text from.");
112
+ if (!input.to.trim()) problems.push("Add the phone number to text.");
113
+ else if (!isPhone(input.to)) problems.push("Use the full number with country code, for example +14155550100.");
114
+ if (!input.body.trim()) problems.push("Write the message.");
115
+ return problems;
116
+ }
117
+
118
+ // --- Tags -------------------------------------------------------------------------------------------------------
119
+
120
+ export interface ManagedTag {
121
+ id: string;
122
+ name: string;
123
+ description: string;
124
+ color: string;
125
+ /** Built-in tags: "system" cannot be edited or deleted, "sdr_ai" cannot be renamed or deleted. */
126
+ policy: "system" | "sdr_ai" | "user";
127
+ }
128
+
129
+ /** Tags from `GET /workflows/inbox-tags` (`{ tags: [...], total }`, with or without an envelope). */
130
+ export function managedTags(payload: unknown): ManagedTag[] {
131
+ const out: ManagedTag[] = [];
132
+ for (const row of rowsFrom<Record<string, unknown>>(payload, "tags")) {
133
+ const id = row?.id == null ? "" : String(row.id);
134
+ if (!id) continue;
135
+ const policy = row.policy === "system" || row.policy === "sdr_ai" || row.is_system === true ? (row.policy === "sdr_ai" ? "sdr_ai" : "system") : "user";
136
+ out.push({ id, name: String(row.name ?? id), description: String(row.description ?? ""), color: String(row.color ?? ""), policy });
137
+ }
138
+ return out;
139
+ }
140
+
141
+ export const asInboxTags = (tags: ManagedTag[]): InboxTag[] => tags.map((t) => ({ id: t.id, name: t.name }));
142
+
143
+ export const canEditTag = (tag: ManagedTag): boolean => tag.policy !== "system";
144
+ export const canRenameTag = (tag: ManagedTag): boolean => tag.policy === "user";
145
+ export const canDeleteTag = (tag: ManagedTag): boolean => tag.policy === "user";
146
+
147
+ /** Only the fields that changed, for `PUT /inbox/tags/{id}` (which writes exactly what it is sent). */
148
+ export function tagChanges(
149
+ loaded: Pick<ManagedTag, "name" | "description" | "color">,
150
+ draft: { name: string; description: string; color: string },
151
+ ): Record<string, string> {
152
+ const out: Record<string, string> = {};
153
+ if (draft.name.trim() !== loaded.name) out.name = draft.name.trim();
154
+ if (draft.description.trim() !== loaded.description) out.description = draft.description.trim();
155
+ if (draft.color.trim() !== loaded.color) out.color = draft.color.trim();
156
+ return out;
157
+ }
158
+
159
+ export const isHexColor = (v: string): boolean => v === "" || /^#[0-9a-f]{6}$/i.test(v.trim());
160
+
161
+ // --- Blocked senders --------------------------------------------------------------------------------------------
162
+
163
+ export interface BlockedSender {
164
+ id: string;
165
+ email: string;
166
+ mailbox: string;
167
+ reason: string;
168
+ }
169
+
170
+ export function blockedSenders(payload: unknown): BlockedSender[] {
171
+ const out: BlockedSender[] = [];
172
+ for (const row of rowsFrom<Record<string, unknown>>(payload, "blocked_senders", "senders")) {
173
+ const id = row?.id == null ? "" : String(row.id);
174
+ const email = String(row?.sender_email ?? row?.email ?? "");
175
+ if (id && email) out.push({ id, email, mailbox: String(row.mailbox ?? ""), reason: String(row.reason ?? "") });
176
+ }
177
+ return out;
178
+ }
179
+
180
+ // --- Per-thread actions -----------------------------------------------------------------------------------------
181
+
182
+ /** The `channel_type` the `/inbox/channels/{id}/...` routes use for a thread of this channel. */
183
+ export function channelTypeOf(channel: InboxChannel | string): "email" | "sms" | "heyreach" {
184
+ return channel === "sms" ? "sms" : channel === "linkedin" ? "heyreach" : "email";
185
+ }
186
+
187
+ /** Marking a thread unread exists only for LinkedIn; email and SMS can only be marked read. */
188
+ export const canMarkUnread = (thread: Pick<InboxThread, "channel" | "linkedin_account_id">): boolean =>
189
+ thread.channel === "linkedin" && thread.linkedin_account_id != null;
190
+
191
+ /** Can this thread be marked read at all? (LinkedIn needs its account id for the route.) */
192
+ export const canMarkRead = (thread: Pick<InboxThread, "channel" | "linkedin_account_id">): boolean =>
193
+ thread.channel !== "linkedin" || thread.linkedin_account_id != null;
194
+
195
+ // --- Server-side search -----------------------------------------------------------------------------------------
196
+
197
+ export interface SearchHit {
198
+ id: string;
199
+ channel: "email" | "linkedin";
200
+ title: string;
201
+ subtitle: string;
202
+ preview: string;
203
+ when?: string;
204
+ }
205
+
206
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
207
+
208
+ /** A search result row (email thread or LinkedIn conversation) as a list entry. Null when it has no usable id. */
209
+ export function searchHit(channel: "email" | "linkedin", row: Record<string, unknown>): SearchHit | null {
210
+ const id = row.id ?? row.email_id ?? row.thread_id ?? row.conversation_id ?? row.chat_id;
211
+ if (id == null || id === "") return null;
212
+ const contact = (row.contact ?? {}) as Record<string, unknown>;
213
+ const first = Array.isArray(row.messages) ? ((row.messages as Array<Record<string, unknown>>)[0] ?? {}) : {};
214
+ const who = str(contact.name) || str(row.contact_name) || str(row.from_name) || str(row.from_email) || str(row.sender) || str(contact.email);
215
+ const title = str(row.subject) || who || (channel === "linkedin" ? "LinkedIn conversation" : "Email conversation");
216
+ const preview = stripHtml(str(row.snippet) || str(row.preview) || str(row.last_message) || str(first.content) || str(row.body)).replace(/\s+/g, " ").slice(0, 140);
217
+ const when = str(row.updated_at) || str(row.last_message_at) || str(row.created_at) || undefined;
218
+ return { id: String(id), channel, title, subtitle: who && who !== title ? who : str(row.mailbox), preview, when };
219
+ }
220
+
221
+ // --- Hidden or missing threads ------------------------------------------------------------------------------------
222
+
223
+ /**
224
+ * The dev API answers a uniform 404 for a thread that does not exist AND for one this key may not see (an org key sees
225
+ * shared threads only), so the two cannot be told apart. Views say so instead of showing a generic failure.
226
+ */
227
+ export const isNotFound = (error: unknown): boolean => (error as { status?: number } | null)?.status === 404;
@@ -200,6 +200,8 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
200
200
  const [goal, setGoal] = useState("");
201
201
  const [pending, setPending] = useState<"create" | "activate" | null>(null);
202
202
  // Bumped after a version restore so the editor reloads the draft instead of showing the old text.
203
+ // Bumped after a version restore so the Write tab remounts and reloads the draft. The History tab is NOT keyed on it:
204
+ // remounting it would drop a Duplicate confirm opened right after the restore (it refetches its own versions).
203
205
  const [formRev, setFormRev] = useState(0);
204
206
 
205
207
  const newsletters = useG8Query((signal) => g8Get<Newsletter[]>("/newsletters", {}, { signal }).then((r) => r.data ?? []), []);
@@ -249,7 +251,7 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
249
251
  { id: "write", label: isDraft(selected) ? "Write" : "Content", content: <IssueForm key={`f${selected.id}:${formRev}`} newsletterId={newsletterId} issueId={selected.id} onSaved={() => void issues.refetch()} onDeleted={() => { setIssueParam(null); void issues.refetch(); }} /> },
250
252
  { id: "send", label: "Send", content: <IssueScheduler key={`s${selected.id}`} newsletterId={newsletterId} issueId={selected.id} onSent={() => void issues.refetch()} /> },
251
253
  { id: "results", label: "Results", content: <IssueResults newsletterId={newsletterId} issue={selected} /> },
252
- { id: "history", label: "History", content: <IssueVersions key={`v${selected.id}:${formRev}`} newsletterId={newsletterId} issue={selected} onRestored={() => { setFormRev((n) => n + 1); void issues.refetch(); }} onDuplicated={(id) => { void issues.refetch(); setIssueParam(String(id)); }} /> },
254
+ { id: "history", label: "History", content: <IssueVersions key={`v${selected.id}`} newsletterId={newsletterId} issue={selected} onRestored={() => { setFormRev((n) => n + 1); void issues.refetch(); }} onDuplicated={(id) => { void issues.refetch(); setIssueParam(String(id)); }} /> },
253
255
  { id: "details", label: "Issue details", content: <IssueDetails issue={selected} /> },
254
256
  ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
255
257
  ]
@@ -262,7 +262,16 @@
262
262
  ]
263
263
  }
264
264
  ],
265
- "hooks": [],
265
+ "hooks": [
266
+ {
267
+ "name": "useLandingPagePaths",
268
+ "file": "hooks/use-landing-page-paths.ts",
269
+ "purpose": "Resolve a website path to the one published landing page at that address (by id, never by name), for glue such as web analytics' Open landing page.",
270
+ "operations": [
271
+ "list_landing_pages_landing_pages_get"
272
+ ]
273
+ }
274
+ ],
266
275
  "lib": [
267
276
  {
268
277
  "file": "lib/studio.ts",
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ /**
4
+ * useLandingPagePaths: which landing page lives at a given website path. Used by glue that links a page in analytics to
5
+ * its landing page (web-analytics: "Open landing page").
6
+ *
7
+ * A page is matched by its published address only: the path of `published_url`. The analytics path has no host, so when two
8
+ * landing pages publish the same path on different domains the path is ambiguous and links to NEITHER (never a guess, and
9
+ * never a match on the page's name or title). Drafts have no `published_url` and are never matched.
10
+ *
11
+ * SDK operations (read, free):
12
+ * 1. listLandingPages GET /landing-pages?limit&offset (every page is read, 100 at a time, with useAllPages)
13
+ */
14
+ import { useMemo } from "react";
15
+ import { useAllPages } from "@/g8/hooks/use-all-pages";
16
+ import { g8Get } from "@/g8/lib/g8-client";
17
+ import { rowsOf } from "../lib/studio";
18
+ import type { LandingPageRow } from "../lib/studio";
19
+
20
+ const PAGE = 100;
21
+
22
+ /** `/pricing/` and `/pricing` are the same page; the root stays `/`. */
23
+ export function normalizePath(path: string): string {
24
+ const p = path.split(/[?#]/)[0];
25
+ return p.length > 1 ? p.replace(/\/+$/, "") || "/" : p || "/";
26
+ }
27
+
28
+ /** path -> landing page id, for published pages whose path is unique among them. */
29
+ export function landingPageIdsByPath(pages: Array<Pick<LandingPageRow, "id" | "published_url">>): Map<string, string> {
30
+ const seen = new Map<string, string | null>();
31
+ for (const page of pages) {
32
+ if (!page.published_url) continue;
33
+ let path: string;
34
+ try {
35
+ path = normalizePath(new URL(page.published_url).pathname);
36
+ } catch {
37
+ continue;
38
+ }
39
+ seen.set(path, seen.has(path) ? null : page.id);
40
+ }
41
+ const out = new Map<string, string>();
42
+ for (const [path, id] of seen) if (id) out.set(path, id);
43
+ return out;
44
+ }
45
+
46
+ export interface LandingPagePaths {
47
+ /** The id of the one published landing page at this path, or undefined. */
48
+ resolve: (path: string) => string | undefined;
49
+ loading: boolean;
50
+ }
51
+
52
+ export function useLandingPagePaths(): LandingPagePaths {
53
+ const pages = useAllPages<LandingPageRow>(async (page, signal) => {
54
+ const env = await g8Get("/landing-pages", { limit: PAGE, offset: (page - 1) * PAGE }, { signal });
55
+ const rows = rowsOf<LandingPageRow>(env.data, "landing_pages");
56
+ return { rows, hasNext: rows.length >= PAGE };
57
+ }, []);
58
+ const map = useMemo(() => landingPageIdsByPath(pages.items), [pages.items]);
59
+ return { resolve: (path) => map.get(normalizePath(path)), loading: pages.loading };
60
+ }
@@ -0,0 +1,82 @@
1
+ # programmatic-pages
2
+
3
+ One **template landing page** plus one **data source** makes **one page per row**: a page per account, per city, per integration. A campaign holds the template, the source (a contact list, or a Workbench, CSV or sitemap-import table), an address pattern such as `{{company}}-{{first_name}}`, and per-variable rules. Rendering is free (no model runs). Putting pages on the live site is a separate, confirmed step.
4
+
5
+ ## Screens
6
+
7
+ `ProgrammaticPagesScreen` (`components/programmatic-pages-screen.tsx`) is the finished page.
8
+
9
+ ```tsx
10
+ import { ProgrammaticPagesScreen } from "@/g8/programmatic-pages/components/programmatic-pages-screen";
11
+ export default function Page() { return <ProgrammaticPagesScreen />; }
12
+ ```
13
+
14
+ Open a campaign (`?id=`) for Overview, Pages, Preview, Variables, Source, Settings, Edit and Details (`?tab=`); `&view=full` shows it as a full page. The Overview links to the template page in the `pages` screen (`/pages?id=`; override with `pagesHref`).
15
+
16
+ Flows: see every campaign with state, source and page counts (search; filter by state and source; the list is complete, the API does not page it); create one from a template page and a list or table; render all pages and follow the run; read, search and filter the generated pages; mark pages ready, regenerate them, replace one page's HTML and undo it; take pages off the live site, delete them, export their addresses, send live ones to Search Console (each confirmed); publish waiting pages, republish everything or roll the last publish back (each confirmed with its page count); preview one row; set a fallback, character limit and cut rule per variable; switch the source or import a sitemap as the source; set regeneration, publishing, search and link settings; edit the name and address pattern; delete a campaign (confirmed). Optional props: `pagesHref`, `renderRowActions`, `renderDetailActions`, `extraDetailTabs`, `toolbarActions`.
17
+
18
+ Parts to mount alone: `ProgrammaticCampaignForm`, `ProgrammaticOverview`, `ProgrammaticGenerate`, `ProgrammaticPublish`, `ProgrammaticGeneratedPages`, `ProgrammaticPreview`, `ProgrammaticVariables`, `ProgrammaticSource`, `ProgrammaticSettings`.
19
+
20
+ ## Install
21
+
22
+ ```
23
+ npx @graph8/sdk add programmatic-pages
24
+ ```
25
+
26
+ Installs `foundation`, `crm` and `pages` first if missing. Everything lands under `g8/programmatic-pages/`.
27
+
28
+ ## Prerequisites (the human must do these)
29
+
30
+ | Where | What | How to verify |
31
+ |---|---|---|
32
+ | graph8 | A **template landing page** with HTML and `{{variables}}` (make it in the pages block). It cannot be changed after the campaign exists. | `GET /api/v1/landing-pages` lists it. |
33
+ | graph8 | A **source** with a column for each variable (a contact list or a Workbench table). | `GET /api/v1/programmatic-campaigns/{id}/variables` shows every variable as found. |
34
+ | graph8 | **Publishing** needs a connected Cloudflare website or hosted publishing. Rendering does not. | A person publishes one test campaign. |
35
+ | graph8 key | `campaigns:read`, `campaigns:write`, `campaigns:delete`; `campaigns:run` for page actions and publish (separate from `campaigns:write`); `lists:read`. | `g8.api.me.describeCurrentKey()` |
36
+ | Your app | `allowedPrefixes` must include `programmatic-campaigns`, `landing-pages`, `workbench`, `lists`. | The route file lists them. |
37
+
38
+ ## The flow, in order (ids passed between steps)
39
+
40
+ 1. **Create**: `createProgrammaticCampaign({ name, template_page_id, data_source_type, list_id | data_source_id, slug_template? })` -> **`campaign.id`** (a UUID). `template_page_id` comes from `listLandingPages`, `list_id` from `listLists`, a table id from `listWorkbenchTables`.
41
+ 2. **Check**: `getProgrammaticCampaignVariables` shows each `{{variable}}`, whether the source has a column for it, and the rule stored for it. `updateProgrammaticWorkspace({ variable_config })` REPLACES the whole array: send the existing entries back with your change.
42
+ 3. **Preview**: `previewProgrammaticPage({ contact_id? })` renders one row and saves nothing.
43
+ 4. **Render**: `startProgrammaticGeneration` (202) then poll `getProgrammaticGenerationStatus` until `completed`, `failed` or `paused`. Free.
44
+ 5. **Review**: `listProgrammaticGeneratedPages` (50 per read, up to 500) and `getProgrammaticWorkspace` (counts, search health, deployment).
45
+ 6. **Page actions**: `runProgrammaticPageAction` (`mark_ready_to_deploy`, `unpublish`, `mark_for_regeneration`, `override`, `reset_override`) and `runProgrammaticBulkPageAction` on a selection.
46
+ 7. **Publish**: `runProgrammaticBulkPageAction({ action: "deploy_pending_changes" | "republish_all" | "rollback_publish" })`, confirmed with the page count.
47
+ 8. **Source**: `updateProgrammaticWorkspace` switches list or table; `importProgrammaticSitemap` REPLACES the source with a table of the sitemap's addresses.
48
+ 9. **Remove**: `deleteProgrammaticCampaign` removes the campaign and its pages (confirmed); live copies are not taken down.
49
+
50
+ ## Default decisions (do not ask the human unless marked)
51
+
52
+ | Decision | Default | Ask? |
53
+ |---|---|---|
54
+ | Data source | None: a person picks the list or table | **yes** |
55
+ | Address pattern | Left blank: the API's own `{{company}}-{{first_name}}` | no |
56
+ | Rendering | Free; makes drafts. Confirmed first when the approval mode is "Publish automatically" | no |
57
+ | Publishing | Never without a person confirming the page count | **yes** |
58
+ | Stopping | Unpublish first; a delete does not take live pages down | no |
59
+
60
+ ## Operation coverage (15 programmaticPages operations, all used)
61
+
62
+ `listProgrammaticCampaigns`, `createProgrammaticCampaign`, `getProgrammaticCampaign`, `updateProgrammaticCampaign`, `deleteProgrammaticCampaign`, `getProgrammaticCampaignVariables`, `previewProgrammaticPage`, `startProgrammaticGeneration`, `getProgrammaticGenerationStatus`, `listProgrammaticGeneratedPages`, `getProgrammaticWorkspace`, `updateProgrammaticWorkspace`, `importProgrammaticSitemap`, `runProgrammaticPageAction`, `runProgrammaticBulkPageAction`. Tiers: `deleteProgrammaticCampaign` is destructive; both page-action operations are external (they can change the live site); none is billable. The block also reads `listLandingPages`, `getLandingPage`, `listWorkbenchTables` and (through the crm `ListSelect`) `listLists`.
63
+
64
+ ## API gaps (found while building; not edited, the backend is colleague-owned)
65
+
66
+ | Asked for | Reality | What the block does |
67
+ |---|---|---|
68
+ | "Generated as drafts; nothing goes live until a deploy" | With approval mode `auto_publish` the render task marks every page `published` with a public address as it renders. | Confirms the render (external, sized by the source rows) in that mode and says so. |
69
+ | Re-render replaces pages | The render does not clear earlier pages and `cb_programmatic_pages` has no unique slug, so a second run can add a second set. | A visible warning before "Generate again"; delete duplicates with the bulk delete. |
70
+ | Read a page's HTML | No endpoint returns a generated page's HTML. | "Replace HTML" asks for the full replacement. |
71
+ | Filter or search generated pages | `listProgrammaticGeneratedPages` takes `offset` and `limit` only. | Search and the status filter act on the loaded pages and say so. |
72
+ | Map a variable to a differently named column | The workspace shows `source_column`, but the renderer matches `{{name}}` to a column of the same name only. | Only the rules the renderer reads are offered: fallback, character limit, cut rule. |
73
+ | Edit a running or finished campaign | `updateProgrammaticCampaign` answers 400 unless the status is draft or failed. | The Edit tab says so; source, variables and settings go through the workspace, which has no such rule. |
74
+ | Change the template | `updateProgrammaticCampaign` has no template field. | Shown read-only; make a new campaign. |
75
+ | Rename the source list | The list source carries no name. | Shown as "Current list" until the options load. |
76
+ | `deploy_pending_changes` count | The workspace counts pages in status `generated`; the API also redeploys live pages whose data changed. | The confirm shows the waiting count and the changed-row count. |
77
+ | Responses | Every programmatic response is an open object (`StudioContentPayload`); the list key is `campaigns` / `pages`, mirrored onto `items`. | Read defensively; a missing value shows a dash. |
78
+
79
+ ## Things that surprise people
80
+
81
+ - Page action `publish` and `regenerate` are aliases (`mark_ready_to_deploy`, `mark_for_regeneration`); marking ready does not put a page on the live site, only a publish does.
82
+ - Preview needs a numeric row id; workbench rows without one can only be previewed as "First row".