@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,102 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProgrammaticPublish: put the campaign's pages on the live site, republish them, or roll the last publish back.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. getProgrammaticWorkspace (GET /programmatic-campaigns/{id}/workspace) read pages waiting, pages live, publish history
8
+ * 2. runProgrammaticBulkPageAction (POST /programmatic-campaigns/{id}/pages/actions) external deploy_pending_changes, republish_all,
9
+ * rollback_publish. Each changes what real visitors see, so each is confirmed with the number of pages it touches.
10
+ * The numbers in the confirm come from the workspace counts: pages waiting (rendered, not live), pages live, and what a
11
+ * republish covers (every rendered page that did not fail, drafts included). Publishing needs a connected Cloudflare site or the
12
+ * workspace's hosting; when neither is configured the API says so and the message is shown. Nothing is sent without the confirm.
13
+ */
14
+
15
+ import { useState } from "react";
16
+ import { Rocket, RotateCcw } from "lucide-react";
17
+ import { Button, Skeleton } from "@graph8/sdk/ui";
18
+ import { ApiError } from "@/g8/components/api-error";
19
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
20
+ import { EmptyValue, KeyValueList } from "@/g8/components/screen/key-value-list";
21
+ import { StatusPill } from "@/g8/components/screen/status-pill";
22
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
+ import { invalidate } from "@/g8/lib/g8-cache";
24
+ import { g8Post } from "@/g8/lib/g8-client";
25
+ import { plural } from "@/g8/lib/plural";
26
+ import { asRec, num, shortDate, text } from "@/g8/pages/lib/studio";
27
+ import { useProgrammaticWorkspace } from "../hooks/use-programmatic-workspace";
28
+ import type { Campaign, DeployActionName } from "../lib/programmatic-types";
29
+
30
+ export interface ProgrammaticPublishProps {
31
+ campaign: Campaign;
32
+ }
33
+
34
+ export function ProgrammaticPublish({ campaign }: ProgrammaticPublishProps) {
35
+ const ws = useProgrammaticWorkspace(campaign.id);
36
+ const [asking, setAsking] = useState<DeployActionName | null>(null);
37
+ const [done, setDone] = useState("");
38
+ const run = useG8Mutation((action: DeployActionName) => g8Post(`/programmatic-campaigns/${encodeURIComponent(campaign.id)}/pages/actions`, { action }), {
39
+ onSuccess: (res, action) => {
40
+ const d = asRec(res.data);
41
+ setAsking(null);
42
+ setDone(`${action === "rollback_publish" ? "Rolled back" : "Published"} ${plural(num(d.count) ?? 0, "page")}.${text(d.deployment_url) ? ` Site: ${text(d.deployment_url)}` : ""}`);
43
+ void invalidate(["programmatic"]);
44
+ },
45
+ });
46
+ if (ws.error && !ws.data) return <ApiError error={ws.error} title="Could not load the publishing state" onRetry={() => void ws.refetch()} />;
47
+ if (!ws.data) return <Skeleton className="h-24 w-full" aria-busy="true" />;
48
+
49
+ const counts = ws.data.pages?.status_counts ?? {};
50
+ const waiting = ws.data.deployment?.pending_pages ?? counts.generated ?? null;
51
+ const live = ws.data.deployment?.published_pages ?? counts.published ?? null;
52
+ const covered = (counts.generated ?? 0) + (counts.published ?? 0);
53
+ const changed = ws.data.overview?.changed_rows ?? null;
54
+ const last = ws.data.deployment?.diagnostics;
55
+ const history = ws.data.deployment?.history ?? [];
56
+ const canRollback = Boolean(ws.data.campaign?.deployment_run_id ?? campaign.deployment_run_id);
57
+ const nothingWaiting = (waiting ?? 0) === 0 && (changed ?? 0) === 0;
58
+ const copy: Record<DeployActionName, { title: string; body: string; affected: number | undefined; label: string }> = {
59
+ deploy_pending_changes: { title: `Publish ${waiting == null ? "the waiting pages" : plural(waiting, "waiting page")}?`, body: `Puts the rendered pages that are not live yet on the live site, and refreshes live pages whose data or template changed${changed == null ? "" : ` (${plural(changed, "changed row")})`}.`, affected: waiting ?? undefined, label: "Publish" },
60
+ republish_all: { title: `Republish ${plural(covered, "page")}?`, body: "Rebuilds the whole site from every rendered page that did not fail. Rendered drafts that are not live yet go live too.", affected: covered, label: "Republish" },
61
+ rollback_publish: { title: "Roll back the last publish?", body: "Takes the pages from the most recent publish off the live site and returns them to ready. Earlier publishes are not touched.", affected: undefined, label: "Roll back" },
62
+ };
63
+ const open = asking ? copy[asking] : null;
64
+ return (
65
+ <section aria-label="Publish" className="grid gap-3 rounded-md border p-3">
66
+ <div>
67
+ <h3 className="text-sm font-medium">Publish to the live site</h3>
68
+ <p className="text-muted-foreground text-xs">Rendering pages never publishes them (unless the approval mode says so). These actions do.</p>
69
+ </div>
70
+ <KeyValueList columns={2} items={[
71
+ { label: "Waiting to go live", value: waiting == null ? null : waiting.toLocaleString() },
72
+ { label: "Live now", value: live == null ? null : live.toLocaleString() },
73
+ { label: "Last publish", value: shortDate(campaign.last_deployed_at ?? ws.data.overview?.last_publish) || null },
74
+ { label: "Address pattern", value: ws.data.deployment?.url_pattern_preview ?? null, mono: true },
75
+ ]} />
76
+ <div className="flex flex-wrap gap-2">
77
+ <Button size="sm" disabled={nothingWaiting} title={nothingWaiting ? "No page is waiting to go live and no live page has changed." : undefined} onClick={() => { run.reset(); setAsking("deploy_pending_changes"); }}><Rocket className="size-4" />Publish waiting pages</Button>
78
+ <Button size="sm" variant="outline" disabled={covered === 0} title={covered === 0 ? "No rendered page to republish." : undefined} onClick={() => { run.reset(); setAsking("republish_all"); }}>Republish everything</Button>
79
+ <Button size="sm" variant="outline" disabled={!canRollback} title={canRollback ? undefined : "There is no publish to roll back."} onClick={() => { run.reset(); setAsking("rollback_publish"); }}><RotateCcw className="size-4" />Roll back last publish</Button>
80
+ </div>
81
+ {done ? <p role="status" className="text-xs">{done}</p> : null}
82
+ {last?.status ? <p className="text-xs">Last result: <StatusPill tone={last.status === "ok" ? "positive" : last.status === "error" ? "negative" : "neutral"}>{last.status}</StatusPill> {last.message || <EmptyValue />}</p> : null}
83
+ {history.length > 0 ? (
84
+ <ul aria-label="Publish history" className="text-muted-foreground grid gap-1 text-xs">
85
+ {history.slice(0, 5).map((h, i) => <li key={`${h.checked_at ?? i}`}>{shortDate(h.checked_at) || <EmptyValue />}: {h.message || <EmptyValue />}</li>)}
86
+ </ul>
87
+ ) : null}
88
+ {!asking && run.error ? <ApiError error={run.error} title="Could not run the action" /> : null}
89
+ <ConfirmSpendDialog
90
+ open={asking !== null}
91
+ tier="external"
92
+ title={open?.title ?? "Publish"}
93
+ affected={open?.affected}
94
+ description={<><p>{open?.body}</p>{run.error ? <ApiError error={run.error} title="Could not run the action" /> : null}</>}
95
+ confirmLabel={open?.label ?? "Publish"}
96
+ confirming={run.loading}
97
+ onCancel={() => setAsking(null)}
98
+ onConfirm={() => { if (asking) void run.mutate(asking); }}
99
+ />
100
+ </section>
101
+ );
102
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProgrammaticSettings: how a campaign regenerates, where its pages are published, and its search and link rules.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. getProgrammaticCampaign (GET /programmatic-campaigns/{id}) read the full settings (the list row may omit them)
8
+ * 2. updateProgrammaticWorkspace (PATCH /programmatic-campaigns/{id}/workspace) write only the keys that changed
9
+ * Settings are merged by the API per group, so the save sends only the changed keys of the changed groups, and a cleared text is
10
+ * sent as null. Choosing "Publish automatically" is confirmed here, because it makes every later render go live. Nothing is
11
+ * published by saving.
12
+ */
13
+
14
+ import { useState } from "react";
15
+ import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
16
+ import { ApiError } from "@/g8/components/api-error";
17
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
18
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
19
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
20
+ import { g8Get, g8Patch } from "@/g8/lib/g8-client";
21
+ import { APPROVAL_MODES, REGENERATION_MODES, settingsDraftOf, settingsPatch } from "../lib/programmatic-types";
22
+ import type { Campaign, SettingsDraft } from "../lib/programmatic-types";
23
+
24
+ export interface ProgrammaticSettingsProps {
25
+ campaign: Campaign;
26
+ }
27
+
28
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm";
29
+
30
+ export function ProgrammaticSettings({ campaign }: ProgrammaticSettingsProps) {
31
+ const full = useG8Query(
32
+ (signal) => g8Get<Campaign>(`/programmatic-campaigns/${encodeURIComponent(campaign.id)}`, {}, { signal }).then((r) => r.data),
33
+ [campaign.id], { key: ["programmatic", "detail", campaign.id] },
34
+ );
35
+ if (full.error && !full.data) return <ApiError error={full.error} title="Could not load the settings" onRetry={() => void full.refetch()} />;
36
+ if (!full.data) return <Skeleton className="h-40 w-full" aria-busy="true" />;
37
+ return <Fields key={campaign.id} campaign={full.data} />;
38
+ }
39
+
40
+ function Fields({ campaign }: { campaign: Campaign }) {
41
+ const was = settingsDraftOf(campaign);
42
+ const [d, setD] = useState<SettingsDraft>(was);
43
+ const [asking, setAsking] = useState(false);
44
+ const patch = settingsPatch(was, d);
45
+ const dirty = Object.keys(patch).length > 0;
46
+ const thinOk = d.thinWords.trim() === "" || /^\d+$/.test(d.thinWords.trim());
47
+ const save = useG8Mutation(() => g8Patch(`/programmatic-campaigns/${encodeURIComponent(campaign.id)}/workspace`, { settings: patch }), {
48
+ onSuccess: () => setAsking(false), onError: () => setAsking(false), invalidates: [["programmatic"]],
49
+ });
50
+ const set = (p: Partial<SettingsDraft>): void => setD((cur) => ({ ...cur, ...p }));
51
+ const turnsOnAuto = d.approval === "auto_publish" && was.approval !== "auto_publish";
52
+ return (
53
+ <form className="grid gap-4" onSubmit={(e) => { e.preventDefault(); if (!dirty || !thinOk) return; if (turnsOnAuto) { save.reset(); setAsking(true); } else void save.mutate(undefined); }}>
54
+ <fieldset className="grid gap-3 sm:grid-cols-2">
55
+ <legend className="mb-2 text-sm font-medium">Regeneration</legend>
56
+ <div><Label htmlFor="g8-pp-mode" className="mb-1">Render again</Label>
57
+ <select id="g8-pp-mode" className={SELECT} value={d.mode} onChange={(e) => set({ mode: e.target.value })}>{was.mode === "" ? <option value="">Not set</option> : null}{REGENERATION_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}</select></div>
58
+ <div><Label htmlFor="g8-pp-approval" className="mb-1">New pages</Label>
59
+ <select id="g8-pp-approval" className={SELECT} value={d.approval} onChange={(e) => set({ approval: e.target.value })}>{was.approval === "" ? <option value="">Not set</option> : null}{APPROVAL_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}</select>
60
+ <p className="text-muted-foreground mt-1 text-xs">{APPROVAL_MODES.find((m) => m.value === d.approval)?.hint}</p></div>
61
+ <label className="flex items-center gap-2 text-sm sm:col-span-2"><input type="checkbox" checked={d.changedOnly} onChange={(e) => set({ changedOnly: e.target.checked })} />Only render rows that changed</label>
62
+ </fieldset>
63
+ <fieldset className="grid gap-3 sm:grid-cols-2">
64
+ <legend className="mb-2 text-sm font-medium">Publishing</legend>
65
+ <div><Label htmlFor="g8-pp-domain" className="mb-1">Domain</Label><Input id="g8-pp-domain" placeholder="pages.example.com" value={d.domain} onChange={(e) => set({ domain: e.target.value })} /></div>
66
+ <div><Label htmlFor="g8-pp-base" className="mb-1">Folder</Label><Input id="g8-pp-base" placeholder="integrations" value={d.basePath} onChange={(e) => set({ basePath: e.target.value })} /></div>
67
+ </fieldset>
68
+ <fieldset className="grid gap-3 sm:grid-cols-2">
69
+ <legend className="mb-2 text-sm font-medium">Search</legend>
70
+ <div><Label htmlFor="g8-pp-thin" className="mb-1">Thin page below (words)</Label><Input id="g8-pp-thin" inputMode="numeric" value={d.thinWords} onChange={(e) => set({ thinWords: e.target.value })} aria-invalid={!thinOk} /></div>
71
+ <div><Label htmlFor="g8-pp-canon" className="mb-1">Canonical address pattern</Label><Input id="g8-pp-canon" value={d.canonical} onChange={(e) => set({ canonical: e.target.value })} /></div>
72
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={d.schemaRequired} onChange={(e) => set({ schemaRequired: e.target.checked })} />Flag pages with no structured data</label>
73
+ </fieldset>
74
+ <fieldset className="grid gap-2">
75
+ <legend className="mb-2 text-sm font-medium">Links</legend>
76
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={d.sitemap} onChange={(e) => set({ sitemap: e.target.checked })} />Include pages in a sitemap</label>
77
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={d.breadcrumbs} onChange={(e) => set({ breadcrumbs: e.target.checked })} />Breadcrumbs</label>
78
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={d.related} onChange={(e) => set({ related: e.target.checked })} />Related pages</label>
79
+ </fieldset>
80
+ {!thinOk ? <p role="alert" className="text-error text-xs">The word count must be a whole number.</p> : null}
81
+ {!asking && save.error ? <ApiError error={save.error} title="Could not save the settings" /> : null}
82
+ {save.data && !dirty ? <p role="status" className="text-xs">Settings saved.</p> : null}
83
+ <div><Button type="submit" loading={save.loading} disabled={!dirty || !thinOk}>Save settings</Button></div>
84
+ <ConfirmSpendDialog
85
+ open={asking}
86
+ tier="external"
87
+ title="Publish new pages automatically?"
88
+ description={<p>From now on every render marks its pages live as soon as they are made, with no review step. Existing pages are not changed by this save.</p>}
89
+ confirmLabel="Publish automatically"
90
+ confirming={save.loading}
91
+ onCancel={() => setAsking(false)}
92
+ onConfirm={() => void save.mutate(undefined)}
93
+ />
94
+ </form>
95
+ );
96
+ }
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProgrammaticSourceFields: choose where a campaign's rows come from (a contact list, or a Workbench / CSV / sitemap-import table).
5
+ *
6
+ * SDK operations: listWorkbenchTables (GET /workbench/tables) read, only while a table source type is chosen.
7
+ * The list choice is the crm block's ListSelect (GET /lists, paged). No id is typed by hand.
8
+ */
9
+
10
+ import { Label } from "@graph8/sdk/ui";
11
+ import { ApiError } from "@/g8/components/api-error";
12
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
13
+ import { g8Get } from "@/g8/lib/g8-client";
14
+ import { hasNextOf } from "@/g8/lib/paging";
15
+ import { ListSelect } from "@/g8/crm/components/list-select";
16
+ import { rowsOf } from "@/g8/pages/lib/studio";
17
+ import { SOURCE_TYPES } from "../lib/programmatic-types";
18
+ import type { SourceType } from "../lib/programmatic-types";
19
+
20
+ export interface SourceValue { type: SourceType; listId: number | null; tableId: string; tableName: string }
21
+
22
+ export interface ProgrammaticSourceFieldsProps {
23
+ value: SourceValue;
24
+ onChange: (next: SourceValue) => void;
25
+ disabled?: boolean;
26
+ /** Name to show for the current list or table before the options have loaded. */
27
+ currentName?: string;
28
+ idPrefix?: string;
29
+ }
30
+
31
+ interface TableRow { id?: string | null; name?: string | null; row_count?: number | null }
32
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm disabled:opacity-50";
33
+ const TABLE_READ = 100;
34
+
35
+ export function ProgrammaticSourceFields({ value, onChange, disabled, currentName, idPrefix = "g8-pp" }: ProgrammaticSourceFieldsProps) {
36
+ const tables = useG8Query(
37
+ (signal) => g8Get("/workbench/tables", { limit: TABLE_READ }, { signal }).then((r) => {
38
+ const rows = rowsOf<TableRow>(r.data, "tables");
39
+ return { rows, more: hasNextOf(r, TABLE_READ, rows.length) === true };
40
+ }),
41
+ [], { enabled: value.type !== "list" },
42
+ );
43
+ const rows = (tables.data?.rows ?? []).filter((t): t is TableRow & { id: string } => Boolean(t.id));
44
+ const known = rows.some((t) => t.id === value.tableId);
45
+ return (
46
+ <div className="grid gap-3 sm:grid-cols-2">
47
+ <div>
48
+ <Label htmlFor={`${idPrefix}-source-type`} className="mb-1">Data source</Label>
49
+ <select id={`${idPrefix}-source-type`} className={SELECT} value={value.type} disabled={disabled} onChange={(e) => onChange({ type: e.target.value as SourceType, listId: null, tableId: "", tableName: "" })}>
50
+ {SOURCE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
51
+ </select>
52
+ <p className="text-muted-foreground mt-1 text-xs">{SOURCE_TYPES.find((t) => t.value === value.type)?.hint}</p>
53
+ </div>
54
+ <div>
55
+ {value.type === "list" ? (
56
+ <>
57
+ <Label htmlFor={`${idPrefix}-list`} className="mb-1">List</Label>
58
+ <ListSelect id={`${idPrefix}-list`} label="Source list" emptyOption="Choose a list" type="contacts" value={value.listId} valueLabel={currentName || "Current list"} disabled={disabled} onChange={(l) => onChange({ ...value, listId: l ? Number(l.id) : null })} />
59
+ </>
60
+ ) : (
61
+ <>
62
+ <Label htmlFor={`${idPrefix}-table`} className="mb-1">Table</Label>
63
+ <select id={`${idPrefix}-table`} className={SELECT} value={value.tableId} disabled={disabled || (tables.loading && !tables.data)} onChange={(e) => onChange({ ...value, tableId: e.target.value, tableName: rows.find((t) => t.id === e.target.value)?.name ?? "" })}>
64
+ <option value="">{tables.loading && !tables.data ? "Loading tables" : "Choose a table"}</option>
65
+ {value.tableId && !known ? <option value={value.tableId}>{value.tableName || currentName || "Current table"}</option> : null}
66
+ {rows.map((t) => <option key={t.id} value={t.id}>{t.name ?? "Untitled table"}{t.row_count == null ? "" : ` (${t.row_count.toLocaleString()} rows)`}</option>)}
67
+ </select>
68
+ {tables.error ? <ApiError error={tables.error} title="Could not load tables" onRetry={() => void tables.refetch()} /> : null}
69
+ {tables.data?.more ? <p role="note" className="text-muted-foreground mt-1 text-xs">Showing the first {TABLE_READ} tables.</p> : null}
70
+ </>
71
+ )}
72
+ </div>
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProgrammaticSource: the rows the pages are made from: what the source is, its columns and first rows, switching it, and
5
+ * importing a sitemap as the source.
6
+ *
7
+ * SDK operations, in order:
8
+ * 1. getProgrammaticWorkspace (GET /programmatic-campaigns/{id}/workspace) read source, columns, sample rows, row count
9
+ * 2. updateProgrammaticWorkspace (PATCH /programmatic-campaigns/{id}/workspace) write switch to another list or table (works at any status)
10
+ * 3. importProgrammaticSitemap (POST /programmatic-campaigns/{id}/imports/sitemap) write REPLACES the source with a table of the sitemap's URLs
11
+ * A source change marks the pages as changed; render again to apply it. The sitemap import fetches the sitemap (and, if asked,
12
+ * each page) from the public web; the maximum is left to the API's default unless a number is typed.
13
+ */
14
+
15
+ import { useState } from "react";
16
+ import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
17
+ import { ApiError } from "@/g8/components/api-error";
18
+ import { EmptyValue, KeyValueList } from "@/g8/components/screen/key-value-list";
19
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
20
+ import { g8Patch, g8Post } from "@/g8/lib/g8-client";
21
+ import { plural } from "@/g8/lib/plural";
22
+ import { text } from "@/g8/pages/lib/studio";
23
+ import { useProgrammaticWorkspace } from "../hooks/use-programmatic-workspace";
24
+ import { SOURCE_TYPES, sourceComplete, sourceOf } from "../lib/programmatic-types";
25
+ import type { Campaign, SourceType } from "../lib/programmatic-types";
26
+ import { ProgrammaticSourceFields } from "./programmatic-source-fields";
27
+ import type { SourceValue } from "./programmatic-source-fields";
28
+
29
+ export interface ProgrammaticSourceProps {
30
+ campaign: Campaign;
31
+ }
32
+
33
+ export function ProgrammaticSource({ campaign }: ProgrammaticSourceProps) {
34
+ const ws = useProgrammaticWorkspace(campaign.id);
35
+ if (ws.error && !ws.data) return <ApiError error={ws.error} title="Could not load the source" onRetry={() => void ws.refetch()} />;
36
+ if (!ws.data) return <Skeleton className="h-32 w-full" aria-busy="true" />;
37
+ const src = sourceOf({ data_source: ws.data.data?.source, data_source_type: campaign.data_source_type, list_id: campaign.list_id });
38
+ const cols = (ws.data.data?.columns ?? []).slice(0, 6);
39
+ const rows = ws.data.data?.sample_rows ?? [];
40
+ const total = ws.data.data?.total_rows ?? null;
41
+ return (
42
+ <div className="grid gap-4">
43
+ <KeyValueList columns={2} items={[
44
+ { label: "Source", value: SOURCE_TYPES.find((t) => t.value === src.type)?.label ?? null },
45
+ { label: "Name", value: src.name || null },
46
+ { label: "Rows", value: total == null ? null : total.toLocaleString() },
47
+ { label: "Columns", value: (ws.data.data?.columns ?? []).length ? (ws.data.data?.columns ?? []).join(", ") : null, wide: true },
48
+ ]} />
49
+ {rows.length > 0 ? (
50
+ <div className="overflow-x-auto rounded-md border">
51
+ <table aria-label="First source rows" className="w-full text-sm">
52
+ <thead className="text-muted-foreground text-left text-xs"><tr>{cols.map((c) => <th key={c} className="p-2">{c}</th>)}</tr></thead>
53
+ <tbody>{rows.map((r, i) => <tr key={i} className="border-t">{cols.map((c) => <td key={c} className="max-w-48 truncate p-2">{text(r[c]) || <EmptyValue />}</td>)}</tr>)}</tbody>
54
+ </table>
55
+ </div>
56
+ ) : <p className="text-muted-foreground text-sm">The source has no rows to show.</p>}
57
+ <SwitchSource campaign={campaign} current={src} />
58
+ <ImportSitemap campaign={campaign} rows={total} />
59
+ </div>
60
+ );
61
+ }
62
+
63
+ function SwitchSource({ campaign, current }: { campaign: Campaign; current: { type: string; id: string; name: string } }) {
64
+ const [v, setV] = useState<SourceValue>({ type: (SOURCE_TYPES.find((t) => t.value === current.type)?.value ?? "list") as SourceType, listId: current.type === "list" && current.id ? Number(current.id) : null, tableId: current.type !== "list" ? current.id : "", tableName: current.name });
65
+ const same = v.type === current.type && (v.type === "list" ? String(v.listId ?? "") === current.id : v.tableId === current.id);
66
+ const save = useG8Mutation(() => g8Patch(`/programmatic-campaigns/${encodeURIComponent(campaign.id)}/workspace`, v.type === "list" ? { data_source_type: v.type, list_id: v.listId } : { data_source_type: v.type, data_source_id: v.tableId, ...(v.tableName ? { data_source_name: v.tableName } : {}) }), { invalidates: [["programmatic"]] });
67
+ return (
68
+ <form className="grid gap-3 rounded-md border p-3" onSubmit={(e) => { e.preventDefault(); if (!same && sourceComplete({ source_type: v.type, list_id: v.listId, table_id: v.tableId })) void save.mutate(undefined); }}>
69
+ <h3 className="text-sm font-medium">Use a different source</h3>
70
+ <ProgrammaticSourceFields value={v} onChange={setV} currentName={current.name} idPrefix="g8-pp-switch" />
71
+ {save.error ? <ApiError error={save.error} title="Could not change the source" /> : null}
72
+ {save.data ? <p role="status" className="text-xs">Source changed. Render the pages again to apply it.</p> : null}
73
+ <div><Button type="submit" size="sm" loading={save.loading} disabled={same || !sourceComplete({ source_type: v.type, list_id: v.listId, table_id: v.tableId })}>Change source</Button></div>
74
+ </form>
75
+ );
76
+ }
77
+
78
+ function ImportSitemap({ campaign, rows }: { campaign: Campaign; rows: number | null }) {
79
+ const [url, setUrl] = useState("");
80
+ const [name, setName] = useState("");
81
+ const [max, setMax] = useState("");
82
+ const [fetchPages, setFetchPages] = useState(false);
83
+ const maxOk = max.trim() === "" || (/^\d+$/.test(max.trim()) && Number(max) >= 1);
84
+ const valid = /^https?:\/\/\S+$/i.test(url.trim()) && maxOk;
85
+ const run = useG8Mutation(() => {
86
+ const body: Record<string, unknown> = { sitemap_url: url.trim(), fetch_pages: fetchPages };
87
+ if (name.trim()) body.source_name = name.trim();
88
+ if (max.trim()) body.max_urls = Number(max);
89
+ return g8Post<{ rows_imported?: number }>(`/programmatic-campaigns/${encodeURIComponent(campaign.id)}/imports/sitemap`, body);
90
+ }, { invalidates: [["programmatic"]] });
91
+ return (
92
+ <form className="grid gap-3 rounded-md border p-3" onSubmit={(e) => { e.preventDefault(); if (valid) void run.mutate(undefined); }}>
93
+ <h3 className="text-sm font-medium">Import a sitemap as the source</h3>
94
+ <p role="note" className="text-xs">This replaces the current source{rows == null ? "" : ` (${plural(rows, "row")})`} with a new table of the sitemap&apos;s addresses. The current table is not deleted; switch back to it above.</p>
95
+ <div className="grid gap-3 sm:grid-cols-2">
96
+ <div><Label htmlFor="g8-pp-sitemap" className="mb-1">Sitemap address</Label><Input id="g8-pp-sitemap" type="url" placeholder="https://example.com/sitemap.xml" value={url} onChange={(e) => setUrl(e.target.value)} /></div>
97
+ <div><Label htmlFor="g8-pp-sitemap-name" className="mb-1">Table name (optional)</Label><Input id="g8-pp-sitemap-name" value={name} onChange={(e) => setName(e.target.value)} /></div>
98
+ <div><Label htmlFor="g8-pp-sitemap-max" className="mb-1">Most addresses (optional)</Label><Input id="g8-pp-sitemap-max" inputMode="numeric" value={max} onChange={(e) => setMax(e.target.value)} aria-invalid={!maxOk} /></div>
99
+ <label className="flex items-center gap-2 self-end text-sm"><input type="checkbox" checked={fetchPages} onChange={(e) => setFetchPages(e.target.checked)} />Also read each page</label>
100
+ </div>
101
+ {run.error ? <ApiError error={run.error} title="Could not import the sitemap" /> : null}
102
+ {run.data ? <p role="status" className="text-xs">Imported {run.data.data.rows_imported == null ? "the sitemap" : plural(run.data.data.rows_imported, "address", "addresses")}. It is now the source.</p> : null}
103
+ <div><Button type="submit" size="sm" variant="outline" loading={run.loading} disabled={!valid}>Replace source with sitemap</Button></div>
104
+ </form>
105
+ );
106
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProgrammaticVariables: the {{variables}} of the template and slug, whether the source has a column for each, and the
5
+ * per-variable rules the renderer applies (a fallback for empty values, a character limit and how to cut).
6
+ *
7
+ * SDK operations, in order:
8
+ * 1. getProgrammaticCampaignVariables (GET /programmatic-campaigns/{id}/variables) read variables, matches, sample columns
9
+ * 2. updateProgrammaticWorkspace (PATCH /programmatic-campaigns/{id}/workspace) write the whole variable_config
10
+ * The API replaces the whole array, so the save merges the edited variables into the stored entries: every other entry, and every
11
+ * key the form does not show, is sent back unchanged. A cleared fallback or limit is sent as null. Only variables whose values
12
+ * changed are written. Saving marks the pages as changed; render again to apply it.
13
+ */
14
+
15
+ import { useState } from "react";
16
+ import { Button, Input, Skeleton } from "@graph8/sdk/ui";
17
+ import { ApiError } from "@/g8/components/api-error";
18
+ import { StatusPill } from "@/g8/components/screen/status-pill";
19
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
20
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
21
+ import { g8Get, g8Patch } from "@/g8/lib/g8-client";
22
+ import type { Rec } from "@/g8/pages/lib/studio";
23
+ import { changedEdits, editOf, mergeVariableConfig, validMaxChars } from "../lib/programmatic-types";
24
+ import type { Campaign, VariableEdit, VariableStatus } from "../lib/programmatic-types";
25
+
26
+ export interface ProgrammaticVariablesProps {
27
+ campaign: Campaign;
28
+ }
29
+
30
+ interface VariablesPayload { variables?: VariableStatus[]; sample_fields?: string[]; slug_template?: string }
31
+
32
+ export function ProgrammaticVariables({ campaign }: ProgrammaticVariablesProps) {
33
+ const q = useG8Query(
34
+ (signal) => g8Get<VariablesPayload>(`/programmatic-campaigns/${encodeURIComponent(campaign.id)}/variables`, {}, { signal }).then((r) => r.data),
35
+ [campaign.id], { key: ["programmatic", "variables", campaign.id] },
36
+ );
37
+ const stored = useG8Query(
38
+ (signal) => g8Get<Campaign>(`/programmatic-campaigns/${encodeURIComponent(campaign.id)}`, {}, { signal }).then((r) => r.data),
39
+ [campaign.id], { key: ["programmatic", "detail", campaign.id] },
40
+ );
41
+ if (q.error && !q.data) return <ApiError error={q.error} title="Could not load the variables" onRetry={() => void q.refetch()} />;
42
+ if (stored.error && !stored.data) return <ApiError error={stored.error} title="Could not load the campaign" onRetry={() => void stored.refetch()} />;
43
+ if (!q.data || !stored.data) return <Skeleton className="h-32 w-full" aria-busy="true" />;
44
+ return <Rules key={campaign.id} campaignId={campaign.id} vars={q.data.variables ?? []} columns={q.data.sample_fields ?? []} existing={stored.data.variable_config ?? []} />;
45
+ }
46
+
47
+ function Rules({ campaignId, vars, columns, existing }: { campaignId: string; vars: VariableStatus[]; columns: string[]; existing: Rec[] }) {
48
+ const [edits, setEdits] = useState<Record<string, VariableEdit>>(() => Object.fromEntries(vars.map((v) => [v.name, editOf(v)])));
49
+ const changed = changedEdits(vars, edits);
50
+ const valid = changed.every((e) => validMaxChars(e.maxChars));
51
+ const set = (name: string, patch: Partial<VariableEdit>): void => setEdits((cur) => ({ ...cur, [name]: { ...cur[name], ...patch } }));
52
+ const save = useG8Mutation(() => g8Patch(`/programmatic-campaigns/${encodeURIComponent(campaignId)}/workspace`, { variable_config: mergeVariableConfig(existing, changed) }), {
53
+ invalidates: [["programmatic"]],
54
+ });
55
+ if (vars.length === 0) return <p className="text-muted-foreground text-sm">The template and the address pattern use no {"{{variables}}"}, so every page is the same.</p>;
56
+ return (
57
+ <form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (valid && changed.length) void save.mutate(undefined); }}>
58
+ <p className="text-muted-foreground text-xs">Source columns: {columns.length ? columns.join(", ") : "none found"}. A variable with no matching column renders empty unless it has a fallback.</p>
59
+ <div className="overflow-x-auto rounded-md border">
60
+ <table className="w-full text-sm">
61
+ <thead className="text-muted-foreground text-left text-xs"><tr><th className="p-2">Variable</th><th className="p-2">Source column</th><th className="p-2">Fallback</th><th className="p-2">Character limit</th><th className="p-2">When too long</th></tr></thead>
62
+ <tbody>
63
+ {vars.map((v) => {
64
+ const e = edits[v.name] ?? editOf(v);
65
+ return (
66
+ <tr key={v.name} className="border-t">
67
+ <td className="p-2 font-mono text-xs">{`{{${v.name}}}`}{v.in_slug ? <span className="text-muted-foreground"> (address)</span> : null}</td>
68
+ <td className="p-2"><StatusPill tone={v.matched_in_source ? "positive" : "warning"}>{v.matched_in_source ? "Found" : "No column"}</StatusPill></td>
69
+ <td className="p-2"><Input aria-label={`Fallback for ${v.name}`} value={e.fallback} onChange={(ev) => set(v.name, { fallback: ev.target.value })} /></td>
70
+ <td className="p-2"><Input aria-label={`Character limit for ${v.name}`} inputMode="numeric" className="w-24" value={e.maxChars} onChange={(ev) => set(v.name, { maxChars: ev.target.value })} aria-invalid={!validMaxChars(e.maxChars)} /></td>
71
+ <td className="p-2"><select aria-label={`Cut ${v.name} with`} className="border-input bg-background h-9 rounded-md border px-2 text-sm" value={e.policy} onChange={(ev) => set(v.name, { policy: ev.target.value as "ellipsis" | "clip" })}><option value="ellipsis">Add ...</option><option value="clip">Cut off</option></select></td>
72
+ </tr>
73
+ );
74
+ })}
75
+ </tbody>
76
+ </table>
77
+ </div>
78
+ {!valid ? <p role="alert" className="text-error text-xs">A character limit is a whole number from 1 to 5000.</p> : null}
79
+ {save.error ? <ApiError error={save.error} title="Could not save the variables" /> : null}
80
+ {save.data ? <p role="status" className="text-xs">Saved. Render the pages again to apply the new rules.</p> : null}
81
+ <div><Button type="submit" loading={save.loading} disabled={!valid || changed.length === 0}>Save variable rules</Button></div>
82
+ </form>
83
+ );
84
+ }
@@ -0,0 +1,19 @@
1
+ "use client";
2
+
3
+ /**
4
+ * useProgrammaticWorkspace: the workspace of one campaign (source, readiness, numbers, links, deployment), shared by every tab
5
+ * through one cache entry so a save on one tab refreshes the others.
6
+ *
7
+ * SDK operations: getProgrammaticWorkspace (GET /programmatic-campaigns/{id}/workspace) read
8
+ */
9
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
10
+ import { g8Get } from "@/g8/lib/g8-client";
11
+ import type { Workspace } from "../lib/programmatic-types";
12
+
13
+ export function useProgrammaticWorkspace(campaignId: string) {
14
+ return useG8Query(
15
+ (signal) => g8Get<Workspace>(`/programmatic-campaigns/${encodeURIComponent(campaignId)}/workspace`, {}, { signal }).then((r) => r.data),
16
+ [campaignId],
17
+ { key: ["programmatic", "workspace", campaignId] },
18
+ );
19
+ }