@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,210 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProgrammaticGeneratedPages: the generated pages of one campaign, with row and bulk lifecycle actions.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. listProgrammaticGeneratedPages (GET /programmatic-campaigns/{id}/pages) read 50 per read, offset paged
8
+ * 2. runProgrammaticPageAction (POST /programmatic-campaigns/{id}/pages/{page_id}/actions) external mark ready, unpublish, mark for
9
+ * regeneration, replace the HTML (override), undo the replacement. Unpublish takes the page OFF the live site: confirmed.
10
+ * 3. runProgrammaticBulkPageAction (POST /programmatic-campaigns/{id}/pages/actions) external the same on a selection; delete
11
+ * removes pages (destructive, confirmed); queue_search_console submits live URLs to Google (confirmed); export_urls only reads.
12
+ * A confirm counts exactly the pages the request sends: the selection is filtered once to the pages the action applies to (live
13
+ * pages for unpublish and Search Console, rendered drafts for mark ready) and that array is both counted and sent. The list is
14
+ * paged by the API without a filter, so search and the status filter work on the pages loaded so far and say so.
15
+ */
16
+
17
+ import { useEffect, useMemo, useRef, useState } from "react";
18
+ import { ExternalLink, FileDown, Trash2 } from "lucide-react";
19
+ import { Button, Skeleton, Textarea } from "@graph8/sdk/ui";
20
+ import { ApiError } from "@/g8/components/api-error";
21
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
22
+ import { EmptyState } from "@/g8/components/empty-state";
23
+ import { LoadMore } from "@/g8/components/paging-notices";
24
+ import { EmptyValue } from "@/g8/components/screen/key-value-list";
25
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
26
+ import { SelectFilter } from "@/g8/components/screen/filter-controls";
27
+ import { StatusPill } from "@/g8/components/screen/status-pill";
28
+ import { Toolbar } from "@/g8/components/screen/toolbar";
29
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
30
+ import { useLoadMoreList } from "@/g8/hooks/use-load-more-list";
31
+ import { invalidate } from "@/g8/lib/g8-cache";
32
+ import { g8Get, g8Post } from "@/g8/lib/g8-client";
33
+ import { byOffset } from "@/g8/lib/paging";
34
+ import { plural } from "@/g8/lib/plural";
35
+ import { asRec, num, rowsOf, shortDate } from "@/g8/pages/lib/studio";
36
+ import { PAGE_SIZE, PAGE_STATUSES, bulkTargets, isLive, pageActionBlock, wording } from "../lib/programmatic-types";
37
+ import type { BulkActionName, Campaign, GeneratedPage, PageActionName } from "../lib/programmatic-types";
38
+
39
+ export interface ProgrammaticGeneratedPagesProps {
40
+ campaign: Campaign;
41
+ }
42
+
43
+ type Pending = { kind: "page"; page: GeneratedPage; action: "unpublish" } | { kind: "bulk"; action: BulkActionName; ids: string[] };
44
+ const tone = (s?: string | null) => (s === "published" ? "positive" : s === "failed" ? "negative" : s === "generated" ? "info" : "neutral");
45
+ const LABELS: Record<string, string> = { mark_ready_to_deploy: "Mark ready", unpublish: "Unpublish", mark_for_regeneration: "Regenerate", override: "Replace HTML", reset_override: "Undo HTML edit" };
46
+
47
+ export function ProgrammaticGeneratedPages({ campaign }: ProgrammaticGeneratedPagesProps) {
48
+ const base = `/programmatic-campaigns/${encodeURIComponent(campaign.id)}`;
49
+ const total = useRef<number | undefined>(undefined);
50
+ const list = useLoadMoreList(byOffset<GeneratedPage>(PAGE_SIZE, async (offset, signal) => {
51
+ const data = asRec((await g8Get(`${base}/pages`, { limit: PAGE_SIZE, offset: offset || undefined }, { signal })).data);
52
+ const rows = rowsOf<GeneratedPage>(data, "pages");
53
+ if (offset === 0) total.current = num(data.total); // the first read of a campaign replaces the count; a later page never keeps another campaign's
54
+ return { rows, hasNext: total.current === undefined ? undefined : offset + rows.length < total.current };
55
+ }), [campaign.id]);
56
+ const pages = list.data ?? [];
57
+ const [search, setSearch] = useState("");
58
+ const [status, setStatus] = useState("");
59
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
60
+ const [pending, setPending] = useState<Pending | null>(null);
61
+ const [editing, setEditing] = useState<GeneratedPage | null>(null);
62
+ const [html, setHtml] = useState("");
63
+ const [busy, setBusy] = useState<string | null>(null);
64
+ const [note, setNote] = useState("");
65
+ const [urls, setUrls] = useState<string[] | null>(null);
66
+
67
+ // Selection follows the data: ids that are no longer loaded (a refetch, a delete) leave the selection.
68
+ useEffect(() => { setSelected((cur) => { const alive = new Set(pages.map((p) => p.id)); const next = new Set([...cur].filter((id) => alive.has(id))); return next.size === cur.size ? cur : next; }); }, [list.data]); // eslint-disable-line react-hooks/exhaustive-deps
69
+ useEffect(() => { setSelected(new Set()); setNote(""); }, [campaign.id]);
70
+
71
+ const shown = useMemo(() => {
72
+ const q = search.trim().toLowerCase();
73
+ return pages.filter((p) => (!status || p.status === status) && (!q || `${p.slug ?? ""} ${p.published_url ?? ""}`.toLowerCase().includes(q)));
74
+ }, [pages, search, status]);
75
+
76
+ const refresh = (): void => { void invalidate(["programmatic"]); void list.refetch(); };
77
+ const rowAction = useG8Mutation((v: { page: GeneratedPage; action: PageActionName; html?: string }) => {
78
+ const body: Record<string, unknown> = { action: v.action };
79
+ if (v.action === "override") { body.html_output = v.html; body.manual_override = { fields: ["html_output"] }; }
80
+ return g8Post(`${base}/pages/${encodeURIComponent(v.page.id)}/actions`, body);
81
+ }, { onSuccess: (_r, v) => { setBusy(null); setPending(null); setEditing(null); setNote(`${LABELS[v.action]} done for ${v.page.slug ?? "the page"}.`); refresh(); }, onError: () => setBusy(null) });
82
+ const bulk = useG8Mutation((v: { action: BulkActionName; ids: string[] }) => g8Post<{ urls?: string[]; count?: number }>(`${base}/pages/actions`, { action: v.action, page_ids: v.ids }), {
83
+ onSuccess: (res, v) => {
84
+ setPending(null);
85
+ const d = asRec(res.data);
86
+ if (v.action === "export_urls") setUrls(Array.isArray(d.urls) ? (d.urls as string[]) : []);
87
+ else { setSelected(new Set()); setNote(`${plural(num(d.count) ?? v.ids.length, "page")} updated.`); refresh(); }
88
+ },
89
+ });
90
+
91
+ const run = (page: GeneratedPage, action: PageActionName): void => {
92
+ rowAction.reset();
93
+ if (action === "unpublish") return setPending({ kind: "page", page, action });
94
+ if (action === "override") { setHtml(""); return setEditing(page); }
95
+ setBusy(page.id);
96
+ void rowAction.mutate({ page, action });
97
+ };
98
+ const ask = (action: BulkActionName): void => {
99
+ const ids = bulkTargets(pages, selected, action);
100
+ if (ids.length === 0) return;
101
+ if (action === "unpublish_selected" || action === "delete_selected" || action === "queue_search_console") { bulk.reset(); setPending({ kind: "bulk", action, ids }); }
102
+ else void bulk.mutate({ action, ids });
103
+ };
104
+ const toggle = (id: string): void => setSelected((cur) => { const n = new Set(cur); if (n.has(id)) n.delete(id); else n.add(id); return n; });
105
+ const allShown = shown.length > 0 && shown.every((p) => selected.has(p.id));
106
+ const count = (a: BulkActionName): number => bulkTargets(pages, selected, a).length;
107
+
108
+ const copy = pending?.kind === "bulk" ? pending : null;
109
+ const liveInPick = (ids: string[]): number => bulkTargets(pages, new Set(ids), "unpublish_selected").length;
110
+ const confirmText = pending?.kind === "page"
111
+ ? { tier: "external" as const, title: `Take ${pending.page.slug ?? "this page"} off the live site?`, body: "Visitors to its address will no longer see it. On a custom domain the last published copy may stay up until the next publish. You can mark it ready and publish it again.", label: "Unpublish", affected: 1 }
112
+ : copy?.action === "delete_selected"
113
+ ? { tier: "destructive" as const, title: `Delete ${plural(copy.ids.length, "page")}?`, body: `The pages are removed from this campaign. ${liveInPick(copy.ids) === 0 ? "None of them is live." : `${plural(liveInPick(copy.ids), "of them is", "of them are")} live.`} On a hosted address a deleted live page stops being served right away; on a custom domain its last published copy may stay up until the next publish. Unpublish first if you only want it offline.`, label: "Delete", affected: copy.ids.length }
114
+ : copy?.action === "queue_search_console"
115
+ ? { tier: "external" as const, title: `Send ${plural(copy.ids.length, "live page")} to Google Search Console?`, body: "The live addresses are queued for submission to the connected Search Console property.", label: "Send", affected: copy.ids.length }
116
+ : copy
117
+ ? { tier: "external" as const, title: `Unpublish ${plural(copy.ids.length, "live page")}?`, body: "Visitors to their addresses will no longer see them (on a custom domain the last published copy may stay up until the next publish). Pages that are not live are left out.", label: "Unpublish", affected: copy.ids.length }
118
+ : null;
119
+ const confirming = rowAction.loading || bulk.loading;
120
+
121
+ if (list.error && !list.data) return <ApiError error={list.error} title="Could not load the pages" onRetry={() => void list.refetch()} />;
122
+ if (!list.data) return <div aria-busy="true" className="grid gap-2"><Skeleton className="h-10 w-full" /><Skeleton className="h-10 w-full" /></div>;
123
+ if (pages.length === 0) return <EmptyState title="No pages yet" description="Render the pages from the Overview tab. One page is made for every row of the source." />;
124
+ return (
125
+ <div className="grid gap-3">
126
+ <Toolbar
127
+ search={{ value: search, onChange: setSearch, placeholder: "Search loaded pages" }}
128
+ filters={<SelectFilter label="page status" value={status} onChange={setStatus} any="Any status" options={PAGE_STATUSES.map((s) => ({ value: s, label: wording(s) }))} />}
129
+ />
130
+ <p role="status" className="text-muted-foreground text-xs">
131
+ {shown.length === pages.length ? `${pages.length.toLocaleString()} of ${(total.current ?? pages.length).toLocaleString()} pages loaded` : `${shown.length.toLocaleString()} of ${pages.length.toLocaleString()} loaded pages match`}
132
+ {list.hasMore ? ". Search and the filter look only at the pages loaded so far." : ""}
133
+ </p>
134
+ {selected.size > 0 ? (
135
+ <div role="group" aria-label="Actions for the selected pages" className="bg-muted flex flex-wrap items-center gap-2 rounded-md p-2 text-sm">
136
+ <span className="mr-2">{plural(selected.size, "page")} selected</span>
137
+ <Button size="sm" variant="outline" disabled={count("mark_ready_to_deploy_selected") === 0} loading={bulk.loading} onClick={() => ask("mark_ready_to_deploy_selected")}>Mark {count("mark_ready_to_deploy_selected")} ready</Button>
138
+ <Button size="sm" variant="outline" disabled={count("mark_for_regeneration_selected") === 0} onClick={() => ask("mark_for_regeneration_selected")}>Regenerate {count("mark_for_regeneration_selected")}</Button>
139
+ <Button size="sm" variant="outline" disabled={count("unpublish_selected") === 0} onClick={() => ask("unpublish_selected")}>Unpublish {count("unpublish_selected")}</Button>
140
+ <Button size="sm" variant="outline" disabled={count("queue_search_console") === 0} onClick={() => ask("queue_search_console")}>Send {count("queue_search_console")} to Search Console</Button>
141
+ <Button size="sm" variant="outline" disabled={count("export_urls") === 0} onClick={() => ask("export_urls")}><FileDown className="size-4" />Export {count("export_urls")} addresses</Button>
142
+ <Button size="sm" variant="outline" onClick={() => ask("delete_selected")}><Trash2 className="size-4" />Delete {count("delete_selected")}</Button>
143
+ </div>
144
+ ) : null}
145
+ {note ? <p role="status" className="text-xs">{note}</p> : null}
146
+ {!pending && bulk.error ? <ApiError error={bulk.error} title="Could not run the action" /> : null}
147
+ {!pending && !editing && rowAction.error ? <ApiError error={rowAction.error} title="Could not run the action" /> : null}
148
+ <div className="overflow-x-auto rounded-md border">
149
+ <table className="w-full text-sm">
150
+ <thead className="text-muted-foreground text-left text-xs">
151
+ <tr>
152
+ <th className="w-8 p-2"><input type="checkbox" aria-label="Select all shown pages" checked={allShown} onChange={() => setSelected(allShown ? new Set() : new Set(shown.map((p) => p.id)))} /></th>
153
+ <th className="p-2">Page</th><th className="p-2">Status</th><th className="p-2">Changes</th><th className="p-2">Search score</th><th className="p-2">Rendered</th><th className="p-2 text-right">Actions</th>
154
+ </tr>
155
+ </thead>
156
+ <tbody>
157
+ {shown.length === 0 ? <tr><td colSpan={7} className="text-muted-foreground p-4 text-center">No loaded page matches.</td></tr> : shown.map((p) => (
158
+ <tr key={p.id} className="border-t align-top">
159
+ <td className="p-2"><input type="checkbox" aria-label={`Select ${p.slug ?? "page"}`} checked={selected.has(p.id)} onChange={() => toggle(p.id)} /></td>
160
+ <td className="p-2">
161
+ <span className="block font-medium">{p.slug || <EmptyValue />}</span>
162
+ {p.published_url ? <a className="text-muted-foreground inline-flex items-center gap-1 text-xs underline" href={p.published_url} target="_blank" rel="noopener noreferrer">Open live page<ExternalLink className="size-3" /></a> : null}
163
+ {p.error_message ? <span className="text-error block text-xs">{p.error_message}</span> : null}
164
+ </td>
165
+ <td className="p-2"><StatusPill tone={tone(p.status)}>{p.status ? wording(p.status) : "Unknown"}</StatusPill></td>
166
+ <td className="p-2">{p.change_state && p.change_state !== "no_changes" ? wording(p.change_state) : <EmptyValue />}</td>
167
+ <td className="p-2">{p.seo_health?.score == null ? <EmptyValue /> : p.seo_health.score}</td>
168
+ <td className="p-2">{shortDate(p.last_generated_at) || <EmptyValue />}</td>
169
+ <td className="p-2"><div className="flex flex-wrap justify-end gap-1">
170
+ {(["mark_ready_to_deploy", "unpublish", "mark_for_regeneration", "override", "reset_override"] as PageActionName[]).map((a) => {
171
+ const why = pageActionBlock(p, a);
172
+ return <Button key={a} size="sm" variant="ghost" aria-label={`${LABELS[a]} ${p.slug ?? "page"}`} title={why || undefined} disabled={Boolean(why) || busy === p.id} loading={busy === p.id && a !== "unpublish" && a !== "override"} onClick={() => run(p, a)}>{LABELS[a]}</Button>;
173
+ })}
174
+ </div></td>
175
+ </tr>
176
+ ))}
177
+ </tbody>
178
+ </table>
179
+ </div>
180
+ <LoadMore shown={pages.length} noun="pages" hasMore={list.hasMore} loading={list.loadingMore} onLoadMore={list.loadMore} />
181
+ <FormSheet open={editing !== null} onOpenChange={(o) => { if (!o) setEditing(null); }} title={`Replace the HTML of ${editing?.slug ?? "this page"}`} description="Your HTML replaces the rendered page. The API removes scripts, forms, styles and event handlers from it. The page keeps its edit until you undo it or regenerate.">
182
+ <div className="grid gap-3">
183
+ <Textarea aria-label="Page HTML" rows={14} value={html} onChange={(e) => setHtml(e.target.value)} placeholder="Paste the complete page HTML" />
184
+ <p className="text-muted-foreground text-xs">The API does not return a page&apos;s current HTML, so paste the whole replacement. {isLive(editing ?? {} as GeneratedPage) ? "This page is live; the edit reaches visitors after the next publish." : ""}</p>
185
+ {rowAction.error ? <ApiError error={rowAction.error} title="Could not replace the HTML" /> : null}
186
+ <div className="flex gap-2">
187
+ <Button loading={rowAction.loading} disabled={html.trim() === "" || !editing} onClick={() => editing && void rowAction.mutate({ page: editing, action: "override", html })}>Replace HTML</Button>
188
+ <Button variant="outline" onClick={() => setEditing(null)}>Cancel</Button>
189
+ </div>
190
+ </div>
191
+ </FormSheet>
192
+ <FormSheet open={urls !== null} onOpenChange={(o) => { if (!o) setUrls(null); }} title="Page addresses" description={`${plural(urls?.length ?? 0, "live address")} for the selected pages.`}>
193
+ {urls && urls.length === 0 ? <p className="text-muted-foreground text-sm">None of the selected pages has a live address.</p> : <Textarea aria-label="Page addresses" readOnly rows={12} value={(urls ?? []).join("\n")} />}
194
+ </FormSheet>
195
+ {confirmText ? (
196
+ <ConfirmSpendDialog
197
+ open
198
+ tier={confirmText.tier}
199
+ title={confirmText.title}
200
+ affected={confirmText.affected}
201
+ description={<><p>{confirmText.body}</p>{pending && (bulk.error || rowAction.error) ? <ApiError error={bulk.error ?? rowAction.error} title="Could not run the action" /> : null}</>}
202
+ confirmLabel={confirmText.label}
203
+ confirming={confirming}
204
+ onCancel={() => setPending(null)}
205
+ onConfirm={() => (pending?.kind === "page" ? (setBusy(pending.page.id), void rowAction.mutate({ page: pending.page, action: "unpublish" })) : pending && void bulk.mutate({ action: pending.action, ids: pending.ids }))}
206
+ />
207
+ ) : null}
208
+ </div>
209
+ );
210
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProgrammaticOverview: where a campaign stands: readiness checks, the numbers, the template, search health and what the next
5
+ * render and publish will do. Hosts the Generate and Publish panels.
6
+ *
7
+ * SDK operations, in order:
8
+ * 1. getProgrammaticWorkspace (GET /programmatic-campaigns/{id}/workspace) read readiness, counts, search, links, deployment
9
+ * 2. getLandingPage (GET /landing-pages/{landing_page_id}) read the template page's name, only when the workspace names it
10
+ * Generating and publishing live in ProgrammaticGenerate and ProgrammaticPublish, which confirm what reaches the live site.
11
+ * A missing number is shown as a dash, never as 0.
12
+ */
13
+
14
+ import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
15
+ import { Skeleton } from "@graph8/sdk/ui";
16
+ import { ApiError } from "@/g8/components/api-error";
17
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
18
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
19
+ import { g8Get } from "@/g8/lib/g8-client";
20
+ import { asRec, shortDate, text } from "@/g8/pages/lib/studio";
21
+ import { useProgrammaticWorkspace } from "../hooks/use-programmatic-workspace";
22
+ import type { Campaign } from "../lib/programmatic-types";
23
+ import { wording } from "../lib/programmatic-types";
24
+ import { ProgrammaticGenerate } from "./programmatic-generate";
25
+ import { ProgrammaticPublish } from "./programmatic-publish";
26
+
27
+ export interface ProgrammaticOverviewProps {
28
+ campaign: Campaign;
29
+ /** Where the template page is edited. Default: the pages screen at /pages?id=. */
30
+ pagesHref?: (templatePageId: string) => string;
31
+ }
32
+
33
+ const n = (v: number | null | undefined): string | null => (v == null ? null : v.toLocaleString());
34
+
35
+ export function ProgrammaticOverview({ campaign, pagesHref = (id) => `/pages?id=${encodeURIComponent(id)}` }: ProgrammaticOverviewProps) {
36
+ const ws = useProgrammaticWorkspace(campaign.id);
37
+ const templateId = ws.data?.template?.template_page_id ?? campaign.template_page_id ?? "";
38
+ const template = useG8Query(
39
+ (signal) => g8Get(`/landing-pages/${encodeURIComponent(templateId)}`, undefined, { signal }).then((r) => asRec(r.data)),
40
+ [templateId], { enabled: Boolean(templateId), key: ["programmatic", "template", templateId] },
41
+ );
42
+ if (ws.error && !ws.data) return <ApiError error={ws.error} title="Could not load the campaign" onRetry={() => void ws.refetch()} />;
43
+ if (!ws.data) return <Skeleton className="h-40 w-full" aria-busy="true" />;
44
+ const o = ws.data.overview ?? {};
45
+ const seo = ws.data.seo;
46
+ const issues = Object.entries(seo?.issues ?? {}).filter(([, c]) => c > 0);
47
+ const name = text(template.data?.name);
48
+ return (
49
+ <div className="grid gap-4">
50
+ <ul aria-label="Readiness" className="grid gap-1 text-sm">
51
+ {(o.readiness ?? []).map((r) => (
52
+ <li key={r.key} className="flex items-center gap-2">
53
+ {r.complete ? <CheckCircle2 className="size-4" aria-label="Done" /> : <Circle className="text-muted-foreground size-4" aria-label="Not done" />}
54
+ <span>{r.label}</span>
55
+ {!r.complete && r.details?.length ? <span className="text-muted-foreground text-xs">Missing: {r.details.join(", ")}</span> : null}
56
+ </li>
57
+ ))}
58
+ </ul>
59
+ <KeyValueList columns={2} items={[
60
+ { label: "Source rows", value: n(o.total_rows) },
61
+ { label: "Pages rendered", value: n(o.generated_pages) },
62
+ { label: "Live", value: n(o.live_pages) },
63
+ { label: "Failed", value: n(o.failed_pages) },
64
+ { label: "Changed since render", value: n(o.changed_rows) },
65
+ { label: "Hand-edited", value: n(o.manual_overrides) },
66
+ { label: "Last render", value: shortDate(o.last_generation) || null },
67
+ { label: "Last publish", value: shortDate(o.last_publish) || null },
68
+ { label: "Next automatic render", value: o.next_regeneration || null },
69
+ { label: "Search score (average)", value: seo?.average_score == null ? null : String(seo.average_score) },
70
+ { label: "Search issues", value: issues.length ? issues.map(([k, c]) => `${wording(k)}: ${c}`).join(", ") : null, wide: true },
71
+ { label: "Pages with no links in", value: n(ws.data.links?.orphan_pages?.length) },
72
+ { label: "Address pattern", value: ws.data.template?.slug_template ?? campaign.slug_template ?? null, mono: true },
73
+ {
74
+ label: "Template page",
75
+ wide: true,
76
+ value: templateId ? <a className="inline-flex items-center gap-1 underline" href={pagesHref(templateId)}>{name || "Open the template page"}<ExternalLink className="size-3" /></a> : null,
77
+ },
78
+ ]} />
79
+ {campaign.error_message ? <p role="alert" className="text-error text-sm">{campaign.error_message}</p> : null}
80
+ <ProgrammaticGenerate campaign={campaign} />
81
+ <ProgrammaticPublish campaign={campaign} />
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,221 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProgrammaticPagesScreen: the finished workspace for programmatic pages: one template landing page plus a data source makes one
5
+ * page per row (a page per account, per city, per integration). No props needed.
6
+ *
7
+ * Composes: ScreenLayout (title, stats), Toolbar (search, status and source filters), SplitView + DetailPanel (the selected
8
+ * campaign, deep link `?id=`, detail tab `?tab=`), FormSheet (new campaign via ProgrammaticCampaignForm), ProgrammaticOverview
9
+ * (with ProgrammaticGenerate and ProgrammaticPublish), ProgrammaticGeneratedPages, ProgrammaticPreview, ProgrammaticVariables,
10
+ * ProgrammaticSource, ProgrammaticSettings and the form again in edit mode.
11
+ *
12
+ * User flows: see every campaign with its state, source and page counts (search; filter by state and source); create one from a
13
+ * template page and a list or table; open it to see its readiness and numbers; render all pages (free; confirmed when the approval
14
+ * mode publishes automatically) and follow the run; read, search and filter the generated pages, replace one page's HTML, mark pages
15
+ * ready, regenerate them, take them off the live site (confirmed), delete them (confirmed), export their addresses, send live ones to
16
+ * Search Console (confirmed); publish waiting pages, republish everything or roll the last publish back (each confirmed with its
17
+ * page count); preview one row; set per-variable fallbacks and limits; switch or import the source (a sitemap); set regeneration,
18
+ * publishing, search and link settings; edit the name and address pattern; delete a campaign (confirmed). The template page is edited
19
+ * in the pages screen, linked from the Overview.
20
+ *
21
+ * SDK operations: listProgrammaticCampaigns, deleteProgrammaticCampaign (destructive, confirmed); everything else lives in the
22
+ * composed parts, which confirm every action that changes the live site.
23
+ */
24
+
25
+ import { useState } from "react";
26
+ import type { ReactNode } from "react";
27
+ import { Layers, Plus, Trash2 } from "lucide-react";
28
+ import { Button, Skeleton } from "@graph8/sdk/ui";
29
+ import { ApiError } from "@/g8/components/api-error";
30
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
31
+ import { EmptyState } from "@/g8/components/empty-state";
32
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
33
+ import { SelectFilter } from "@/g8/components/screen/filter-controls";
34
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
35
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
36
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
37
+ import { SplitView } from "@/g8/components/screen/split-view";
38
+ import { StatusPill } from "@/g8/components/screen/status-pill";
39
+ import { Toolbar } from "@/g8/components/screen/toolbar";
40
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
41
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
42
+ import { useUrlState } from "@/g8/hooks/use-url-state";
43
+ import { g8Delete, g8Get } from "@/g8/lib/g8-client";
44
+ import { plural } from "@/g8/lib/plural";
45
+ import { shortDate } from "@/g8/pages/lib/studio";
46
+ import { CAMPAIGN_STATUSES, SOURCE_TYPES, campaignRows, sourceOf, wording } from "../lib/programmatic-types";
47
+ import type { Campaign } from "../lib/programmatic-types";
48
+ import { ProgrammaticCampaignForm } from "./programmatic-campaign-form";
49
+ import { ProgrammaticGeneratedPages } from "./programmatic-generated-pages";
50
+ import { ProgrammaticOverview } from "./programmatic-overview";
51
+ import { ProgrammaticPreview } from "./programmatic-preview";
52
+ import { ProgrammaticSettings } from "./programmatic-settings";
53
+ import { ProgrammaticSource } from "./programmatic-source";
54
+ import { ProgrammaticVariables } from "./programmatic-variables";
55
+
56
+ export interface ProgrammaticPagesScreenProps {
57
+ /** Where a campaign's template page is edited. Default: /pages?id=<id> (the pages screen). */
58
+ pagesHref?: (templatePageId: string) => string;
59
+ renderRowActions?: (campaign: Campaign) => ReactNode;
60
+ renderDetailActions?: (campaign: Campaign) => ReactNode;
61
+ extraDetailTabs?: { id: string; label: string; render: (campaign: Campaign) => ReactNode }[];
62
+ toolbarActions?: ReactNode;
63
+ className?: string;
64
+ }
65
+
66
+ const tone = (s?: string | null) => (s === "completed" ? "positive" : s === "failed" ? "negative" : s === "generating" || s === "validating" ? "info" : "neutral");
67
+ const sourceLabel = (c: Campaign): string => SOURCE_TYPES.find((t) => t.value === sourceOf(c).type)?.label ?? "";
68
+ const sum = (rows: Campaign[], pick: (c: Campaign) => number | null | undefined): number | null => {
69
+ const known = rows.map(pick).filter((v): v is number => typeof v === "number");
70
+ return known.length ? known.reduce((a, b) => a + b, 0) : null;
71
+ };
72
+
73
+ export function ProgrammaticPagesScreen({ pagesHref, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, className }: ProgrammaticPagesScreenProps) {
74
+ const [id, setId] = useUrlState("id", "");
75
+ const [view, setView] = useUrlState("view", "");
76
+ const [tab, setTab] = useUrlState("tab", "overview");
77
+ const expandProps = { onExpand: () => setView("full"), onCollapse: view === "full" ? () => setView(null) : undefined };
78
+ const [search, setSearch] = useState("");
79
+ const [status, setStatus] = useState("");
80
+ const [source, setSource] = useState("");
81
+ const [creating, setCreating] = useState(false);
82
+ const [toDelete, setToDelete] = useState<Campaign | null>(null);
83
+
84
+ const list = useG8Query((signal) => g8Get("/programmatic-campaigns", {}, { signal }).then((r) => campaignRows(r.data)), [], { key: ["programmatic", "list"] });
85
+ const remove = useG8Mutation((c: Campaign) => g8Delete(`/programmatic-campaigns/${encodeURIComponent(c.id)}`), {
86
+ onSuccess: (_r, c) => { setToDelete(null); if (c.id === id) { setId(null); setView(null); } },
87
+ invalidates: [["programmatic"]],
88
+ });
89
+
90
+ const all = list.data ?? [];
91
+ const q = search.trim().toLowerCase();
92
+ const shown = all.filter((c) => (!status || c.status === status) && (!source || sourceOf(c).type === source) && (!q || `${c.name} ${c.slug_template ?? ""}`.toLowerCase().includes(q)));
93
+ const selected = all.find((c) => c.id === id);
94
+ const closeDetail = (): void => { setId(null); setView(null); setTab(null); };
95
+ const rendered = sum(all, (c) => c.pages_generated);
96
+ const failed = sum(all, (c) => c.pages_failed);
97
+
98
+ const rows = list.error ? <ApiError error={list.error} title="Could not load programmatic campaigns" onRetry={() => void list.refetch()} /> : list.loading && !list.data ? (
99
+ <div aria-busy="true" className="grid gap-2"><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /></div>
100
+ ) : shown.length === 0 ? (
101
+ <EmptyState icon={<Layers className="size-8" />} title={all.length === 0 ? "No programmatic campaigns yet" : "No campaigns match"} description={all.length === 0 ? "Pick a template landing page and a list or table. One page is made for every row: a page per account, per city, per integration." : "Try another search or filter."} action={all.length === 0 ? <Button onClick={() => setCreating(true)}>Create your first campaign</Button> : undefined} />
102
+ ) : (
103
+ <>
104
+ <p role="status" className="text-muted-foreground mb-2 text-xs">{shown.length === all.length ? `All ${plural(all.length, "campaign")}` : `${shown.length.toLocaleString()} of ${plural(all.length, "campaign")}`}</p>
105
+ <ul className="grid gap-2">
106
+ {shown.map((c) => (
107
+ <li key={c.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 ${c.id === id ? "bg-muted" : ""}`}>
108
+ <button type="button" className="min-w-0 flex-1 text-left" onClick={() => setId(c.id)}>
109
+ <span className="block truncate font-medium">{c.name}</span>
110
+ <span className="text-muted-foreground block truncate text-xs">{sourceLabel(c) || "Source unknown"} · {c.pages_generated == null ? "-" : c.pages_generated.toLocaleString()} of {c.total_pages == null ? "-" : c.total_pages.toLocaleString()} pages · {shortDate(c.last_generated_at) || "not rendered"}</span>
111
+ </button>
112
+ <StatusPill tone={tone(c.status)}>{c.status ? wording(c.status) : "Unknown"}</StatusPill>
113
+ <div className="flex gap-1">
114
+ <Button size="sm" variant="ghost" aria-label={`Delete ${c.name}`} onClick={() => { remove.reset(); setToDelete(c); }}><Trash2 className="size-4" /></Button>
115
+ {renderRowActions?.(c)}
116
+ </div>
117
+ </li>
118
+ ))}
119
+ </ul>
120
+ </>
121
+ );
122
+
123
+ const detail = selected ? (
124
+ <DetailPanel
125
+ title={selected.name}
126
+ subtitle={selected.slug_template ?? undefined}
127
+ badges={<StatusPill tone={tone(selected.status)}>{selected.status ? wording(selected.status) : "Unknown"}</StatusPill>}
128
+ actions={renderDetailActions?.(selected)}
129
+ onClose={closeDetail}
130
+ {...expandProps}
131
+ activeTab={tab}
132
+ onTabChange={(t) => setTab(t === "overview" ? null : t)}
133
+ tabs={[
134
+ { id: "overview", label: "Overview", content: <ProgrammaticOverview key={selected.id} campaign={selected} pagesHref={pagesHref} /> },
135
+ { id: "pages", label: "Pages", content: <ProgrammaticGeneratedPages key={selected.id} campaign={selected} /> },
136
+ { id: "preview", label: "Preview", content: <ProgrammaticPreview key={selected.id} campaign={selected} /> },
137
+ { id: "variables", label: "Variables", content: <ProgrammaticVariables key={selected.id} campaign={selected} /> },
138
+ { id: "source", label: "Source", content: <ProgrammaticSource key={selected.id} campaign={selected} /> },
139
+ { id: "settings", label: "Settings", content: <ProgrammaticSettings key={selected.id} campaign={selected} /> },
140
+ { id: "edit", label: "Edit", content: <ProgrammaticCampaignForm key={selected.id} campaign={selected} onSaved={() => void list.refetch()} /> },
141
+ { id: "details", label: "Details", content: <CampaignDetails campaign={selected} /> },
142
+ ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
143
+ ]}
144
+ />
145
+ ) : null;
146
+
147
+ return (
148
+ <ScreenLayout
149
+ className={className}
150
+ title="Programmatic pages"
151
+ description="One template page and one data source make a page for every row: a page per account, per city, per integration."
152
+ icon={<Layers className="size-5" />}
153
+ actions={<Button size="sm" onClick={() => setCreating(true)}><Plus className="size-4" />New campaign</Button>}
154
+ stats={[
155
+ { label: "Campaigns", value: list.data ? all.length.toLocaleString() : "..." },
156
+ { label: "Pages rendered", value: list.data ? (rendered == null ? "-" : rendered.toLocaleString()) : "..." },
157
+ { label: "Failed pages", value: list.data ? (failed == null ? "-" : failed.toLocaleString()) : "...", tone: failed ? "negative" : undefined },
158
+ { label: "Rendering now", value: list.data ? all.filter((c) => c.status === "generating").length.toLocaleString() : "...", tone: "positive" },
159
+ ]}
160
+ >
161
+ <SplitView
162
+ list={(
163
+ <div className="grid gap-3">
164
+ <Toolbar
165
+ search={{ value: search, onChange: setSearch, placeholder: "Search campaigns" }}
166
+ filters={(<>
167
+ <SelectFilter label="state" value={status} onChange={setStatus} any="Any state" options={CAMPAIGN_STATUSES.map((s) => ({ value: s, label: wording(s) }))} />
168
+ <SelectFilter label="source" value={source} onChange={setSource} any="Any source" options={SOURCE_TYPES.map((t) => ({ value: t.value, label: t.label }))} />
169
+ </>)}
170
+ actions={toolbarActions}
171
+ />
172
+ {rows}
173
+ </div>
174
+ )}
175
+ detail={detail}
176
+ detailOpen={Boolean(selected)}
177
+ onCloseDetail={closeDetail}
178
+ expanded={view === "full"}
179
+ onExpandedChange={(e) => setView(e ? "full" : null)}
180
+ detailWidth="xl"
181
+ />
182
+ <FormSheet open={creating} onOpenChange={setCreating} title="New programmatic campaign" description="Nothing is rendered or published until you ask.">
183
+ {creating ? <ProgrammaticCampaignForm onCancel={() => setCreating(false)} onSaved={(created) => { setCreating(false); void list.refetch(); setTab(null); setId(created.id); }} /> : null}
184
+ </FormSheet>
185
+ <ConfirmSpendDialog
186
+ open={toDelete !== null}
187
+ tier="destructive"
188
+ title={`Delete ${toDelete?.name ?? "this campaign"} and at least its ${plural(toDelete ? (toDelete.pages_generated ?? 0) + (toDelete.pages_failed ?? 0) : 0, "page")}?`}
189
+ affected={toDelete ? (toDelete.pages_generated ?? 0) + (toDelete.pages_failed ?? 0) : undefined}
190
+ description={<><p>The campaign and every page it generated are removed. The count is at least this many: a re-run keeps earlier pages, so more can go. On a hosted address a live page stops being served as soon as it is deleted; on a custom domain its last published copy may stay up until the next publish. Unpublish first if you only want pages offline.</p>{remove.error ? <ApiError error={remove.error} title="Could not delete the campaign" /> : null}</>}
191
+ confirmLabel="Delete"
192
+ confirming={remove.loading}
193
+ onCancel={() => setToDelete(null)}
194
+ onConfirm={() => { if (toDelete) void remove.mutate(toDelete); }}
195
+ />
196
+ </ScreenLayout>
197
+ );
198
+ }
199
+
200
+ function CampaignDetails({ campaign }: { campaign: Campaign }) {
201
+ const src = sourceOf(campaign);
202
+ return (
203
+ <KeyValueList
204
+ columns={2}
205
+ items={[
206
+ { label: "Name", value: campaign.name },
207
+ { label: "State", value: campaign.status ? wording(campaign.status) : null },
208
+ { label: "Lifecycle", value: campaign.lifecycle_state ? wording(campaign.lifecycle_state) : null },
209
+ { label: "Changes", value: campaign.change_state && campaign.change_state !== "no_changes" ? wording(campaign.change_state) : null },
210
+ { label: "Source", value: sourceLabel(campaign) || null },
211
+ { label: "Source name", value: src.name || null },
212
+ { label: "Address pattern", value: campaign.slug_template ?? null, mono: true, wide: true },
213
+ { label: "Pages rendered", value: campaign.pages_generated == null ? null : campaign.pages_generated.toLocaleString() },
214
+ { label: "Pages failed", value: campaign.pages_failed == null ? null : campaign.pages_failed.toLocaleString() },
215
+ { label: "Created", value: shortDate(campaign.created_at) || null },
216
+ { label: "Updated", value: shortDate(campaign.updated_at) || null },
217
+ { label: "Campaign id", value: campaign.id, mono: true },
218
+ ]}
219
+ />
220
+ );
221
+ }
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProgrammaticPreview: see one page rendered for one source row, without saving anything.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. getProgrammaticWorkspace (GET /programmatic-campaigns/{id}/workspace) read the first sample rows of the source
8
+ * 2. previewProgrammaticPage (POST /programmatic-campaigns/{id}/preview) write renders one row; FREE, saves nothing
9
+ * A row is chosen from the sample rows the workspace returns (only rows with a numeric id can be asked for by id); "First row" sends
10
+ * no row at all and the API uses the source's first row. The HTML is shown in a sandboxed frame with scripts off.
11
+ */
12
+
13
+ import { useState } from "react";
14
+ import { Eye } from "lucide-react";
15
+ import { Button, Label } from "@graph8/sdk/ui";
16
+ import { ApiError } from "@/g8/components/api-error";
17
+ import { EmptyValue, KeyValueList } from "@/g8/components/screen/key-value-list";
18
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
19
+ import { g8Post } from "@/g8/lib/g8-client";
20
+ import { text } from "@/g8/pages/lib/studio";
21
+ import type { Rec } from "@/g8/pages/lib/studio";
22
+ import { useProgrammaticWorkspace } from "../hooks/use-programmatic-workspace";
23
+ import type { Campaign } from "../lib/programmatic-types";
24
+
25
+ export interface ProgrammaticPreviewProps {
26
+ campaign: Campaign;
27
+ }
28
+
29
+ interface Rendered { html?: string; slug?: string; contact_id?: number | null; contact_name?: string }
30
+ const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
31
+
32
+ /** A readable name for a source row: a person, company or title field, else its position. */
33
+ export function rowLabel(row: Rec, index: number): string {
34
+ const person = [text(row.first_name), text(row.last_name)].filter(Boolean).join(" ");
35
+ return person || text(row.name) || text(row.company) || text(row.company_name) || text(row.title) || text(row.email) || `Row ${index + 1}`;
36
+ }
37
+
38
+ export function ProgrammaticPreview({ campaign }: ProgrammaticPreviewProps) {
39
+ const ws = useProgrammaticWorkspace(campaign.id);
40
+ const [rowId, setRowId] = useState("");
41
+ const rows = (ws.data?.data?.sample_rows ?? []).map((r, i) => ({ id: typeof r.id === "number" ? r.id : null, label: rowLabel(r, i) })).filter((r): r is { id: number; label: string } => r.id !== null);
42
+ const render = useG8Mutation((id: string) => g8Post<Rendered>(`/programmatic-campaigns/${encodeURIComponent(campaign.id)}/preview`, id ? { contact_id: Number(id) } : {}));
43
+ const out = render.data?.data;
44
+ return (
45
+ <section aria-label="Preview" className="grid gap-3">
46
+ <div className="flex flex-wrap items-end gap-2">
47
+ <div>
48
+ <Label htmlFor="g8-pp-prev-row" className="mb-1">Source row</Label>
49
+ <select id="g8-pp-prev-row" className={SELECT} value={rowId} onChange={(e) => setRowId(e.target.value)}>
50
+ <option value="">First row</option>
51
+ {rows.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
52
+ </select>
53
+ </div>
54
+ <Button size="sm" loading={render.loading} onClick={() => void render.mutate(rowId)}><Eye className="size-4" />Render preview</Button>
55
+ </div>
56
+ {ws.error ? <ApiError error={ws.error} title="Could not load the source rows" onRetry={() => void ws.refetch()} /> : null}
57
+ {render.error ? <ApiError error={render.error} title="Could not render the preview" /> : null}
58
+ {out ? (
59
+ <>
60
+ <KeyValueList columns={2} items={[{ label: "Row", value: out.contact_name || null }, { label: "Page address", value: out.slug || null, mono: true }]} />
61
+ {out.html ? <iframe title="Page preview" sandbox="" srcDoc={out.html} className="bg-background h-[28rem] w-full rounded-md border" /> : <p className="text-muted-foreground text-sm">The API returned no HTML <EmptyValue /></p>}
62
+ </>
63
+ ) : <p className="text-muted-foreground text-sm">Pick a row and render it to see the finished page. Nothing is saved.</p>}
64
+ </section>
65
+ );
66
+ }