@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,128 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SocialSettings: the org-wide spend ceilings and capture rules for social listening. These decide whether an
5
+ * over-eager listener can keep spending, so every save is confirmed with the before and after of each limit.
6
+ *
7
+ * SDK operations, in order:
8
+ * 1. getSocialSettings (GET /social-listeners/settings) read the full settings are read before the form shows (edit-before-load)
9
+ * 2. updateSocialSettings (PATCH /social-listeners/settings) billable tier, ADMIN ONLY (a 403 is explained); ConfirmSpendDialog lists each change
10
+ * Only changed fields are sent. A channel or capture rule that changed is sent whole (the API rebuilds a rule from its
11
+ * defaults), but only with the fields the API reported plus the ones edited: nothing is filled in. The API answers a complete
12
+ * rule for every channel it holds; a channel it does not report is not shown, because there is nothing to edit.
13
+ * A cleared number is not sent: the API cannot unset one.
14
+ */
15
+
16
+ import { useState } from "react";
17
+ import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
18
+ import { ApiError } from "@/g8/components/api-error";
19
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
20
+ import { SectionCard } from "@/g8/components/screen/section-card";
21
+ import { g8Get, g8Patch } from "@/g8/lib/g8-client";
22
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
24
+ import { CHANNEL_NUMBERS, LISTENER_TYPES, PLATFORMS, SETTING_NUMBERS, fmt, num, wholeNumber } from "../lib/signals-types";
25
+ import type { CaptureRule, ChannelRule, SocialSettings } from "../lib/signals-types";
26
+
27
+
28
+ export function SocialSettingsPanel() {
29
+ const q = useG8Query((signal) => g8Get<SocialSettings>("/social-listeners/settings", {}, { signal }).then((r) => r.data), [], { key: ["signals", "settings"] });
30
+ if (q.error && !q.data) return <ApiError error={q.error} title="Could not load settings" onRetry={() => void q.refetch()} />;
31
+ if (!q.data) return <Skeleton className="h-48 w-full" aria-label="Loading settings" />;
32
+ return <SettingsFields key={q.updatedAt ?? 0} settings={q.data} />;
33
+ }
34
+
35
+ function SettingsFields({ settings }: { settings: SocialSettings }) {
36
+ const [numbers, setNumbers] = useState<Record<string, string>>(() => Object.fromEntries(SETTING_NUMBERS.map((f) => [f.key, settings[f.key] == null ? "" : String(settings[f.key])])));
37
+ const [pause, setPause] = useState(Boolean(settings.auto_pause_on_cap));
38
+ const [create, setCreate] = useState(Boolean(settings.auto_create_contacts));
39
+ const reportedChannels = PLATFORMS.filter((p) => settings.channel_settings?.[p.value]);
40
+ const reportedCapture = LISTENER_TYPES.filter((t) => settings.contact_capture?.[t.value]);
41
+ const [channels, setChannels] = useState<Record<string, ChannelRule>>(() => Object.fromEntries(reportedChannels.map((p) => [p.value, { ...settings.channel_settings![p.value] }])));
42
+ const [capture, setCapture] = useState<Record<string, CaptureRule>>(() => Object.fromEntries(reportedCapture.map((t) => [t.value, { ...settings.contact_capture![t.value] }])));
43
+ const [confirm, setConfirm] = useState(false);
44
+ // What was typed into a channel or capture number box, kept as text so it can be emptied while retyping; applied once it is a whole number.
45
+ const [raw, setRaw] = useState<Record<string, string>>({});
46
+ const setNum = (id: string, text: string, apply: (n: number) => void): void => { setRaw((r) => ({ ...r, [id]: text })); const n = wholeNumber(text); if (n !== null) apply(n); };
47
+
48
+ const body: Record<string, unknown> = {};
49
+ const lines: string[] = [];
50
+ for (const f of SETTING_NUMBERS) {
51
+ const next = wholeNumber(numbers[f.key] ?? "", f.max);
52
+ if (next !== null && next !== num(settings[f.key])) { body[f.key] = next; lines.push(`${f.label}: ${fmt(settings[f.key])} to ${next.toLocaleString()}`); }
53
+ }
54
+ if (pause !== Boolean(settings.auto_pause_on_cap)) { body.auto_pause_on_cap = pause; lines.push(`Pause at the cap: ${pause ? "on" : "off (spending continues past the cap)"}`); }
55
+ if (create !== Boolean(settings.auto_create_contacts)) { body.auto_create_contacts = create; lines.push(`Create contacts from matched authors: ${create ? "on" : "off"}`); }
56
+ const chBody: Record<string, ChannelRule> = {};
57
+ for (const p of reportedChannels) if (JSON.stringify(settings.channel_settings![p.value]) !== JSON.stringify(channels[p.value])) {
58
+ chBody[p.value] = channels[p.value];
59
+ const before = settings.channel_settings![p.value] as Record<string, unknown>;
60
+ for (const [k, v] of Object.entries(channels[p.value])) if (before[k] !== v) lines.push(`${p.label} ${k.replace(/_/g, " ")}: ${before[k] === undefined ? "-" : typeof before[k] === "number" ? (before[k] as number).toLocaleString() : String(before[k])} to ${typeof v === "number" ? v.toLocaleString() : String(v)}`);
61
+ }
62
+ if (Object.keys(chBody).length) body.channel_settings = chBody;
63
+ const capBody: Record<string, CaptureRule> = {};
64
+ for (const t of reportedCapture) if (JSON.stringify(settings.contact_capture![t.value]) !== JSON.stringify(capture[t.value])) { capBody[t.value] = capture[t.value]; lines.push(`Contact capture for ${t.label.toLowerCase()} listeners`); }
65
+ if (Object.keys(capBody).length) body.contact_capture = capBody;
66
+ const invalid = SETTING_NUMBERS.some((f) => numbers[f.key] !== "" && wholeNumber(numbers[f.key], f.max) === null) || Object.values(raw).some((t) => wholeNumber(t) === null);
67
+
68
+ const save = useG8Mutation((): Promise<unknown> => g8Patch("/social-listeners/settings", body), { onSuccess: () => setConfirm(false), invalidates: [["signals", "settings"], ["signals", "stats"]] });
69
+ const setCh = (p: string, patch: Partial<ChannelRule>): void => setChannels((c) => ({ ...c, [p]: { ...c[p], ...patch } }));
70
+ const setCap = (t: string, patch: Partial<CaptureRule>): void => setCapture((c) => ({ ...c, [t]: { ...c[t], ...patch } }));
71
+
72
+ return (
73
+ <form className="grid gap-4" onSubmit={(e) => { e.preventDefault(); if (lines.length && !invalid) setConfirm(true); }}>
74
+ <SectionCard title="Spend limits" description="These bound what social listening can cost your workspace. Changing them needs an admin.">
75
+ <div className="grid gap-3 sm:grid-cols-2">
76
+ {SETTING_NUMBERS.map((f) => (
77
+ <div key={f.key}><Label htmlFor={`g8-ss-${f.key}`} className="mb-1">{f.label}</Label><Input id={`g8-ss-${f.key}`} inputMode="numeric" value={numbers[f.key]} onChange={(e) => setNumbers((n) => ({ ...n, [f.key]: e.target.value }))} /><p className="text-muted-foreground mt-1 text-xs">{f.hint}</p></div>
78
+ ))}
79
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={pause} onChange={(e) => setPause(e.target.checked)} />Stop polling when the cap is reached</label>
80
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={create} onChange={(e) => setCreate(e.target.checked)} />Create contacts from matched authors</label>
81
+ </div>
82
+ </SectionCard>
83
+ <SectionCard title="Channels" description="Per-channel ceilings and whether to fetch who engaged with each post (this multiplies what a poll costs).">
84
+ <div className="grid gap-3">
85
+ {reportedChannels.map((p) => (
86
+ <fieldset key={p.value} className="grid gap-2 rounded-md border p-3">
87
+ <legend className="px-1 text-sm font-medium">{p.label}</legend>
88
+ <div className="flex flex-wrap gap-4">
89
+ <label className="flex items-center gap-1 text-sm"><input type="checkbox" aria-label={`${p.label} enabled`} checked={channels[p.value].enabled === true} onChange={(e) => setCh(p.value, { enabled: e.target.checked })} />Enabled</label>
90
+ <label className="flex items-center gap-1 text-sm"><input type="checkbox" aria-label={`${p.label} fetch engagements`} checked={channels[p.value].fetch_engagements === true} onChange={(e) => setCh(p.value, { fetch_engagements: e.target.checked })} />Fetch engagements</label>
91
+ </div>
92
+ <div className="grid gap-2 sm:grid-cols-4">
93
+ {CHANNEL_NUMBERS.map((f) => (
94
+ <div key={f.key}><Label htmlFor={`g8-ss-${p.value}-${f.key}`} className="mb-1 text-xs">{f.label}</Label><Input id={`g8-ss-${p.value}-${f.key}`} inputMode="numeric" value={raw[`${p.value}.${f.key}`] ?? (channels[p.value][f.key] == null ? "" : String(channels[p.value][f.key]))} onChange={(e) => setNum(`${p.value}.${f.key}`, e.target.value, (n) => setCh(p.value, { [f.key]: n }))} /></div>
95
+ ))}
96
+ </div>
97
+ </fieldset>
98
+ ))}
99
+ </div>
100
+ </SectionCard>
101
+ <SectionCard title="Contact capture" description="When the author of a matched post becomes a contact, by listener type.">
102
+ <div className="grid gap-2">
103
+ {reportedCapture.map((t) => (
104
+ <div key={t.value} className="flex flex-wrap items-center gap-4 rounded-md border p-2 text-sm">
105
+ <span className="w-24 font-medium">{t.label}</span>
106
+ <label className="flex items-center gap-1"><input type="checkbox" aria-label={`${t.label} capture enabled`} checked={capture[t.value].enabled === true} onChange={(e) => setCap(t.value, { enabled: e.target.checked })} />Capture</label>
107
+ <label className="flex items-center gap-1"><input type="checkbox" aria-label={`${t.label} only when classified`} checked={capture[t.value].only_when_classified === true} onChange={(e) => setCap(t.value, { only_when_classified: e.target.checked })} />Only when scored</label>
108
+ <label className="flex items-center gap-1">Min followers<Input aria-label={`${t.label} minimum followers`} className="w-24" inputMode="numeric" value={raw[`${t.value}.min_followers`] ?? (capture[t.value].min_followers == null ? "" : String(capture[t.value].min_followers))} onChange={(e) => setNum(`${t.value}.min_followers`, e.target.value, (n) => setCap(t.value, { min_followers: n }))} /></label>
109
+ </div>
110
+ ))}
111
+ </div>
112
+ </SectionCard>
113
+ {invalid ? <p role="alert" className="text-error text-xs">Limits must be whole numbers, 0 or more (the alert share at most 100). A limit can't be unset; enter a number.</p> : null}
114
+ {save.error ? <ApiError error={save.error} title="Could not save the settings" /> : null}
115
+ <div><Button type="submit" disabled={lines.length === 0 || invalid}>Save settings</Button></div>
116
+ <ConfirmSpendDialog
117
+ open={confirm}
118
+ tier="billable"
119
+ title="Change the social listening limits?"
120
+ description={<><ul className="list-disc pl-5 text-sm">{lines.map((l) => <li key={l}>{l}</li>)}</ul><p className="mt-2">Raising a cap or turning off the pause lets listeners spend more credits.</p>{save.error ? <ApiError error={save.error} title="Could not save the settings" /> : null}</>}
121
+ confirmLabel="Save"
122
+ confirming={save.loading}
123
+ onCancel={() => setConfirm(false)}
124
+ onConfirm={() => void save.mutate(undefined)}
125
+ />
126
+ </form>
127
+ );
128
+ }
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SourceLists: graph8 lists that feed account listeners, and one-step creation of a listener per profile on a list.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. listSourceLists (GET /social-listeners/source-lists) read lists already used as a source
8
+ * 2. listLists (GET /lists), through ListSelect read the list to turn into listeners
9
+ * 3. bulkCreateListenersFromList (POST /social-listeners/bulk-from-list) billable creates one `user` listener per social profile on the list
10
+ * and per chosen channel; ConfirmSpendDialog states the most listeners it can create (list size x channels, from the list itself)
11
+ * and that spend then follows the per-listener caps. Handles resolve later, so creation itself spends nothing.
12
+ */
13
+
14
+ import { useState } from "react";
15
+ import { Button, 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 { EmptyState } from "@/g8/components/empty-state";
19
+ import { SectionCard } from "@/g8/components/screen/section-card";
20
+ import { ListSelect } from "@/g8/crm/components/list-select";
21
+ import type { ListSelectOption } from "@/g8/crm/components/list-select";
22
+ import { g8Get, g8Post } from "@/g8/lib/g8-client";
23
+ import { plural } from "@/g8/lib/plural";
24
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
25
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
26
+ import { PLATFORMS, fmt, itemsOf, num, when } from "../lib/signals-types";
27
+ import type { Platform, SourceList } from "../lib/signals-types";
28
+
29
+ interface BulkResult { list_title?: string; created_count?: number; skipped_no_handle?: number; skipped_duplicate?: number; sample_handles?: string[] }
30
+ const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm";
31
+
32
+ export function SourceLists() {
33
+ const [list, setList] = useState<ListSelectOption | null>(null);
34
+ const [channels, setChannels] = useState<Platform[]>(["x"]);
35
+ const [confirm, setConfirm] = useState(false);
36
+ const sources = useG8Query((signal) => g8Get<unknown>("/social-listeners/source-lists", {}, { signal }).then((r) => itemsOf<SourceList>(r.data)), [], { key: ["signals", "sources"] });
37
+ const run = useG8Mutation(async (): Promise<BulkResult> => (await g8Post<BulkResult>("/social-listeners/bulk-from-list", { list_id: Number(list!.id), platforms: channels })).data, {
38
+ onSuccess: () => setConfirm(false),
39
+ invalidates: [["signals", "sources"], ["signals", "listeners"], ["signals", "stats"]],
40
+ });
41
+ const size = num(list?.contact_count);
42
+ const max = size === null ? null : size * channels.length;
43
+ const toggle = (p: Platform): void => setChannels((c) => (c.includes(p) ? c.filter((x) => x !== p) : [...c, p]));
44
+ const r = run.data;
45
+
46
+ return (
47
+ <div className="grid gap-4">
48
+ <SectionCard title="Watch the people on a list" description="Creates one account listener for every social profile on the list, on each channel you tick.">
49
+ <div className="grid gap-3">
50
+ <div><Label htmlFor="g8-sl-src" className="mb-1">List</Label><ListSelect id="g8-sl-src" label="List" type="contacts" emptyOption="Choose a list" value={list?.id ?? ""} valueLabel={list?.title} selectClassName={SELECT} onChange={setList} /></div>
51
+ <fieldset className="flex flex-wrap gap-3"><legend className="mb-1 text-sm font-medium">Channels</legend>{PLATFORMS.map((p) => <label key={p.value} className="flex items-center gap-1 text-sm"><input type="checkbox" checked={channels.includes(p.value)} onChange={() => toggle(p.value)} />{p.label}</label>)}</fieldset>
52
+ <div><Button disabled={!list || channels.length === 0} onClick={() => setConfirm(true)}>Create listeners</Button></div>
53
+ {r ? <p role="status" className="text-sm">{plural(r.created_count ?? 0, "listener")} created for {r.list_title ?? "the list"}; {r.skipped_no_handle ?? 0} skipped for no handle, {r.skipped_duplicate ?? 0} already watched.</p> : null}
54
+ </div>
55
+ </SectionCard>
56
+ <SectionCard title="Lists already feeding listeners">
57
+ {sources.error ? <ApiError error={sources.error} title="Could not load source lists" onRetry={() => void sources.refetch()} /> : sources.loading && !sources.data ? <Skeleton className="h-16 w-full" /> : (sources.data ?? []).length === 0 ? (
58
+ <EmptyState compact title="No source lists yet" description="Pick a list above to start watching its people." />
59
+ ) : (
60
+ <ul className="grid gap-2">{(sources.data ?? []).map((s) => (
61
+ <li key={s.audience_id} className="rounded-md border p-2 text-sm"><span className="font-medium">{s.list_title || "-"}</span><span className="text-muted-foreground block text-xs">{fmt(s.contact_count)} contacts · {fmt(s.total_listeners_created)} listeners created · {s.is_dynamic ? "re-scanned as contacts are added" : "one-time"} · last synced {when(s.last_synced_at)}</span></li>
62
+ ))}</ul>
63
+ )}
64
+ </SectionCard>
65
+ <ConfirmSpendDialog
66
+ open={confirm}
67
+ tier="billable"
68
+ title={`Create listeners for ${list?.title ?? "this list"}?`}
69
+ affected={max ?? undefined}
70
+ description={(
71
+ <>
72
+ <p>{max === null ? "The list does not report its size." : `Up to ${plural(max, "listener")} are created (${plural(size ?? 0, "contact")} on ${plural(channels.length, "channel")}); people with no profile are skipped.`} Creating them spends nothing. Each listener then spends credits as it reads posts, up to its own monthly cap and the org-wide caps.</p>
73
+ {run.error ? <ApiError error={run.error} title="Could not create the listeners" /> : null}
74
+ </>
75
+ )}
76
+ confirmLabel="Create"
77
+ confirming={run.loading}
78
+ onCancel={() => setConfirm(false)}
79
+ onConfirm={() => void run.mutate(undefined)}
80
+ />
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ /**
4
+ * TopEngagers: the accounts that engage with your matched posts most often (the warm audience).
5
+ *
6
+ * SDK operations: listTopEngagedAccounts (GET /social-listeners/engagements/top-accounts) read. One read of up to 200
7
+ * rows (the API's cap); when it comes back full the list says it shows the first 200. Filters: window, channel, and a
8
+ * minimum number of posts (omitted unless typed, so the API's own default applies).
9
+ */
10
+
11
+ import { useState } from "react";
12
+ import { ExternalLink } from "lucide-react";
13
+ import { Input, Skeleton } from "@graph8/sdk/ui";
14
+ import { ApiError } from "@/g8/components/api-error";
15
+ import { EmptyState } from "@/g8/components/empty-state";
16
+ import { FirstNotice } from "@/g8/components/paging-notices";
17
+ import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
18
+ import { g8Get } from "@/g8/lib/g8-client";
19
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
20
+ import { PLATFORMS, fmt, itemsOf, platformLabel, when } from "../lib/signals-types";
21
+ import type { TopAccount } from "../lib/signals-types";
22
+
23
+ const LIMIT = 200;
24
+ const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
25
+
26
+ export function TopEngagers() {
27
+ const [days, setDays] = useState("30");
28
+ const [platform, setPlatform] = useState("");
29
+ const [minPosts, setMinPosts] = useState("");
30
+ const min = minPosts.trim() === "" ? undefined : Number(minPosts);
31
+ const minOk = min === undefined || (Number.isInteger(min) && min >= 1);
32
+ const q = useG8Query(
33
+ (signal) => g8Get<unknown>("/social-listeners/engagements/top-accounts", { days: Number(days), platform: platform || undefined, min_posts: minOk ? min : undefined, limit: LIMIT }, { signal }).then((r) => itemsOf<TopAccount>(r.data)),
34
+ [days, platform, minOk ? min : undefined],
35
+ { key: ["signals", "top", days, platform, minOk ? min ?? "" : ""] },
36
+ );
37
+ const rows = q.data ?? [];
38
+ return (
39
+ <div className="grid gap-3">
40
+ <div className="flex flex-wrap items-center gap-2">
41
+ <select aria-label="Window" className={SELECT} value={days} onChange={(e) => setDays(e.target.value)}>{[7, 30, 90, 365].map((d) => <option key={d} value={d}>Last {d} days</option>)}</select>
42
+ <select aria-label="Channel" className={SELECT} value={platform} onChange={(e) => setPlatform(e.target.value)}><option value="">All channels</option>{PLATFORMS.map((p) => <option key={p.value} value={p.value}>{p.label}</option>)}</select>
43
+ <Input aria-label="Minimum posts engaged" className="w-44" inputMode="numeric" placeholder="Minimum posts" value={minPosts} onChange={(e) => setMinPosts(e.target.value)} />
44
+ {!minOk ? <span role="alert" className="text-error text-xs">Use a whole number, 1 or more.</span> : null}
45
+ </div>
46
+ {q.error ? <ApiError error={q.error} title="Could not load top engagers" onRetry={() => void q.refetch()} /> : q.loading && !q.data ? <Skeleton className="h-24 w-full" aria-label="Loading top engagers" /> : rows.length === 0 ? (
47
+ <EmptyState compact title="No repeat engagers yet" description="Accounts appear when they engage with more than one matched post." />
48
+ ) : (
49
+ <table className="w-full text-sm">
50
+ <thead><tr className="text-muted-foreground text-left text-xs"><th className="p-2">Account</th><th className="p-2">Channel</th><th className="p-2">Posts</th><th className="p-2">Comments</th><th className="p-2">Reactions</th><th className="p-2">Last engaged</th></tr></thead>
51
+ <tbody>
52
+ {rows.map((a) => (
53
+ <tr key={`${a.platform}:${a.author_external_id}`} className="border-t">
54
+ <td className="p-2"><span className="flex items-center gap-2"><EntityAvatar name={a.author_name || a.author_handle || "?"} size="sm" /><span><span className="block font-medium">{a.author_name || a.author_handle || "-"}</span><span className="text-muted-foreground block text-xs">{a.author_headline || "-"}</span></span>{a.author_url && /^https?:\/\//.test(a.author_url) ? <a href={a.author_url} target="_blank" rel="noreferrer noopener" aria-label={`Open ${a.author_name || a.author_handle || "profile"}`}><ExternalLink className="size-3" /></a> : null}</span></td>
55
+ <td className="p-2">{platformLabel(a.platform)}</td>
56
+ <td className="p-2 tabular-nums">{fmt(a.posts_engaged)}</td>
57
+ <td className="p-2 tabular-nums">{fmt(a.comments)}</td>
58
+ <td className="p-2 tabular-nums">{fmt(a.reactions)}</td>
59
+ <td className="p-2">{when(a.last_engaged_at)}</td>
60
+ </tr>
61
+ ))}
62
+ </tbody>
63
+ </table>
64
+ )}
65
+ <FirstNotice shown={rows.length} limit={LIMIT} noun="accounts" />
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ /**
4
+ * XIntegration: which X credentials listeners poll with, and connecting or removing your own X developer app.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. getXIntegration (GET /social-listeners/integrations/x) read whether your own credentials are stored (never what they are)
8
+ * 2. getXEffectiveMode (GET /social-listeners/integrations/x/mode) read which credentials are ACTUALLY in use and the shared allowance left
9
+ * 3. listListeners (GET /social-listeners) read to count the X listeners a disconnect affects
10
+ * 4. testXCredentials (POST /social-listeners/integrations/x/test) billable tier: a live call to X, stores nothing; ConfirmSpendDialog
11
+ * 5. connectXCredentials (POST /social-listeners/integrations/x) billable tier, external account: stores the key and secret write-only; ConfirmSpendDialog
12
+ * 6. disconnectXCredentials (DELETE /social-listeners/integrations/x) destructive: pauses X listeners; ConfirmSpendDialog with the real count
13
+ * The key and secret live only in this form's state and are cleared as soon as a call is sent; they are never shown again.
14
+ * Nothing connects on its own: every call is a confirmed click.
15
+ */
16
+
17
+ import { useState } from "react";
18
+ import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
19
+ import { ApiError } from "@/g8/components/api-error";
20
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
21
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
22
+ import { SectionCard } from "@/g8/components/screen/section-card";
23
+ import { StatusPill } from "@/g8/components/screen/status-pill";
24
+ import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
25
+ import { plural } from "@/g8/lib/plural";
26
+ import { useBrand } from "@/g8/hooks/use-brand";
27
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
28
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
29
+ import { fmt, itemsOf, when } from "../lib/signals-types";
30
+ import type { Listener, XMode, XStatus } from "../lib/signals-types";
31
+
32
+ type Step = "test" | "connect" | "disconnect" | null;
33
+ interface TestResult { ok?: boolean; x_app_username?: string | null; error?: string | null }
34
+
35
+ export function XIntegration() {
36
+ const brand = useBrand();
37
+ const [apiKey, setApiKey] = useState("");
38
+ const [apiSecret, setApiSecret] = useState("");
39
+ const [step, setStep] = useState<Step>(null);
40
+ const status = useG8Query((signal) => g8Get<XStatus>("/social-listeners/integrations/x", {}, { signal }).then((r) => r.data), [], { key: ["signals", "x", "status"] });
41
+ const mode = useG8Query((signal) => g8Get<XMode>("/social-listeners/integrations/x/mode", {}, { signal }).then((r) => r.data), [], { key: ["signals", "x", "mode"] });
42
+ const listeners = useG8Query((signal) => g8Get<unknown>("/social-listeners", {}, { signal }).then((r) => itemsOf<Listener>(r.data)), [], { key: ["signals", "listeners"] });
43
+ const known = listeners.data !== undefined;
44
+ const activeX = (listeners.data ?? []).filter((l) => l.platform === "x" && l.is_active).length;
45
+
46
+ const send = (path: string): Promise<TestResult> => {
47
+ const body = { api_key: apiKey.trim(), api_secret: apiSecret.trim() };
48
+ setApiKey(""); setApiSecret("");
49
+ return g8Post<TestResult>(path, body).then((r) => r.data);
50
+ };
51
+ // The fields are cleared when a call is sent, so the dialog closes on failure too: confirming again would post empty strings.
52
+ const test = useG8Mutation(() => send("/social-listeners/integrations/x/test"), { onSuccess: () => setStep(null), onError: () => setStep(null) });
53
+ const connect = useG8Mutation(() => send("/social-listeners/integrations/x"), { onSuccess: () => setStep(null), onError: () => setStep(null), invalidates: [["signals", "x"], ["signals", "listeners"]] });
54
+ const disconnect = useG8Mutation((): Promise<unknown> => g8Delete("/social-listeners/integrations/x"), { onSuccess: () => setStep(null), invalidates: [["signals", "x"], ["signals", "listeners"], ["signals", "stats"]] });
55
+ const filled = apiKey.trim() !== "" && apiSecret.trim() !== "";
56
+ const s = status.data;
57
+
58
+ return (
59
+ <div className="grid gap-4">
60
+ <SectionCard title="X credentials in use" description={`Listeners on X poll with your own developer app when one is connected, otherwise with the shared allowance from ${brand.linkLabel}.`}>
61
+ {status.error ? <ApiError error={status.error} title="Could not read the X connection" onRetry={() => void status.refetch()} /> : status.loading && !s ? <Skeleton className="h-16 w-full" /> : (
62
+ <div className="grid gap-3">
63
+ <p className="flex flex-wrap items-center gap-2 text-sm">Your own app <StatusPill tone={s?.connected ? "positive" : "neutral"}>{s?.connected ? "connected" : "not connected"}</StatusPill>{s?.degraded_reason ? <StatusPill tone="warning">{s.degraded_reason}</StatusPill> : null}</p>
64
+ <KeyValueList columns={2} items={[
65
+ { label: "In use", value: mode.data?.auth_mode ?? "" },
66
+ { label: "App", value: s?.x_app_username ?? "" },
67
+ { label: "Connected", value: s?.connected_at ? when(s.connected_at) : "" },
68
+ { label: "Shared allowance left", value: mode.data?.shared_available ? `${fmt(mode.data.shared_cap_remaining)} of ${fmt(mode.data.shared_cap_total)}` : mode.data ? "Shared mode is not available" : "" },
69
+ ]} />
70
+ {mode.error ? <ApiError compact error={mode.error} title="Could not read the X mode" onRetry={() => void mode.refetch()} /> : null}
71
+ {s?.connected ? (
72
+ <div className="grid gap-1">
73
+ <div><Button variant="outline" size="sm" disabled={!known} onClick={() => setStep("disconnect")}>Disconnect your X app</Button></div>
74
+ {listeners.error ? <ApiError compact error={listeners.error} title="Could not count the X listeners this would pause" onRetry={() => void listeners.refetch()} /> : !known ? <p className="text-muted-foreground text-xs">Counting the X listeners this would pause...</p> : null}
75
+ </div>
76
+ ) : null}
77
+ </div>
78
+ )}
79
+ </SectionCard>
80
+ <SectionCard title={s?.connected ? "Replace your X app" : "Connect your X app"} description="The key and secret are write-only: stored encrypted, never shown again. Test them first so a typo does not pause your listeners.">
81
+ <form className="grid gap-3 sm:max-w-md" autoComplete="off" onSubmit={(e) => e.preventDefault()}>
82
+ <div><Label htmlFor="g8-sl-xkey" className="mb-1">API key</Label><Input id="g8-sl-xkey" type="password" autoComplete="off" value={apiKey} onChange={(e) => setApiKey(e.target.value)} /></div>
83
+ <div><Label htmlFor="g8-sl-xsecret" className="mb-1">API secret</Label><Input id="g8-sl-xsecret" type="password" autoComplete="off" value={apiSecret} onChange={(e) => setApiSecret(e.target.value)} /></div>
84
+ <div className="flex gap-2">
85
+ <Button type="button" variant="outline" disabled={!filled} onClick={() => setStep("test")}>Test credentials</Button>
86
+ <Button type="button" disabled={!filled} onClick={() => setStep("connect")}>Connect</Button>
87
+ </div>
88
+ <p className="text-muted-foreground text-xs">Testing clears both fields so the secret is not kept on screen: type them again to connect.</p>
89
+ {test.data ? <p role="status" className="text-sm">{test.data.ok ? `The credentials work${test.data.x_app_username ? ` (app ${test.data.x_app_username})` : ""}. Nothing was stored.` : `The credentials were refused${test.data.error ? `: ${test.data.error}` : ""}. Nothing was stored.`}</p> : null}
90
+ {connect.data ? <p role="status" className="text-sm">Connected{connect.data.x_app_username ? ` as ${connect.data.x_app_username}` : ""}.</p> : null}
91
+ {test.error ? <ApiError error={test.error} title="The test did not run" /> : null}
92
+ {connect.error ? <ApiError error={connect.error} title="Could not connect" /> : null}
93
+ </form>
94
+ </SectionCard>
95
+ <ConfirmSpendDialog
96
+ open={step === "test"}
97
+ tier="billable"
98
+ title="Test these X credentials?"
99
+ estimatedCredits={0}
100
+ description={<p>{brand.linkLabel} calls X once with this key and secret to check them. Nothing is stored. The fields are cleared when you confirm.</p>}
101
+ confirmLabel="Test"
102
+ confirming={test.loading}
103
+ onCancel={() => setStep(null)}
104
+ onConfirm={() => void test.mutate(undefined)}
105
+ />
106
+ <ConfirmSpendDialog
107
+ open={step === "connect"}
108
+ tier="billable"
109
+ title="Connect this X developer app?"
110
+ description={<p>Your key and secret are stored encrypted and X polls then bill your app directly instead of the shared allowance from {brand.linkLabel}. Existing X listeners keep running under it. The fields are cleared when you confirm.</p>}
111
+ confirmLabel="Connect"
112
+ confirming={connect.loading}
113
+ onCancel={() => setStep(null)}
114
+ onConfirm={() => void connect.mutate(undefined)}
115
+ />
116
+ <ConfirmSpendDialog
117
+ open={step === "disconnect"}
118
+ tier="destructive"
119
+ title="Disconnect your X app?"
120
+ affected={known ? activeX : undefined}
121
+ description={<><p>{plural(activeX, "active X listener")} {activeX === 1 ? "is" : "are"} paused. If shared mode is available they resume under it; if not, they stay paused and stop matching.</p>{disconnect.error ? <ApiError error={disconnect.error} title="Could not disconnect" /> : null}</>}
122
+ confirmLabel="Disconnect"
123
+ confirming={disconnect.loading}
124
+ onCancel={() => setStep(null)}
125
+ onConfirm={() => void disconnect.mutate(undefined)}
126
+ />
127
+ </div>
128
+ );
129
+ }