@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,119 @@
1
+ "use client";
2
+
3
+ /**
4
+ * BulkUpdateButton: set ONE field to the same value on every selected contact (job title, department, seniority, city,
5
+ * state, country). A dialog asks for the field and the value and says how many contacts change.
6
+ *
7
+ * SDK operations:
8
+ * 1. bulkUpdateContacts POST /contacts/bulk-update [{ id, CONTACT_<FIELD>: value }, ...] (write)
9
+ * The body is one object per contact. The API authorises the WHOLE set before the first write, so a contact the
10
+ * key cannot write refuses the whole chunk; failures after that are reported per contact in `errors`, not as an
11
+ * HTTP error. The selection is sent in chunks of 100 with Promise.allSettled so one refused chunk does not hide
12
+ * the others, and the dialog reports "N of M updated".
13
+ *
14
+ * The value is required: the API gives no documented way to clear a field in bulk, so clearing is not offered (use the
15
+ * contact's own Edit form). The owner and tags have their own actions.
16
+ */
17
+
18
+ import { useState } from "react";
19
+ import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@graph8/sdk/ui";
20
+ import { ListChecks } from "lucide-react";
21
+ import { ApiError } from "@/g8/components/api-error";
22
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
+ import { g8Post } from "@/g8/lib/g8-client";
24
+ import { plural } from "@/g8/lib/plural";
25
+ import { BULK_FIELDS } from "../lib/record-actions";
26
+ import type { BulkFieldKey } from "../lib/record-actions";
27
+
28
+ const CHUNK = 100;
29
+
30
+ export interface BulkUpdateButtonProps {
31
+ contactIds: ReadonlyArray<number>;
32
+ /** Called with the ids that were updated, so the screen can refetch. */
33
+ onDone?: (updated: number[]) => void;
34
+ variant?: "default" | "outline" | "secondary" | "ghost";
35
+ size?: "default" | "sm";
36
+ className?: string;
37
+ }
38
+
39
+ interface BulkResult { updated: number[]; failed: Array<{ id: number; error: string }>; refusedChunks: number }
40
+
41
+ /** `{ updated, errors: [{ id, error }] }` from one chunk; ids not named as failed count as updated. */
42
+ export function chunkOutcome(ids: number[], data: unknown): { ok: number[]; failed: Array<{ id: number; error: string }> } {
43
+ const errors = ((data as { errors?: Array<{ id?: unknown; error?: unknown }> } | null)?.errors ?? [])
44
+ .map((e) => ({ id: Number(e.id), error: String(e.error ?? "Update failed.") }))
45
+ .filter((e) => Number.isFinite(e.id));
46
+ const bad = new Set(errors.map((e) => e.id));
47
+ return { ok: ids.filter((id) => !bad.has(id)), failed: errors };
48
+ }
49
+
50
+ export function BulkUpdateButton({ contactIds, onDone, variant = "outline", size = "sm", className }: BulkUpdateButtonProps) {
51
+ const [open, setOpen] = useState(false);
52
+ const [field, setField] = useState<BulkFieldKey>(BULK_FIELDS[0].key);
53
+ const [value, setValue] = useState("");
54
+ const [result, setResult] = useState<(BulkResult & { total: number; label: string }) | null>(null);
55
+ const label = BULK_FIELDS.find((f) => f.key === field)?.label ?? field;
56
+
57
+ const run = useG8Mutation(async (v: { ids: number[]; field: BulkFieldKey; value: string }): Promise<BulkResult> => {
58
+ const chunks: number[][] = [];
59
+ for (let i = 0; i < v.ids.length; i += CHUNK) chunks.push(v.ids.slice(i, i + CHUNK));
60
+ const settled = await Promise.allSettled(chunks.map((ids) => g8Post("/contacts/bulk-update", ids.map((id) => ({ id, [v.field]: v.value })))));
61
+ const out: BulkResult = { updated: [], failed: [], refusedChunks: 0 };
62
+ settled.forEach((s, i) => {
63
+ if (s.status === "rejected") { out.refusedChunks += 1; return; }
64
+ const o = chunkOutcome(chunks[i], s.value.data);
65
+ out.updated.push(...o.ok);
66
+ out.failed.push(...o.failed);
67
+ });
68
+ if (out.updated.length === 0 && out.refusedChunks === chunks.length && settled[0]?.status === "rejected") throw (settled[0] as PromiseRejectedResult).reason;
69
+ return out;
70
+ }, {
71
+ invalidates: [["contacts", "list"], ["contacts", "detail"], ["contacts", "record"]],
72
+ onSuccess: (r, v) => { setResult({ ...r, total: v.ids.length, label }); onDone?.(r.updated); },
73
+ });
74
+
75
+ const close = () => { setOpen(false); setValue(""); setResult(null); run.reset(); };
76
+ const count = contactIds.length;
77
+ const trimmed = value.trim();
78
+
79
+ return (
80
+ <>
81
+ <Button className={className} variant={variant} size={size} disabled={count === 0} onClick={() => setOpen(true)}><ListChecks className="size-4" aria-hidden />Bulk edit</Button>
82
+ <Dialog open={open} onOpenChange={(o) => (o ? setOpen(true) : close())}>
83
+ <DialogContent>
84
+ <DialogHeader>
85
+ <DialogTitle>Edit {plural(count, "contact")}</DialogTitle>
86
+ <DialogDescription>The same value is written to every selected contact. Existing values are replaced.</DialogDescription>
87
+ </DialogHeader>
88
+ {result ? (
89
+ <div role="status" className="text-sm">
90
+ <p className="font-medium">Updated {result.updated.length.toLocaleString()} of {result.total.toLocaleString()} ({result.label}).</p>
91
+ {result.failed.length > 0 ? <p className="mt-1 text-destructive">{plural(result.failed.length, "contact")} could not be updated: {[...new Set(result.failed.map((f) => f.error))].join("; ")}</p> : null}
92
+ {result.refusedChunks > 0 ? <p className="mt-1 text-destructive">{plural(result.refusedChunks, "batch", "batches")} of up to {CHUNK} was refused by the server. Those contacts are unchanged.</p> : null}
93
+ </div>
94
+ ) : (
95
+ <div className="flex flex-col gap-3">
96
+ <label className="flex flex-col gap-1 text-sm">Field
97
+ <select className="h-9 rounded-md border bg-background px-2" value={field} onChange={(e) => setField(e.target.value as BulkFieldKey)}>
98
+ {BULK_FIELDS.map((f) => <option key={f.key} value={f.key}>{f.label}</option>)}
99
+ </select>
100
+ </label>
101
+ <label className="flex flex-col gap-1 text-sm">New value
102
+ <input className="h-9 rounded-md border bg-background px-3" maxLength={200} value={value} onChange={(e) => setValue(e.target.value)} />
103
+ </label>
104
+ {run.error ? <ApiError error={run.error} title="Could not update the contacts" /> : null}
105
+ </div>
106
+ )}
107
+ <DialogFooter>
108
+ {result ? <Button onClick={close}>Done</Button> : (
109
+ <>
110
+ <Button variant="ghost" onClick={close}>Cancel</Button>
111
+ <Button loading={run.loading} disabled={!trimmed || count === 0} onClick={() => void run.mutate({ ids: [...contactIds], field, value: trimmed })}>Update {plural(count, "contact")}</Button>
112
+ </>
113
+ )}
114
+ </DialogFooter>
115
+ </DialogContent>
116
+ </Dialog>
117
+ </>
118
+ );
119
+ }
@@ -0,0 +1,144 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ContactDocumentsTab: the AI documents graph8 writes about ONE contact (outreach personalization, conversation prep,
5
+ * stakeholder intel, career timeline, relationship map, persona value prop, engagement digest). Shows which exist, which
6
+ * are being written, which failed; generate one, regenerate one, generate all, and read a finished one.
7
+ *
8
+ * SDK operations, in order:
9
+ * 1. getContactGenerationStatus GET /contacts/{id}/documents/generation-status (read; polled every 4 s while a document is generating)
10
+ * 2. getContactDocument GET /contacts/{id}/documents/types/{doc_type} (read, when "View" is opened)
11
+ * 3. generateContactDocument POST /contacts/{id}/documents/types/{doc_type}/generate (billable, ConfirmSpendDialog)
12
+ * 4. regenerateContactDocument POST /contacts/{id}/documents/types/{doc_type}/regenerate (billable, ConfirmSpendDialog)
13
+ * 5. generateAllContactDocuments POST /contacts/{id}/documents/generate-all (billable, ConfirmSpendDialog)
14
+ *
15
+ * Every document is a model call. graph8 publishes no price for them, so the confirm says that instead of showing an
16
+ * invented number. Regenerate replaces the stored text (and any inline edits to it), which the confirm states. The
17
+ * three writes only queue the work; the tab then follows the status until nothing is generating.
18
+ */
19
+
20
+ import { useEffect, useState } from "react";
21
+ import { Button, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@graph8/sdk/ui";
22
+ import { FileText, RefreshCw, Sparkles } from "lucide-react";
23
+ import { ApiError } from "@/g8/components/api-error";
24
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
25
+ import { StatusPill } from "@/g8/components/screen/status-pill";
26
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
27
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
28
+ import { g8Get, g8Post } from "@/g8/lib/g8-client";
29
+ import { formatDay } from "../lib/screen-parts";
30
+
31
+ /** The seven synthesis documents the API generates (campaign_builder/models/contact_documents.py CONTACT_SYNTHESIS_STEPS). */
32
+ export const CONTACT_DOC_TYPES = [
33
+ "outreach_personalization", "conversation_prep", "stakeholder_intel", "career_trigger_timeline", "relationship_map", "persona_value_prop", "engagement_digest",
34
+ ] as const;
35
+
36
+ interface DocStatus { id?: string; doc_type: string; status?: string; title?: string | null; folder?: string | null; updated_at?: string | null }
37
+ interface GenStatus { is_generating?: boolean; total?: number; ready?: number; generating?: number; failed?: number; documents?: DocStatus[] }
38
+
39
+ const humanize = (t: string) => t.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
40
+ type Step = { kind: "generate" | "regenerate"; docType: string } | { kind: "all" };
41
+
42
+ function pill(status: string | undefined) {
43
+ if (status === "completed") return <StatusPill tone="positive" dot>Ready</StatusPill>;
44
+ if (status === "failed") return <StatusPill tone="negative" dot>Failed</StatusPill>;
45
+ if (status === "pending" || status === "generating" || status === "queued" || status === "running") return <StatusPill tone="info" dot>Writing</StatusPill>;
46
+ return <StatusPill tone="neutral" dot>Not written</StatusPill>;
47
+ }
48
+
49
+ export function ContactDocumentsTab({ contactId, contactName }: { contactId: number; contactName: string }) {
50
+ const [step, setStep] = useState<Step | null>(null);
51
+ const [viewing, setViewing] = useState<string | null>(null);
52
+ const status = useG8Query((signal) => g8Get<GenStatus>(`/contacts/${contactId}/documents/generation-status`, undefined, { signal }), [contactId], { key: ["contacts", "documents", contactId] });
53
+ const gen = status.data?.data;
54
+ const generating = gen?.is_generating === true;
55
+
56
+ // Follow a running generation; stop when nothing is generating any more.
57
+ useEffect(() => {
58
+ if (!generating) return;
59
+ const t = setTimeout(() => void status.refetch(), 4000);
60
+ return () => clearTimeout(t);
61
+ }, [generating, status.updatedAt]); // eslint-disable-line react-hooks/exhaustive-deps
62
+
63
+ const run = useG8Mutation(async (s: Step) => {
64
+ const base = `/contacts/${contactId}/documents`;
65
+ if (s.kind === "all") await g8Post(`${base}/generate-all`);
66
+ else if (s.kind === "generate") await g8Post(`${base}/types/${s.docType}/generate`);
67
+ else await g8Post(`${base}/types/${s.docType}/regenerate`);
68
+ }, { invalidates: [["contacts", "documents", contactId]], onSuccess: () => { setStep(null); void status.refetch(); } });
69
+
70
+ const docs = new Map((gen?.documents ?? []).map((d) => [d.doc_type, d]));
71
+ const types = [...CONTACT_DOC_TYPES, ...[...docs.keys()].filter((t) => !(CONTACT_DOC_TYPES as readonly string[]).includes(t))];
72
+
73
+ if (status.loading && !status.data) return <p className="p-2 text-sm text-muted-foreground" role="status">Loading documents</p>;
74
+ if (status.error && !status.data) return <ApiError error={status.error} title="Could not load the documents" onRetry={() => void status.refetch()} />;
75
+
76
+ const title = step?.kind === "all" ? `Write all documents for ${contactName}` : step ? `${step.kind === "generate" ? "Write" : "Rewrite"} "${humanize(step.docType)}" for ${contactName}` : "";
77
+ return (
78
+ <div className="flex flex-col gap-3">
79
+ <div className="flex items-center justify-between gap-2">
80
+ <p className="text-sm text-muted-foreground">{gen?.ready ?? 0} of {gen?.total ?? CONTACT_DOC_TYPES.length} written{generating ? ", writing now" : ""}{gen?.failed ? `, ${gen.failed} failed` : ""}.</p>
81
+ <Button size="sm" variant="outline" disabled={generating || run.loading} onClick={() => { run.reset(); setStep({ kind: "all" }); }}><Sparkles className="size-4" aria-hidden />Write all</Button>
82
+ </div>
83
+ {status.error ? <ApiError compact error={status.error} title="Could not refresh the status" onRetry={() => void status.refetch()} /> : null}
84
+ <ul className="flex flex-col divide-y rounded-md border">
85
+ {types.map((t) => {
86
+ const d = docs.get(t);
87
+ const busy = d?.status === "pending" || d?.status === "generating" || d?.status === "queued" || d?.status === "running";
88
+ const ready = d?.status === "completed";
89
+ return (
90
+ <li key={t} className="flex flex-wrap items-center gap-2 px-3 py-2" data-testid={`contact-doc-${t}`}>
91
+ <FileText className="size-4 text-muted-foreground" aria-hidden />
92
+ <span className="text-sm font-medium">{d?.title || humanize(t)}</span>
93
+ {pill(d?.status)}
94
+ {d?.updated_at ? <span className="text-xs text-muted-foreground">{formatDay(d.updated_at)}</span> : null}
95
+ <span className="ml-auto flex gap-1">
96
+ {ready ? <Button size="sm" variant="ghost" onClick={() => setViewing(t)}>View</Button> : null}
97
+ <Button size="sm" variant="outline" disabled={busy || run.loading} aria-label={`${ready ? "Rewrite" : "Write"} ${humanize(t)}`} onClick={() => { run.reset(); setStep({ kind: ready ? "regenerate" : "generate", docType: t }); }}>
98
+ <RefreshCw className="size-4" aria-hidden />{ready ? "Rewrite" : d?.status === "failed" ? "Try again" : "Write"}
99
+ </Button>
100
+ </span>
101
+ </li>
102
+ );
103
+ })}
104
+ </ul>
105
+ <ContactDocumentSheet contactId={contactId} docType={viewing} onClose={() => setViewing(null)} />
106
+ <ConfirmSpendDialog
107
+ open={step != null}
108
+ tier="billable"
109
+ title={title}
110
+ description={
111
+ <>
112
+ Each document is written by an AI model and charged to your credits. No price is published for a document, so no estimate is shown.
113
+ {step?.kind === "regenerate" ? " The current text, including any edits you made to it, is replaced." : ""}
114
+ {step?.kind === "all" ? ` Writing all ${CONTACT_DOC_TYPES.length} documents again replaces the ones that exist, and each one is charged.` : ""}
115
+ {run.error ? <span role="alert" className="mt-2 block text-destructive">It could not be started. Nothing was queued; you can try again.</span> : null}
116
+ </>
117
+ }
118
+ affected={step?.kind === "all" ? CONTACT_DOC_TYPES.length : step ? 1 : undefined}
119
+ confirmLabel="Write"
120
+ confirming={run.loading}
121
+ onConfirm={() => step && void run.mutate(step)}
122
+ onCancel={() => setStep(null)}
123
+ />
124
+ </div>
125
+ );
126
+ }
127
+
128
+ function ContactDocumentSheet({ contactId, docType, onClose }: { contactId: number; docType: string | null; onClose: () => void }) {
129
+ const doc = useG8Query((signal) => g8Get<{ title?: string; content?: string | null }>(`/contacts/${contactId}/documents/types/${docType}`, undefined, { signal }), [contactId, docType], { enabled: docType != null, key: ["contacts", "documents", contactId, docType ?? ""] });
130
+ const d = docType ? doc.data?.data : undefined;
131
+ return (
132
+ <Sheet open={docType != null} onOpenChange={(o) => (o ? undefined : onClose())}>
133
+ <SheetContent className="sm:max-w-xl overflow-y-auto">
134
+ <SheetHeader>
135
+ <SheetTitle>{d?.title || (docType ? humanize(docType) : "")}</SheetTitle>
136
+ <SheetDescription>Written by an AI model from this contact's record and activity.</SheetDescription>
137
+ </SheetHeader>
138
+ {doc.loading && !d ? <p className="p-4 text-sm text-muted-foreground" role="status">Loading</p> : null}
139
+ {doc.error ? <div className="p-4"><ApiError error={doc.error} title="Could not load the document" onRetry={() => void doc.refetch()} /></div> : null}
140
+ {d ? <div className="whitespace-pre-wrap p-4 text-sm">{d.content || "This document has no text yet."}</div> : null}
141
+ </SheetContent>
142
+ </Sheet>
143
+ );
144
+ }
@@ -0,0 +1,118 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ContactInsightsTab: what graph8 knows about a contact's buying signals, and a suggested next step.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. getContactSignals GET /contacts/{id}/signals (read) signal score, trend and the last 30 days of intent,
8
+ * page visits, email engagement, forms and bookings.
9
+ * 2. getContactNextBestStep GET /contacts/{id}/next-best-step (BILLABLE although it is a GET, ConfirmSpendDialog)
10
+ * A model writes the suggestion on a cache miss, and the cache key includes the contact's engagement, so new
11
+ * activity makes the next ask a paid one again. It is therefore a button behind a confirm, never loaded on open.
12
+ * graph8 publishes no price for it, so the confirm says that instead of showing a number.
13
+ *
14
+ * The suggestion is shown as written (action, why, confidence); nothing is executed for the person. A null answer (no
15
+ * context to base it on) says so.
16
+ */
17
+
18
+ import { useState } from "react";
19
+ import { Button } from "@graph8/sdk/ui";
20
+ import { Lightbulb } from "lucide-react";
21
+ import { ApiError } from "@/g8/components/api-error";
22
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
23
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
24
+ import { StatusPill } from "@/g8/components/screen/status-pill";
25
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
26
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
27
+ import { g8Get } from "@/g8/lib/g8-client";
28
+
29
+ interface Signals {
30
+ signal_score?: number | null;
31
+ signal_trend?: string | null;
32
+ keyword_intents?: Array<{ keyword?: string; page_url?: string }>;
33
+ topic_intents?: unknown[];
34
+ page_visits?: Array<{ page_url?: string; visit_count?: number }>;
35
+ email_engagement?: unknown[];
36
+ form_submissions?: unknown[];
37
+ meeting_bookings?: unknown[];
38
+ intent_pages?: unknown[];
39
+ }
40
+ interface NextStep { action?: string; action_type?: string; reasoning?: string[]; confidence?: number }
41
+
42
+ const count = (v: unknown) => (Array.isArray(v) ? v.length : 0);
43
+ const humanize = (t: string) => t.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
44
+
45
+ export function ContactInsightsTab({ contactId }: { contactId: number }) {
46
+ const [confirm, setConfirm] = useState(false);
47
+ const signals = useG8Query((signal) => g8Get<Signals>(`/contacts/${contactId}/signals`, undefined, { signal }), [contactId], { key: ["contacts", "signals", contactId] });
48
+ const next = useG8Mutation(async () => {
49
+ const res = await g8Get<NextStep | null>(`/contacts/${contactId}/next-best-step`);
50
+ return res.data ?? null;
51
+ }, { onSuccess: () => setConfirm(false) });
52
+ const s = signals.data?.data;
53
+ const card = next.data;
54
+
55
+ return (
56
+ <div className="flex flex-col gap-4">
57
+ <section aria-label="Next best step" className="flex flex-col gap-2 rounded-md border p-3">
58
+ <div className="flex items-center justify-between gap-2">
59
+ <h3 className="flex items-center gap-2 text-sm font-medium"><Lightbulb className="size-4" aria-hidden />Next best step</h3>
60
+ <Button size="sm" variant="outline" disabled={next.loading} onClick={() => { next.reset(); setConfirm(true); }}>{card !== undefined ? "Suggest again" : "Suggest a next step"}</Button>
61
+ </div>
62
+ {next.error && !confirm ? <ApiError error={next.error} title="Could not get a suggestion" /> : null}
63
+ {card === null ? <p className="text-sm text-muted-foreground" role="status">There is not enough on this contact to suggest a step yet.</p> : null}
64
+ {card ? (
65
+ <div className="flex flex-col gap-1 text-sm" role="status">
66
+ <p className="font-medium">{card.action}</p>
67
+ <div className="flex items-center gap-2">
68
+ {card.action_type ? <StatusPill tone="info">{humanize(card.action_type)}</StatusPill> : null}
69
+ {typeof card.confidence === "number" ? <span className="text-xs text-muted-foreground">Confidence {Math.round(card.confidence * 100)}%</span> : null}
70
+ </div>
71
+ {card.reasoning?.length ? <ul className="list-disc pl-5 text-muted-foreground">{card.reasoning.map((r, i) => <li key={i}>{r}</li>)}</ul> : null}
72
+ </div>
73
+ ) : null}
74
+ {card === undefined && !next.error ? <p className="text-xs text-muted-foreground">Written by an AI model from this contact's activity. It costs credits, so it only runs when you ask.</p> : null}
75
+ </section>
76
+
77
+ <section aria-label="Signals" className="flex flex-col gap-2">
78
+ <h3 className="text-sm font-medium">Signals, last 30 days</h3>
79
+ {signals.loading && !s ? <p className="text-sm text-muted-foreground" role="status">Loading signals</p> : null}
80
+ {signals.error ? <ApiError error={signals.error} title="Could not load signals" onRetry={() => void signals.refetch()} /> : null}
81
+ {s ? (
82
+ <>
83
+ <KeyValueList columns={2} items={[
84
+ { label: "Signal score", value: s.signal_score ?? <span className="text-muted-foreground">-</span> },
85
+ { label: "Trend", value: s.signal_trend ? humanize(s.signal_trend) : <span className="text-muted-foreground">-</span> },
86
+ { label: "Keyword intents", value: count(s.keyword_intents) },
87
+ { label: "Topic intents", value: count(s.topic_intents) },
88
+ { label: "Pages visited", value: count(s.page_visits) },
89
+ { label: "Email engagement", value: count(s.email_engagement) },
90
+ { label: "Form submissions", value: count(s.form_submissions) },
91
+ { label: "Meeting bookings", value: count(s.meeting_bookings) },
92
+ ]} />
93
+ {s.page_visits?.length ? (
94
+ <ul className="text-sm text-muted-foreground">{s.page_visits.slice(0, 5).map((p, i) => <li key={`${p.page_url}-${i}`} className="truncate">{p.page_url}{p.visit_count ? ` (${p.visit_count})` : ""}</li>)}</ul>
95
+ ) : null}
96
+ </>
97
+ ) : null}
98
+ </section>
99
+
100
+ <ConfirmSpendDialog
101
+ open={confirm}
102
+ tier="billable"
103
+ title="Suggest a next step"
104
+ description={
105
+ <>
106
+ An AI model writes the suggestion from this contact's activity and charges your credits. A recent suggestion for the same activity is reused. No price is published for it, so no estimate is shown.
107
+ {next.error ? <span role="alert" className="mt-2 block text-destructive">The suggestion could not be made. You can try again.</span> : null}
108
+ </>
109
+ }
110
+ affected={1}
111
+ confirmLabel="Suggest"
112
+ confirming={next.loading}
113
+ onConfirm={() => void next.mutate(undefined)}
114
+ onCancel={() => setConfirm(false)}
115
+ />
116
+ </div>
117
+ );
118
+ }
@@ -4,17 +4,22 @@
4
4
  * ContactLookupLink: "Open contact" for something that only knows a person's email (an inbox thread, a meeting
5
5
  * attendee, a visitor). It looks the email up in your CRM and, when exactly one contact has it, shows a button that
6
6
  * calls `onOpen(contactId)` (route to your contacts screen: `router.push("/contacts?id=" + id)`). When nobody has that
7
- * email it renders nothing, so a thread from a stranger gets no dead button.
7
+ * email it renders nothing, so a thread from a stranger gets no dead button, unless `createIfMissing` is set: then a
8
+ * stranger gets an "Add to contacts" button that creates the contact from the email (and name, when you pass one) and
9
+ * opens it.
8
10
  *
9
11
  * SDK operations, in order:
10
- * 1. listContacts GET /contacts?email=<email>&limit=2 (read)
12
+ * 1. listContacts GET /contacts?email=<email>&limit=2 (read)
13
+ * 2. createContacts POST /contacts (write; only after "Add to contacts" is clicked)
11
14
  * Nothing here is billable, external or destructive.
12
15
  */
13
16
 
14
17
  import { Button } from "@graph8/sdk/ui";
15
- import { UserRound } from "lucide-react";
18
+ import { UserPlus, UserRound } from "lucide-react";
19
+ import { ApiError } from "@/g8/components/api-error";
20
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
16
21
  import { useG8Query } from "@/g8/hooks/use-g8-query";
17
- import { g8Get } from "@/g8/lib/g8-client";
22
+ import { g8Get, g8Post } from "@/g8/lib/g8-client";
18
23
 
19
24
  export interface ContactLookupLinkProps {
20
25
  /** The person's email. Nothing is looked up (and nothing rendered) without one. */
@@ -24,17 +29,53 @@ export interface ContactLookupLinkProps {
24
29
  label?: string;
25
30
  variant?: "default" | "outline" | "secondary" | "ghost";
26
31
  size?: "default" | "sm";
32
+ /** Offer to create the contact when nobody has this email. Pass the sender's name to prefill it. */
33
+ createIfMissing?: boolean | { firstName?: string | null; lastName?: string | null };
34
+ /** Called with the new contact's id after it was created (before `onOpen`). */
35
+ onCreated?: (contactId: number) => void;
27
36
  }
28
37
 
29
- export function ContactLookupLink({ email, onOpen, label = "Open contact", variant = "outline", size = "sm" }: ContactLookupLinkProps) {
38
+ /** Splits "Maya Chen" into first and last name; a single word is a first name. */
39
+ export function splitName(name: string | null | undefined): { firstName?: string; lastName?: string } {
40
+ const parts = (name ?? "").trim().split(/\s+/).filter(Boolean);
41
+ if (parts.length === 0) return {};
42
+ return parts.length === 1 ? { firstName: parts[0] } : { firstName: parts[0], lastName: parts.slice(1).join(" ") };
43
+ }
44
+
45
+ export function ContactLookupLink({ email, onOpen, label = "Open contact", variant = "outline", size = "sm", createIfMissing, onCreated }: ContactLookupLinkProps) {
30
46
  const address = email?.trim().toLowerCase() ?? "";
31
47
  const q = useG8Query(
32
48
  async (signal) => ((await g8Get<Array<{ id: number; work_email?: string | null }>>("/contacts", { email: address, limit: 2, page: 1 }, { signal })).data ?? []),
33
49
  [address],
34
50
  { enabled: address.includes("@"), key: ["contacts", "by-email", address] },
35
51
  );
52
+ const create = useG8Mutation(
53
+ async () => {
54
+ const name = typeof createIfMissing === "object" ? createIfMissing : {};
55
+ const env = await g8Post<{ contact_id?: number | null }>("/contacts", { work_email: address, first_name: name.firstName || undefined, last_name: name.lastName || undefined });
56
+ const id = (env.data as { contact_id?: number | null } | undefined)?.contact_id;
57
+ // Never open a guess: when the API sent no id, look the new contact up instead.
58
+ if (id != null) return id;
59
+ const again = (await g8Get<Array<{ id: number }>>("/contacts", { email: address, limit: 1, page: 1 })).data ?? [];
60
+ return again[0]?.id ?? null;
61
+ },
62
+ { invalidates: [["contacts"]], onError: (err) => { if ((err as { status?: number }).status === 409) void q.refetch(); }, onSuccess: (id) => { if (id != null) { onCreated?.(id); onOpen(id); } } },
63
+ );
36
64
  const match = (q.data ?? []).find((c) => !c.work_email || c.work_email.toLowerCase() === address);
37
- if (!address || !match) return null;
65
+ if (!address) return null;
66
+ if (!match) {
67
+ // Only offer to create once the lookup has really answered "nobody": never while loading or after an error.
68
+ if (!createIfMissing || q.loading || q.error || q.data === undefined) return null;
69
+ return (
70
+ <>
71
+ <Button size={size} variant={variant} loading={create.loading} onClick={() => void create.mutate(undefined)}>
72
+ <UserPlus className="mr-1 size-4" aria-hidden />
73
+ Add to contacts
74
+ </Button>
75
+ {create.error ? <ApiError error={create.error} title="Could not add the contact" onRetry={create.reset} /> : null}
76
+ </>
77
+ );
78
+ }
38
79
  return (
39
80
  <Button size={size} variant={variant} onClick={() => onOpen(match.id)}>
40
81
  <UserRound className="mr-1 size-4" aria-hidden />
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ContactRowActions: the "..." button at the end of a contact row. It opens a small dialog for that ONE contact with
5
+ * the record actions (tags, verify email, unlock info). Each action is the same component the bulk bar
6
+ * and the detail panel use, so a row, a selection and the open contact behave identically.
7
+ *
8
+ * SDK operations: none of its own. The actions it hosts call assign/unassignContactTags
9
+ * (write), verifyContactEmails (billable) and unlockContactInfo (billable); the paid ones keep their own
10
+ * ConfirmSpendDialog. Also exported for apps that render their own table through ContactTable's row slot.
11
+ */
12
+
13
+ import { useState } from "react";
14
+ import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@graph8/sdk/ui";
15
+ import { MoreHorizontal } from "lucide-react";
16
+ import { contactName } from "../lib/crm-types";
17
+ import type { ContactRecord } from "../lib/crm-types";
18
+ import { UnlockInfoButton } from "./unlock-info-button";
19
+ import { VerifyEmailButton } from "./verify-email-button";
20
+ import { TagAssignButton } from "./tag-assign";
21
+
22
+ export function ContactRowActions({ row, onDone }: { row: ContactRecord; onDone?: () => void }) {
23
+ const [open, setOpen] = useState(false);
24
+ const name = contactName(row);
25
+ return (
26
+ <>
27
+ <Button variant="ghost" size="sm" aria-label={`Actions for ${name}`} onClick={() => setOpen(true)}><MoreHorizontal className="size-4" aria-hidden /></Button>
28
+ <Dialog open={open} onOpenChange={setOpen}>
29
+ <DialogContent>
30
+ <DialogHeader>
31
+ <DialogTitle>{name}</DialogTitle>
32
+ <DialogDescription>Actions for this contact.</DialogDescription>
33
+ </DialogHeader>
34
+ <div className="flex flex-wrap items-center gap-2">
35
+ <TagAssignButton contactIds={[row.id]} onDone={() => onDone?.()} />
36
+ <VerifyEmailButton rows={[row]} onDone={onDone} />
37
+ <UnlockInfoButton rows={[row]} onDone={onDone} />
38
+ </div>
39
+ </DialogContent>
40
+ </Dialog>
41
+ </>
42
+ );
43
+ }
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ContactTagChips: the tags on ONE contact as chips, with a remove button on each and an "Add tag" picker that can also
5
+ * create a tag on the spot. Used in the contact panel's Overview.
6
+ *
7
+ * SDK operations, in order:
8
+ * 1. getContactTagIds GET /contacts/{contact_id}/tags (read) -> { tag_ids }
9
+ * 2. listContactTags GET /contacts/tags (read) names and colours, via useContactTags
10
+ * 3. assignContactTags POST /contacts/tags/assign { tag_ids, contact_ids: [id] } (write, idempotent)
11
+ * 4. unassignContactTags POST /contacts/tags/unassign { tag_ids, contact_ids: [id] } (write; the tag itself survives)
12
+ * 5. createContactTag POST /contacts/tags { name, color } (write) only from "Create tag", then assigned
13
+ *
14
+ * Removing a chip is immediate and reversible (add the tag again), so it needs no confirmation. A tag id that the
15
+ * list does not know (archived since) is shown as "Archived tag" rather than a raw id.
16
+ */
17
+
18
+ import { useRef, useState } from "react";
19
+ import { Button, cn } from "@graph8/sdk/ui";
20
+ import { Plus, Tag, X } from "lucide-react";
21
+ import { ApiError } from "@/g8/components/api-error";
22
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
24
+ import { g8Get, g8Post } from "@/g8/lib/g8-client";
25
+ import { useContactTags } from "../hooks/use-contact-tags";
26
+ import { HEX_COLOR, TAG_COLORS } from "../lib/record-actions";
27
+ import type { ContactTag } from "../lib/record-actions";
28
+
29
+ /** One tag as a chip: a colour dot and the name. The colour is the tag's own (data), never a theme colour. */
30
+ export function TagChip({ tag, onRemove, disabled }: { tag: Pick<ContactTag, "name" | "color">; onRemove?: () => void; disabled?: boolean }) {
31
+ const color = tag.color && HEX_COLOR.test(tag.color) ? tag.color : undefined;
32
+ return (
33
+ <span className="inline-flex items-center gap-1.5 rounded-full border bg-background px-2 py-0.5 text-xs font-medium">
34
+ <span aria-hidden className={cn("size-2 rounded-full", color ? "" : "bg-muted-foreground")} style={color ? { backgroundColor: color } : undefined} />
35
+ {tag.name}
36
+ {onRemove ? (
37
+ <button type="button" aria-label={`Remove tag ${tag.name}`} disabled={disabled} onClick={onRemove} className="-mr-1 rounded-full p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-50">
38
+ <X className="size-3" aria-hidden />
39
+ </button>
40
+ ) : null}
41
+ </span>
42
+ );
43
+ }
44
+
45
+ export interface ContactTagChipsProps {
46
+ contactId: number;
47
+ /** Called after a tag was added, removed or created (refresh the list that shows tags). */
48
+ onChanged?: () => void;
49
+ }
50
+
51
+ export function ContactTagChips({ contactId, onChanged }: ContactTagChipsProps) {
52
+ const [adding, setAdding] = useState(false);
53
+ const [newName, setNewName] = useState("");
54
+ const [color, setColor] = useState<string>(TAG_COLORS[0]);
55
+ const all = useContactTags();
56
+ const ids = useG8Query((signal) => g8Get<{ tag_ids?: string[] }>(`/contacts/${contactId}/tags`, undefined, { signal }), [contactId], { key: ["contacts", "tags", contactId] });
57
+ const assigned = ids.data?.data?.tag_ids ?? [];
58
+ const byId = new Map(all.tags.map((t) => [t.id, t]));
59
+ const free = all.tags.filter((t) => !assigned.includes(t.id));
60
+
61
+ const write = useG8Mutation(async (v: { op: "assign" | "unassign"; tagId: string }) => {
62
+ const body = { tag_ids: [v.tagId], contact_ids: [contactId] };
63
+ if (v.op === "assign") await g8Post("/contacts/tags/assign", body);
64
+ else await g8Post("/contacts/tags/unassign", body);
65
+ }, { invalidates: [["contacts", "tags", contactId], ["contact-tags"], ["contacts", "list"]], onSuccess: () => { void ids.refetch(); onChanged?.(); } });
66
+
67
+ /** The tag step 1 created, kept so a retry after a failed assign does not create it a second time. */
68
+ const made = useRef<{ name: string; id: string } | null>(null);
69
+ const create = useG8Mutation(async (v: { name: string; color: string }) => {
70
+ if (made.current?.name !== v.name) {
71
+ const res = await g8Post<ContactTag>("/contacts/tags", { name: v.name, color: v.color });
72
+ if (!res.data?.id) throw new Error("The tag was created but the server did not return its id. Pick it from the list.");
73
+ made.current = { name: v.name, id: res.data.id };
74
+ }
75
+ await g8Post("/contacts/tags/assign", { tag_ids: [made.current.id], contact_ids: [contactId] });
76
+ made.current = null;
77
+ }, { invalidates: [["contacts", "tags", contactId], ["contact-tags"], ["contacts", "list"]], onSuccess: () => { setNewName(""); setAdding(false); void ids.refetch(); onChanged?.(); } });
78
+
79
+ if (ids.loading && !ids.data) return <p className="text-sm text-muted-foreground" role="status">Loading tags</p>;
80
+ if (ids.error) return <ApiError compact error={ids.error} title="Could not load tags" onRetry={() => void ids.refetch()} />;
81
+
82
+ return (
83
+ <div className="flex flex-col gap-2" aria-label="Tags">
84
+ <div className="flex flex-wrap items-center gap-1.5">
85
+ <Tag className="size-3.5 text-muted-foreground" aria-hidden />
86
+ {assigned.length === 0 ? <span className="text-sm text-muted-foreground">No tags</span> : null}
87
+ {assigned.map((id) => <TagChip key={id} tag={byId.get(id) ?? { name: all.loaded ? "Archived tag" : "Tag" }} disabled={write.loading} onRemove={() => void write.mutate({ op: "unassign", tagId: id })} />)}
88
+ <Button variant="ghost" size="sm" onClick={() => setAdding((a) => !a)} aria-expanded={adding}><Plus className="size-3.5" aria-hidden />Add tag</Button>
89
+ </div>
90
+ {adding ? (
91
+ <div className="flex flex-col gap-2 rounded-md border p-2">
92
+ {all.error ? <ApiError compact error={all.error} title="Could not load the tag list" onRetry={() => void all.refetch()} /> : null}
93
+ {free.length > 0 ? (
94
+ <div className="flex flex-wrap gap-1.5" role="group" aria-label="Existing tags">
95
+ {free.map((t) => (
96
+ <button key={t.id} type="button" disabled={write.loading} onClick={() => void write.mutate({ op: "assign", tagId: t.id })} aria-label={`Add tag ${t.name}`} className="rounded-full hover:bg-muted disabled:opacity-50"><TagChip tag={t} /></button>
97
+ ))}
98
+ </div>
99
+ ) : <p className="text-xs text-muted-foreground">{all.loaded ? "Every tag is already on this contact." : "Loading tags"}</p>}
100
+ <form className="flex flex-wrap items-center gap-2" onSubmit={(e) => { e.preventDefault(); if (newName.trim()) void create.mutate({ name: newName.trim(), color }); }}>
101
+ <input aria-label="New tag name" maxLength={50} placeholder="New tag name" value={newName} onChange={(e) => setNewName(e.target.value)} className="h-8 w-40 rounded-md border bg-background px-2 text-sm" />
102
+ <span className="flex gap-1" role="radiogroup" aria-label="New tag colour">
103
+ {TAG_COLORS.map((c) => <button key={c} type="button" role="radio" aria-checked={color === c} aria-label={`Colour ${c}`} onClick={() => setColor(c)} className={cn("size-5 rounded-full border-2", color === c ? "border-foreground" : "border-transparent")} style={{ backgroundColor: c }} />)}
104
+ </span>
105
+ <Button type="submit" size="sm" variant="outline" loading={create.loading} disabled={!newName.trim()}>Create and add</Button>
106
+ </form>
107
+ </div>
108
+ ) : null}
109
+ {write.error ? <ApiError compact error={write.error} title="Could not change the tag" /> : null}
110
+ {create.error ? <ApiError compact error={create.error} title="Could not create the tag" /> : null}
111
+ </div>
112
+ );
113
+ }