@graph8/sdk 0.362.0 → 0.370.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.
- package/README.md +14 -10
- package/blocks/_foundation/README.md +64 -1
- package/blocks/_foundation/block.json +79 -6
- package/blocks/_foundation/components/api-error.tsx +9 -7
- package/blocks/_foundation/components/brand-provider.tsx +23 -0
- package/blocks/_foundation/components/confirmed-action.tsx +90 -0
- package/blocks/_foundation/components/connect-graph8.tsx +280 -0
- package/blocks/_foundation/components/screen/index.ts +1 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +15 -97
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/hooks/use-brand.ts +52 -0
- package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
- package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
- package/blocks/_foundation/hooks/use-paginated.ts +120 -0
- package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
- package/blocks/_foundation/lib/g8-cache.ts +454 -0
- package/blocks/_foundation/lib/g8-client.ts +1 -1
- package/blocks/_foundation/lib/plural.ts +23 -0
- package/blocks/_foundation/server/connect-route.ts +15 -0
- package/blocks/_foundation/server/connect.ts +127 -0
- package/blocks/_foundation/server/local-key.ts +70 -0
- package/blocks/_foundation/server/proxy.ts +103 -3
- package/blocks/_foundation/server/route.ts +16 -4
- package/blocks/agents/README.md +14 -0
- package/blocks/agents/block.json +33 -1
- package/blocks/agents/components/agent-builder.tsx +13 -8
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
- package/blocks/agents/components/agent-run-detail.tsx +102 -0
- package/blocks/agents/components/agent-runs.tsx +16 -7
- package/blocks/agents/components/agents-screen.tsx +184 -0
- package/blocks/analytics/README.md +13 -0
- package/blocks/analytics/block.json +45 -1
- package/blocks/analytics/components/analytics-screen.tsx +209 -0
- package/blocks/analytics/components/leaderboard.tsx +14 -7
- package/blocks/analytics/components/report-builder.tsx +23 -9
- package/blocks/analytics/components/report-detail.tsx +84 -0
- package/blocks/analytics/components/trend-chart.tsx +62 -0
- package/blocks/appointments/block.json +6 -2
- package/blocks/appointments/components/availability-editor.tsx +17 -5
- package/blocks/appointments/components/event-type-editor.tsx +2 -1
- package/blocks/appointments/components/event-type-related.tsx +4 -2
- package/blocks/appointments/components/meeting-list.tsx +9 -5
- package/blocks/appointments/components/meetings-screen.tsx +7 -4
- package/blocks/appointments/components/scheduling-screen.tsx +2 -1
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +17 -0
- package/blocks/campaigns/README.md +41 -11
- package/blocks/campaigns/block.json +105 -42
- package/blocks/campaigns/components/campaign-assets.tsx +16 -5
- package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
- package/blocks/campaigns/components/campaign-list.tsx +7 -3
- package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
- package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
- package/blocks/campaigns/components/icp-detail.tsx +154 -0
- package/blocks/campaigns/components/icp-editor.tsx +15 -15
- package/blocks/campaigns/components/persona-detail.tsx +172 -0
- package/blocks/campaigns/components/persona-editor.tsx +14 -11
- package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
- package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
- package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
- package/blocks/coach/README.md +10 -0
- package/blocks/coach/block.json +16 -1
- package/blocks/coach/components/coach-screen.tsx +222 -0
- package/blocks/copilot/README.md +13 -0
- package/blocks/copilot/block.json +14 -1
- package/blocks/copilot/components/copilot-panel.tsx +14 -5
- package/blocks/copilot/components/copilot-screen.tsx +148 -0
- package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
- package/blocks/crm/README.md +28 -0
- package/blocks/crm/block.json +58 -2
- package/blocks/crm/components/companies-screen.tsx +345 -0
- package/blocks/crm/components/company-detail.tsx +8 -6
- package/blocks/crm/components/company-table.tsx +11 -4
- package/blocks/crm/components/contact-detail.tsx +6 -3
- package/blocks/crm/components/contact-table.tsx +10 -3
- package/blocks/crm/components/contacts-screen.tsx +359 -0
- package/blocks/crm/components/dedupe-review.tsx +7 -5
- package/blocks/crm/components/import-wizard.tsx +4 -2
- package/blocks/crm/components/notes-panel.tsx +4 -4
- package/blocks/crm/components/record-form.tsx +5 -3
- package/blocks/crm/components/task-list.tsx +4 -4
- package/blocks/crm/components/tasks-screen.tsx +379 -0
- package/blocks/crm/hooks/use-custom-fields.ts +6 -1
- package/blocks/crm/hooks/use-page-query.ts +7 -1
- package/blocks/crm/lib/crm-types.ts +12 -2
- package/blocks/crm/lib/screen-parts.tsx +105 -0
- package/blocks/deals/block.json +9 -3
- package/blocks/deals/components/deal-detail.tsx +23 -11
- package/blocks/deals/components/deal-related.tsx +1 -1
- package/blocks/deals/components/deals-screen.tsx +10 -5
- package/blocks/deals/components/stage-editor.tsx +4 -4
- package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/dialer/README.md +34 -2
- package/blocks/dialer/block.json +67 -1
- package/blocks/dialer/components/call-contact-button.tsx +252 -0
- package/blocks/dialer/components/call-log-screen.tsx +205 -0
- package/blocks/dialer/components/call-log.tsx +7 -3
- package/blocks/dialer/components/call-queue.tsx +1 -1
- package/blocks/dialer/components/dialer-screen.tsx +319 -0
- package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
- package/blocks/dialer/components/live-calls-table.tsx +7 -3
- package/blocks/dialer/components/session-start-form.tsx +4 -2
- package/blocks/dialer/components/softphone.tsx +3 -1
- package/blocks/enrichment/README.md +14 -2
- package/blocks/enrichment/block.json +32 -4
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
- package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
- package/blocks/enrichment/components/enrich-button.tsx +32 -9
- package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
- package/blocks/enrichment/components/waterfall-config.tsx +39 -10
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
- package/blocks/forms/block.json +32 -18
- package/blocks/forms/components/form-builder.tsx +3 -1
- package/blocks/forms/components/form-embed.tsx +3 -1
- package/blocks/forms/components/forms-screen.tsx +5 -2
- package/blocks/forms/components/submissions-table.tsx +3 -1
- package/blocks/inbox/README.md +27 -3
- package/blocks/inbox/block.json +14 -2
- package/blocks/inbox/components/inbox-list.tsx +7 -3
- package/blocks/inbox/components/inbox-screen.tsx +294 -27
- package/blocks/inbox/components/reply-composer.tsx +65 -9
- package/blocks/inbox/components/tag-and-assign.tsx +4 -2
- package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
- package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
- package/blocks/inbox/lib/inbox-types.ts +47 -1
- package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
- package/blocks/integrations/README.md +9 -0
- package/blocks/integrations/block.json +46 -1
- package/blocks/integrations/components/crm-detail.tsx +100 -0
- package/blocks/integrations/components/integration-list.tsx +9 -4
- package/blocks/integrations/components/integrations-screen.tsx +135 -0
- package/blocks/integrations/components/sync-status.tsx +5 -3
- package/blocks/integrations/components/webhook-detail.tsx +160 -0
- package/blocks/integrations/components/webhook-manager.tsx +13 -8
- package/blocks/integrations/lib/integrations-utils.ts +6 -1
- package/blocks/intent/README.md +14 -0
- package/blocks/intent/block.json +27 -1
- package/blocks/intent/components/intent-feed.tsx +5 -2
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
- package/blocks/intent/components/intent-screen.tsx +265 -0
- package/blocks/intent/components/keyword-detail.tsx +124 -0
- package/blocks/lifecycle/block.json +13 -8
- package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +3 -1
- package/blocks/lifecycle/lib/lifecycle.ts +1 -1
- package/blocks/lists/README.md +13 -0
- package/blocks/lists/block.json +39 -1
- package/blocks/lists/components/add-to-list-button.tsx +3 -2
- package/blocks/lists/components/list-picker.tsx +5 -1
- package/blocks/lists/components/list-table.tsx +11 -5
- package/blocks/lists/components/lists-screen.tsx +344 -0
- package/blocks/lists/components/suppression-manager.tsx +6 -2
- package/blocks/lists/components/suppressions-screen.tsx +255 -0
- package/blocks/mailboxes/README.md +17 -0
- package/blocks/mailboxes/block.json +65 -17
- package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
- package/blocks/mailboxes/components/domain-detail.tsx +142 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
- package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
- package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
- package/blocks/mailboxes/components/warmup-health.tsx +4 -2
- package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
- package/blocks/newsletter/block.json +40 -24
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
- package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
- package/blocks/nurture/block.json +28 -14
- package/blocks/nurture/components/nurture-builder.tsx +3 -1
- package/blocks/nurture/components/nurture-enroll.tsx +6 -3
- package/blocks/nurture/components/nurture-form.tsx +23 -3
- package/blocks/nurture/components/nurture-screen.tsx +10 -6
- package/blocks/nurture/lib/nurture-types.ts +1 -1
- package/blocks/objects/README.md +17 -0
- package/blocks/objects/block.json +29 -16
- package/blocks/objects/components/object-not-found.tsx +3 -1
- package/blocks/objects/components/object-record-detail.tsx +2 -1
- package/blocks/objects/components/object-schema-editor.tsx +3 -1
- package/blocks/objects/components/object-table.tsx +13 -3
- package/blocks/objects/components/objects-screen.tsx +147 -0
- package/blocks/objects/lib/object-types.ts +1 -1
- package/blocks/pages/block.json +28 -23
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +9 -4
- package/blocks/pages/components/page-list.tsx +2 -1
- package/blocks/quotes/block.json +17 -12
- package/blocks/quotes/components/product-picker.tsx +3 -1
- package/blocks/quotes/components/quote-related.tsx +5 -2
- package/blocks/radar/README.md +10 -0
- package/blocks/radar/block.json +41 -1
- package/blocks/radar/components/competitor-detail.tsx +117 -0
- package/blocks/radar/components/competitor-tracker.tsx +3 -1
- package/blocks/radar/components/radar-report.tsx +4 -2
- package/blocks/radar/components/radar-screen.tsx +214 -0
- package/blocks/radar/components/report-detail.tsx +104 -0
- package/blocks/search/README.md +10 -0
- package/blocks/search/block.json +16 -1
- package/blocks/search/components/filter-builder.tsx +3 -3
- package/blocks/search/components/prospect-results.tsx +5 -3
- package/blocks/search/components/prospect-search-screen.tsx +235 -0
- package/blocks/search/components/saved-searches.tsx +4 -1
- package/blocks/search/hooks/use-filter-options.ts +20 -6
- package/blocks/search/lib/search-types.ts +2 -0
- package/blocks/sequencer/README.md +49 -9
- package/blocks/sequencer/block.json +99 -19
- package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
- package/blocks/sequencer/components/sequence-builder.tsx +211 -46
- package/blocks/sequencer/components/sequence-detail.tsx +259 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
- package/blocks/sequencer/components/sequence-list.tsx +33 -8
- package/blocks/sequencer/components/sequence-stats.tsx +3 -2
- package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
- package/blocks/sequencer/components/sequences-screen.tsx +159 -0
- package/blocks/sequencer/components/step-editor.tsx +3 -1
- package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
- package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
- package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
- package/blocks/social-ads/README.md +11 -0
- package/blocks/social-ads/block.json +101 -2
- package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
- package/blocks/social-ads/components/ads-screen.tsx +152 -0
- package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
- package/blocks/social-ads/components/audience-sync.tsx +11 -7
- package/blocks/social-ads/components/campaign-detail.tsx +133 -0
- package/blocks/social-ads/components/creative-detail.tsx +150 -0
- package/blocks/social-ads/components/creative-gallery.tsx +15 -5
- package/blocks/social-ads/components/post-composer.tsx +3 -1
- package/blocks/social-ads/components/post-detail.tsx +160 -0
- package/blocks/social-ads/components/social-post-list.tsx +11 -3
- package/blocks/social-ads/components/social-screen.tsx +160 -0
- package/blocks/visitors/README.md +10 -0
- package/blocks/visitors/block.json +25 -1
- package/blocks/visitors/components/snippet-install.tsx +3 -1
- package/blocks/visitors/components/visitor-company-card.tsx +2 -2
- package/blocks/visitors/components/visitor-detail.tsx +105 -0
- package/blocks/visitors/components/visitor-feed.tsx +11 -2
- package/blocks/visitors/components/visitors-screen.tsx +166 -0
- package/blocks/webchat/README.md +10 -0
- package/blocks/webchat/block.json +21 -7
- package/blocks/webchat/components/chat-widget-config.tsx +7 -4
- package/blocks/webchat/components/conversation-list.tsx +7 -3
- package/blocks/webchat/components/webchat-screen.tsx +245 -0
- package/blocks/workbench/README.md +11 -0
- package/blocks/workbench/block.json +48 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
- package/blocks/workbench/components/workbench-grid.tsx +9 -3
- package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
- package/blocks/workbench/components/workbench-screen.tsx +192 -0
- package/blocks/workbench/components/workbench-sources.tsx +194 -0
- package/blocks/workflows/README.md +13 -0
- package/blocks/workflows/block.json +28 -9
- package/blocks/workflows/components/run-history.tsx +31 -9
- package/blocks/workflows/components/trigger-config.tsx +3 -1
- package/blocks/workflows/components/workflow-editor.tsx +3 -1
- package/blocks/workflows/components/workflow-list.tsx +13 -6
- package/blocks/workflows/components/workflows-screen.tsx +193 -0
- package/dist/cli.js +2709 -1721
- package/dist/errors.d.mts +34 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +46 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.mjs +21 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.browser.js +11 -5
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +11 -5
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +164 -25
- package/dist/index.d.ts +164 -25
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +10 -4
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +10 -4
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +142 -5
- package/dist/react.d.ts +142 -5
- package/dist/react.js +10 -4
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +10 -4
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/add.d.ts +11 -4
- package/dist/types/blocks/brand.d.ts +113 -0
- package/dist/types/blocks/compose.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +22 -19
- package/dist/types/blocks/doctor.d.ts +5 -1
- package/dist/types/blocks/navfile.d.ts +35 -0
- package/dist/types/blocks/paths.d.ts +4 -0
- package/dist/types/blocks/prefixes.d.ts +37 -0
- package/dist/types/blocks/registry.d.ts +17 -0
- package/dist/types/blocks/screens.d.ts +53 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/errors.d.ts +33 -0
- package/dist/types/http.d.ts +2 -18
- package/dist/types/version.d.ts +2 -2
- package/docs/agents.md +15 -4
- package/docs/api/README.md +2 -2
- package/docs/api/by-path.md +1 -0
- package/docs/api/desk.d.ts +98 -2
- package/docs/api/desk.md +123 -1
- package/docs/api/inbox.d.ts +10 -2
- package/docs/api/inbox.md +10 -2
- package/docs/api/seo.d.ts +19 -1
- package/docs/api/seo.md +24 -1
- package/package.json +16 -2
- package/stacks/6sense.json +41 -16
- package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
- package/stacks/apollo.json +118 -36
- package/stacks/calendly.json +60 -0
- package/stacks/clay/pages/companies.tsx.tmpl +15 -0
- package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
- package/stacks/clay.json +59 -26
- package/stacks/gong.json +81 -35
- package/stacks/hootsuite.json +99 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
- package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
- package/stacks/hubspot.json +167 -66
- package/stacks/intercom.json +48 -20
- package/stacks/mailchimp.json +52 -33
- package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
- package/stacks/outreach.json +88 -25
- package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
- package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
- package/stacks/pipedrive.json +115 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
- package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
- package/stacks/salesforce.json +81 -31
- package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +102 -0
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +40 -9
- package/templates/next-app/app/layout.tsx.tmpl +7 -2
- package/templates/next-app/components/app-shell.tsx.tmpl +99 -89
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
* Returns { content (HTML), plain_content }; it delivers NOTHING.
|
|
10
10
|
* 2. POST /inbox/{reply_id}/send (inbox.sendReply) EXTERNAL -> ConfirmSpendDialog first
|
|
11
11
|
* Reaches a real person and cannot be recalled. A fixed Idempotency-Key per compose makes a double
|
|
12
|
-
* click or a retry safe.
|
|
12
|
+
* click or a retry safe. Sends `from_address` = the thread's own sender (email mailbox, SMS number,
|
|
13
|
+
* LinkedIn account id) and `to` = the thread's contact, so the reply leaves from the mailbox the
|
|
14
|
+
* conversation is on. Before this the composer sent neither sender nor (for SMS) number, and the API
|
|
15
|
+
* refused every email reply with 400 "'from_address' (mailbox) is required".
|
|
16
|
+
* Only when an SMS or LinkedIn thread names no sender, one read fills a sender picker:
|
|
17
|
+
* GET /sms/phone-numbers (channels.listSmsPhoneNumbers) or
|
|
18
|
+
* GET /inbox/linkedin/accounts (aiInbox.listInboxLinkedinAccounts) READ
|
|
13
19
|
*
|
|
14
20
|
* Both calls are gated by tier: nothing is billed or sent until the person clicks the dialog's button.
|
|
15
21
|
*/
|
|
@@ -18,9 +24,10 @@ import { useIdempotencyKey } from "../lib/idempotency";
|
|
|
18
24
|
import { Button, Input, Textarea } from "@graph8/sdk/ui";
|
|
19
25
|
import { ApiError } from "@/g8/components/api-error";
|
|
20
26
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
21
|
-
import { g8Post } from "@/g8/lib/g8-client";
|
|
27
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
22
28
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
23
|
-
import {
|
|
29
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
30
|
+
import { replyRecipient, replySender, senderOptions, stripHtml, type InboxThread } from "../lib/inbox-types";
|
|
24
31
|
|
|
25
32
|
type DraftOperation = "generate_from_scratch" | "shorten" | "expand" | "fix_grammar_spelling";
|
|
26
33
|
type Pending = { kind: "draft"; operation: DraftOperation } | { kind: "send" } | null;
|
|
@@ -51,10 +58,22 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
|
|
|
51
58
|
const [body, setBody] = useState("");
|
|
52
59
|
const [subject, setSubject] = useState(thread.subject ? (/^re:/i.test(thread.subject) ? thread.subject : `Re: ${thread.subject}`) : "");
|
|
53
60
|
const [to, setTo] = useState(replyRecipient(thread));
|
|
61
|
+
const threadSender = replySender(thread);
|
|
62
|
+
const [from, setFrom] = useState(threadSender);
|
|
54
63
|
const [tone, setTone] = useState<(typeof TONES)[number]>("professional");
|
|
55
64
|
const [objective, setObjective] = useState<(typeof OBJECTIVES)[number]>("follow_up");
|
|
56
65
|
const [pending, setPending] = useState<Pending>(null);
|
|
57
|
-
const idempotency = useIdempotencyKey(JSON.stringify([thread.channel, thread.id, body, to, channel === "email" ? subject : null]));
|
|
66
|
+
const idempotency = useIdempotencyKey(JSON.stringify([thread.channel, thread.id, body, to, from, channel === "email" ? subject : null]));
|
|
67
|
+
// SMS and LinkedIn cannot send without a sender; when the thread does not name one, offer the org's.
|
|
68
|
+
const needsSenderPick = !threadSender && (channel === "sms" || channel === "linkedin");
|
|
69
|
+
const senders = useG8Query(
|
|
70
|
+
async (signal) => {
|
|
71
|
+
const path = channel === "sms" ? "/sms/phone-numbers" : "/inbox/linkedin/accounts";
|
|
72
|
+
return senderOptions(channel, (await g8Get<unknown>(path, undefined, { signal })).data);
|
|
73
|
+
},
|
|
74
|
+
[channel, needsSenderPick],
|
|
75
|
+
{ enabled: needsSenderPick },
|
|
76
|
+
);
|
|
58
77
|
const threadKey = `${thread.channel}:${thread.id}`;
|
|
59
78
|
const currentThread = useRef(threadKey);
|
|
60
79
|
currentThread.current = threadKey;
|
|
@@ -64,6 +83,7 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
|
|
|
64
83
|
setBody("");
|
|
65
84
|
setSubject(thread.subject ? (/^re:/i.test(thread.subject) ? thread.subject : `Re: ${thread.subject}`) : "");
|
|
66
85
|
setTo(replyRecipient(thread));
|
|
86
|
+
setFrom(replySender(thread));
|
|
67
87
|
setPending(null);
|
|
68
88
|
draft.reset();
|
|
69
89
|
send.reset();
|
|
@@ -85,11 +105,18 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
|
|
|
85
105
|
const send = useG8Mutation(async () => {
|
|
86
106
|
const env = await g8Post<{ message_id?: string | null; status: string }>(
|
|
87
107
|
`/inbox/${encodeURIComponent(thread.id)}/send`,
|
|
88
|
-
{
|
|
108
|
+
{
|
|
109
|
+
body,
|
|
110
|
+
channel,
|
|
111
|
+
subject: channel === "email" ? subject : undefined,
|
|
112
|
+
to: to || undefined,
|
|
113
|
+
// Empty only for an email thread the list did not label; the API then uses the thread's mailbox.
|
|
114
|
+
from_address: from || undefined,
|
|
115
|
+
},
|
|
89
116
|
{ idempotencyKey: idempotency.get() },
|
|
90
117
|
);
|
|
91
118
|
return env.data;
|
|
92
|
-
});
|
|
119
|
+
}, { invalidates: ["inbox"] });
|
|
93
120
|
|
|
94
121
|
const confirm = async () => {
|
|
95
122
|
const current = pending;
|
|
@@ -112,7 +139,8 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
|
|
|
112
139
|
};
|
|
113
140
|
|
|
114
141
|
const busy = draft.loading || send.loading;
|
|
115
|
-
const
|
|
142
|
+
const hasSender = channel === "email" || from.trim().length > 0;
|
|
143
|
+
const canSend = body.trim().length > 0 && (channel === "linkedin" || to.trim().length > 0) && hasSender && !busy;
|
|
116
144
|
const canTransform = body.trim().length > 0 && !busy;
|
|
117
145
|
const canDraft = Boolean(draftChannel(channel));
|
|
118
146
|
|
|
@@ -121,7 +149,33 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
|
|
|
121
149
|
{channel === "email" ? (
|
|
122
150
|
<Input aria-label="Subject" placeholder="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} />
|
|
123
151
|
) : null}
|
|
124
|
-
|
|
152
|
+
{needsSenderPick ? (
|
|
153
|
+
<div className="space-y-1 text-sm">
|
|
154
|
+
<select
|
|
155
|
+
aria-label="Send from"
|
|
156
|
+
className="h-9 w-full rounded-md border bg-background px-2"
|
|
157
|
+
value={from}
|
|
158
|
+
disabled={senders.loading}
|
|
159
|
+
onChange={(e) => setFrom(e.target.value)}
|
|
160
|
+
>
|
|
161
|
+
<option value="">{senders.loading ? "Loading senders..." : channel === "sms" ? "Choose the number to text from" : "Choose the LinkedIn account to send from"}</option>
|
|
162
|
+
{(senders.data ?? []).map((o) => (
|
|
163
|
+
<option key={o.value} value={o.value}>
|
|
164
|
+
{o.label}
|
|
165
|
+
</option>
|
|
166
|
+
))}
|
|
167
|
+
</select>
|
|
168
|
+
{senders.error ? <ApiError error={senders.error} title="Could not load your senders" onRetry={() => void senders.refetch()} /> : null}
|
|
169
|
+
{!senders.loading && !senders.error && senders.data?.length === 0 ? (
|
|
170
|
+
<p className="text-muted-foreground">{channel === "sms" ? "No SMS numbers on this account yet." : "No LinkedIn accounts connected yet."}</p>
|
|
171
|
+
) : null}
|
|
172
|
+
</div>
|
|
173
|
+
) : from ? (
|
|
174
|
+
<p className="text-xs text-muted-foreground">From {channel === "linkedin" ? "your LinkedIn account" : from}</p>
|
|
175
|
+
) : null}
|
|
176
|
+
{channel !== "linkedin" ? (
|
|
177
|
+
<Input aria-label="To" placeholder={channel === "sms" ? "Phone number" : "Recipient"} value={to} onChange={(e) => setTo(e.target.value)} />
|
|
178
|
+
) : null}
|
|
125
179
|
<Textarea aria-label="Reply" rows={6} placeholder="Write your reply" value={body} onChange={(e) => setBody(e.target.value)} />
|
|
126
180
|
|
|
127
181
|
{canDraft ? (
|
|
@@ -172,7 +226,9 @@ export function ReplyComposer({ thread, onSent, className }: ReplyComposerProps)
|
|
|
172
226
|
open={pending?.kind === "send"}
|
|
173
227
|
tier="external"
|
|
174
228
|
title={`Send this ${channel} reply?`}
|
|
175
|
-
description={`It goes to ${to || "the contact"} from
|
|
229
|
+
description={`It goes to ${to || "the contact"} from ${
|
|
230
|
+
channel === "email" ? (from || "the thread's mailbox") : channel === "sms" ? from : "your LinkedIn account"
|
|
231
|
+
}.`}
|
|
176
232
|
recipients={1}
|
|
177
233
|
confirming={send.loading}
|
|
178
234
|
onConfirm={() => void confirm()}
|
|
@@ -23,6 +23,7 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
|
23
23
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
24
24
|
import type { InboxTag, InboxThread } from "../lib/inbox-types";
|
|
25
25
|
import { ThreadBadge } from "./thread-badge";
|
|
26
|
+
import { pluralWord } from "@/g8/lib/plural";
|
|
26
27
|
|
|
27
28
|
type Pending = { kind: "tag"; ids: string[] } | { kind: "assign"; email: string } | null;
|
|
28
29
|
interface TeamUser {
|
|
@@ -70,9 +71,10 @@ export function TagAndAssign({ thread, availableTags = [], onChanged, className
|
|
|
70
71
|
const tag = useG8Mutation(async (ids: string[]) => {
|
|
71
72
|
// Only the NEW ids: association can fire tag automations, so already-applied tags are not re-sent.
|
|
72
73
|
return g8Post(`/inbox/${encodeURIComponent(thread.id)}/tag`, { tag_ids: ids.filter((id) => !applied.has(id)) }, { query: channelQuery });
|
|
73
|
-
});
|
|
74
|
+
}, { invalidates: ["inbox"] });
|
|
74
75
|
const assign = useG8Mutation(async (email: string) =>
|
|
75
76
|
g8Post(`/inbox/${encodeURIComponent(thread.id)}/assign`, { assignee_email: email }, { query: channelQuery }),
|
|
77
|
+
{ invalidates: ["inbox"] },
|
|
76
78
|
);
|
|
77
79
|
|
|
78
80
|
const addTag = async () => {
|
|
@@ -132,7 +134,7 @@ export function TagAndAssign({ thread, availableTags = [], onChanged, className
|
|
|
132
134
|
</Button>
|
|
133
135
|
</div>
|
|
134
136
|
<Button size="sm" disabled={selected.length === 0 || tag.loading} onClick={() => setPending({ kind: "tag", ids: selected })}>
|
|
135
|
-
Apply {selected.length > 0 ? `${selected.length} ` : ""}
|
|
137
|
+
Apply {selected.length > 0 ? `${selected.length} ` : ""}{pluralWord(selected.length, "tag")}
|
|
136
138
|
</Button>
|
|
137
139
|
{createTag.error ? <ApiError error={createTag.error} title="Could not create the tag" /> : null}
|
|
138
140
|
{tag.error ? <ApiError error={tag.error} title="Could not apply the tags" /> : null}
|
|
@@ -19,6 +19,6 @@ export function useInboxThread(threadId: string | null | undefined, channel: str
|
|
|
19
19
|
return env.data;
|
|
20
20
|
},
|
|
21
21
|
[threadId, channel],
|
|
22
|
-
{ enabled: Boolean(threadId) },
|
|
22
|
+
{ enabled: Boolean(threadId), key: ["inbox", "detail", threadId ?? null, channel ?? null] },
|
|
23
23
|
);
|
|
24
24
|
}
|
|
@@ -35,6 +35,7 @@ export function useInboxThreads(filters: InboxFilters, page = 1, pageSize = 25)
|
|
|
35
35
|
return { threads: env.data ?? [], hasNext: Boolean(env.pagination?.has_next), total: (env.pagination as { total?: number } | undefined)?.total };
|
|
36
36
|
},
|
|
37
37
|
[filters.channel, filters.status, filters.tag, filters.sequenceId, page, pageSize],
|
|
38
|
+
{ key: ["inbox", "list", { channel: filters.channel, status: filters.status, tag: filters.tag, sequenceId: filters.sequenceId, page, pageSize }] },
|
|
38
39
|
);
|
|
39
40
|
return { ...query, threads: query.data?.threads ?? [], hasNext: query.data?.hasNext ?? false, total: query.data?.total };
|
|
40
41
|
}
|
|
@@ -46,6 +46,14 @@ export interface InboxThread {
|
|
|
46
46
|
assignees: InboxAssignee[];
|
|
47
47
|
created_at?: string | null;
|
|
48
48
|
updated_at?: string | null;
|
|
49
|
+
/** Email: the connected mailbox the thread belongs to; a reply is sent from it. */
|
|
50
|
+
mailbox?: string | null;
|
|
51
|
+
/** SMS: your number the thread runs on; a reply is sent from it. */
|
|
52
|
+
from_number?: string | null;
|
|
53
|
+
/** SMS: the contact's number; a reply goes to it. */
|
|
54
|
+
to_number?: string | null;
|
|
55
|
+
/** LinkedIn: the account the conversation belongs to; a reply is sent from it. */
|
|
56
|
+
linkedin_account_id?: number | null;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/** Statuses `GET /inbox?status=` accepts (case-insensitive). Anything else is a 422. */
|
|
@@ -95,6 +103,44 @@ export function formatWhen(iso: string | null | undefined): string {
|
|
|
95
103
|
/** Default recipient for a reply: the contact's address on the thread's channel. */
|
|
96
104
|
export function replyRecipient(thread: InboxThread): string {
|
|
97
105
|
const c = thread.contact;
|
|
98
|
-
if (thread.channel === "sms") return c?.phone
|
|
106
|
+
if (thread.channel === "sms") return thread.to_number || c?.phone || "";
|
|
99
107
|
return c?.email ?? "";
|
|
100
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Default sender for a reply, as `POST /inbox/{id}/send` expects it in `from_address`: the thread's mailbox
|
|
112
|
+
* (email), your number on the thread (SMS) or the numeric LinkedIn account id. Empty when the thread does not
|
|
113
|
+
* say; the API then defaults email itself, and SMS/LinkedIn need a sender picked.
|
|
114
|
+
*/
|
|
115
|
+
export function replySender(thread: InboxThread): string {
|
|
116
|
+
if (thread.channel === "sms") return thread.from_number ?? "";
|
|
117
|
+
if (thread.channel === "linkedin") return thread.linkedin_account_id != null ? String(thread.linkedin_account_id) : "";
|
|
118
|
+
return thread.mailbox ?? "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface SenderOption {
|
|
122
|
+
value: string;
|
|
123
|
+
label: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Senders for a picker from `GET /sms/phone-numbers` or `GET /inbox/linkedin/accounts`. Both answer with an
|
|
128
|
+
* untyped `{ items: [...] }` envelope (no response schema in the contract), so this reads defensively.
|
|
129
|
+
*/
|
|
130
|
+
export function senderOptions(channel: string, payload: unknown): SenderOption[] {
|
|
131
|
+
const raw = Array.isArray(payload) ? payload : ((payload as { items?: unknown[] } | null)?.items ?? []);
|
|
132
|
+
const out: SenderOption[] = [];
|
|
133
|
+
for (const item of raw as Array<Record<string, unknown>>) {
|
|
134
|
+
if (!item || typeof item !== "object") continue;
|
|
135
|
+
if (channel === "sms") {
|
|
136
|
+
const number = item.phone_number ?? item.number ?? item.phone;
|
|
137
|
+
if (typeof number === "string" && number) out.push({ value: number, label: String(item.friendly_name ?? item.label ?? number) });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const id = item.id ?? item.account_id ?? item.linkedin_account_id;
|
|
141
|
+
if (typeof id !== "number" && !(typeof id === "string" && /^\d+$/.test(id))) continue;
|
|
142
|
+
const name = [item.first_name ?? item.firstName, item.last_name ?? item.lastName].filter(Boolean).join(" ");
|
|
143
|
+
out.push({ value: String(id), label: String(name || item.name || item.email || item.emailAddress || `Account ${id}`) });
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
@@ -32,11 +32,21 @@ const draft = await g8.api.aiInbox.generateInboxDraft({
|
|
|
32
32
|
});
|
|
33
33
|
const html = (draft.data as { content?: string }).content ?? "";
|
|
34
34
|
|
|
35
|
-
// 4. EXTERNAL: send. Reuse the same idempotency key if
|
|
35
|
+
// 4. EXTERNAL: send from the thread's own mailbox (`mailbox`) to its contact. Reuse the same idempotency key if
|
|
36
|
+
// you retry this exact call. When `from_address` / `to` are omitted the API fills both from the thread, but
|
|
37
|
+
// graph8 APIs older than contract 1.374.0 refuse the send with 400, so name them.
|
|
38
|
+
const mailbox = full.data.mailbox ?? undefined;
|
|
39
|
+
const contactEmail = (full.data.contact as { email?: string | null } | null | undefined)?.email ?? undefined;
|
|
36
40
|
const sent = await g8.api.inbox.sendReply(
|
|
37
41
|
{
|
|
38
42
|
path: { reply_id: thread.id },
|
|
39
|
-
body: {
|
|
43
|
+
body: {
|
|
44
|
+
channel: "email",
|
|
45
|
+
body: html,
|
|
46
|
+
subject: full.data.subject ? `Re: ${full.data.subject}` : null,
|
|
47
|
+
from_address: mailbox,
|
|
48
|
+
to: contactEmail,
|
|
49
|
+
},
|
|
40
50
|
},
|
|
41
51
|
{ idempotencyKey: randomUUID() },
|
|
42
52
|
);
|
|
@@ -5,6 +5,15 @@ Apollo / Salesforce "integrations" settings screen.
|
|
|
5
5
|
|
|
6
6
|
Depends on: `foundation`. Install: `npx @graph8/sdk add integrations`.
|
|
7
7
|
|
|
8
|
+
## Screens
|
|
9
|
+
|
|
10
|
+
`IntegrationsScreen` (`components/integrations-screen.tsx`) is the finished page: copy it as your route. No props are required.
|
|
11
|
+
|
|
12
|
+
- **Connections tab**: the five CRMs and the workspace tools. "Manage sync" opens a side panel with sync switches, job progress and a manual
|
|
13
|
+
push (deep link `?id=hubspot`; a webhook is `?tab=webhooks&id=<webhook id>`). Connecting is a link out to the graph8 app, because OAuth consent only runs there.
|
|
14
|
+
- **Webhooks tab**: create, pause, rotate and delete webhooks, read deliveries. The secret is shown once.
|
|
15
|
+
- Props (all optional): `connectUrl`, `defaultWebhookUrl`, `pushRecords`, `toolbarActions`, `renderDetailActions(record)`, `extraDetailTabs` (each `{ id, label, render(record) }`), where `record` is `{ kind: "crm", provider }` or `{ kind: "webhook", id }`.
|
|
16
|
+
|
|
8
17
|
## Components
|
|
9
18
|
|
|
10
19
|
| Component | What it does | Tiers |
|
|
@@ -85,6 +85,50 @@
|
|
|
85
85
|
"external",
|
|
86
86
|
"destructive"
|
|
87
87
|
]
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "IntegrationsScreen",
|
|
91
|
+
"file": "components/integrations-screen.tsx",
|
|
92
|
+
"purpose": "Finished integrations page: CRM and tool connections with a sync side panel, plus webhook management.",
|
|
93
|
+
"operations": [
|
|
94
|
+
"list_crm_syncs_crm_syncs_get",
|
|
95
|
+
"list_webhooks_webhooks_get"
|
|
96
|
+
],
|
|
97
|
+
"tiers": [
|
|
98
|
+
"read"
|
|
99
|
+
]
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"name": "CrmDetail",
|
|
103
|
+
"file": "components/crm-detail.tsx",
|
|
104
|
+
"purpose": "Complete screen for one CRM integration: connection, sync switches, manual push, disconnect.",
|
|
105
|
+
"operations": [
|
|
106
|
+
"list_crm_syncs_crm_syncs_get",
|
|
107
|
+
"list_connections_integrations_connections_get",
|
|
108
|
+
"delete_connection_integrations_connections__connection_id__delete"
|
|
109
|
+
],
|
|
110
|
+
"tiers": [
|
|
111
|
+
"read",
|
|
112
|
+
"destructive"
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name": "WebhookDetail",
|
|
117
|
+
"file": "components/webhook-detail.tsx",
|
|
118
|
+
"purpose": "Complete screen for one webhook: every field, edit, deliveries, pause, rotate secret, delete.",
|
|
119
|
+
"operations": [
|
|
120
|
+
"list_webhooks_webhooks_get",
|
|
121
|
+
"list_webhook_events_webhooks_events_get",
|
|
122
|
+
"list_webhook_deliveries_webhooks__webhook_id__deliveries_get",
|
|
123
|
+
"update_webhook_webhooks__webhook_id__patch",
|
|
124
|
+
"rotate_webhook_secret_webhooks__webhook_id__rotate_secret_post",
|
|
125
|
+
"delete_webhook_webhooks__webhook_id__delete"
|
|
126
|
+
],
|
|
127
|
+
"tiers": [
|
|
128
|
+
"read",
|
|
129
|
+
"external",
|
|
130
|
+
"destructive"
|
|
131
|
+
]
|
|
88
132
|
}
|
|
89
133
|
],
|
|
90
134
|
"hooks": [],
|
|
@@ -167,6 +211,7 @@
|
|
|
167
211
|
}
|
|
168
212
|
],
|
|
169
213
|
"peerDependencies": {
|
|
170
|
-
"react": ">=18"
|
|
214
|
+
"react": ">=18",
|
|
215
|
+
"lucide-react": ">=0.400"
|
|
171
216
|
}
|
|
172
217
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CrmDetail: the complete screen for one CRM integration (HubSpot, Salesforce, Pipedrive, Zoho, SugarCRM). Shows its connection
|
|
5
|
+
* (status, connected and last sync time, connection id), the continuous sync switches, job progress, manual push and contact fields
|
|
6
|
+
* (SyncStatus), and the actions: disconnect (destructive) when connected, or a link to connect in graph8 when it is not.
|
|
7
|
+
* Connecting is a link out: the OAuth consent screen only runs in the graph8 app.
|
|
8
|
+
*
|
|
9
|
+
* SDK operations, in order:
|
|
10
|
+
* 1. listCrmSyncs GET /crm-syncs (read)
|
|
11
|
+
* 2. listConnections GET /integrations/connections (read; the connection to disconnect)
|
|
12
|
+
* 3. deleteConnection DELETE /integrations/connections/{id}?provider_config_key= (destructive, confirmed)
|
|
13
|
+
* 4. SyncStatus see sync-status.tsx (getCrmStatus, getCrmFields, set push/pull enabled, getSyncJob, pushContactsToCrm)
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactNode } from "react";
|
|
16
|
+
import { Skeleton } from "@graph8/sdk/ui";
|
|
17
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
18
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
19
|
+
import { g8Delete, g8Get } from "@/g8/lib/g8-client";
|
|
20
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
21
|
+
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
22
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
23
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
24
|
+
import { arrayOf, formatWhen, GRAPH8_APP_INTEGRATIONS_PATH, PROVIDER_LABELS } from "../lib/integrations-utils";
|
|
25
|
+
import type { ConnectionRow, CrmProvider, CrmSyncRow } from "../lib/integrations-utils";
|
|
26
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
27
|
+
import { AskButton, useConfirmAction } from "@/g8/components/confirmed-action";
|
|
28
|
+
import { SyncStatus } from "./sync-status";
|
|
29
|
+
import { brandUrl, useBrand } from "@/g8/hooks/use-brand";
|
|
30
|
+
|
|
31
|
+
export interface CrmDetailProps {
|
|
32
|
+
provider: CrmProvider;
|
|
33
|
+
/** Where "Connect in graph8" sends the person. */
|
|
34
|
+
connectUrl?: string;
|
|
35
|
+
/** Contact records (in the CRM's field names) offered for a manual push. */
|
|
36
|
+
records?: ReadonlyArray<Record<string, unknown>>;
|
|
37
|
+
onClose?: () => void;
|
|
38
|
+
onExpand?: () => void;
|
|
39
|
+
onCollapse?: () => void;
|
|
40
|
+
onChanged?: () => void;
|
|
41
|
+
/** Extra header actions from the screen's `renderDetailActions` slot. */
|
|
42
|
+
extraActions?: ReactNode;
|
|
43
|
+
/** Extra tabs from the screen's `extraDetailTabs` slot, after the built-in ones. */
|
|
44
|
+
extraTabs?: { id: string; label: string; content: ReactNode }[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CONNECTED = ["connected", "active", "ok", "healthy", "syncing", "synced", "enabled", "linked"];
|
|
48
|
+
|
|
49
|
+
export function CrmDetail({ provider, connectUrl: connectUrlProp, records, onClose, onExpand, onCollapse, onChanged, extraActions, extraTabs }: CrmDetailProps) {
|
|
50
|
+
const brand = useBrand();
|
|
51
|
+
const connectUrl = connectUrlProp ?? brandUrl(brand, GRAPH8_APP_INTEGRATIONS_PATH);
|
|
52
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
53
|
+
const syncs = useG8Query((signal) => g8Get("/crm-syncs", undefined, { signal }), []);
|
|
54
|
+
const connections = useG8Query((signal) => g8Get("/integrations/connections", undefined, { signal }), []);
|
|
55
|
+
const label = PROVIDER_LABELS[provider];
|
|
56
|
+
|
|
57
|
+
if (syncs.error) return <ApiError error={syncs.error} title="Could not load the integration" onRetry={() => void syncs.refetch()} />;
|
|
58
|
+
if (!syncs.data) return <Skeleton className="h-40 w-full" />;
|
|
59
|
+
const row = arrayOf<CrmSyncRow>(syncs.data).find((r) => r.provider?.toLowerCase() === provider);
|
|
60
|
+
const connected = Boolean(row && CONNECTED.includes(row.status?.toLowerCase()));
|
|
61
|
+
const all = arrayOf<ConnectionRow>(connections.data).filter((c) => (c.provider ?? "").toLowerCase() === provider || (c.provider_config_key ?? "").toLowerCase().split(/[-_]/)[0] === provider);
|
|
62
|
+
const connection = all.find((c) => row?.connection_id && c.connection_id === row.connection_id) ?? (all.length === 1 ? all[0] : undefined);
|
|
63
|
+
const changed = () => { void syncs.refetch(); void connections.refetch(); onChanged?.(); };
|
|
64
|
+
|
|
65
|
+
const actions = connected ? (
|
|
66
|
+
<AskButton ask={ask} label="Disconnect" variant="ghost" tier="destructive" title={`Disconnect ${label}`} confirmLabel="Disconnect"
|
|
67
|
+
disabled={!connection?.connection_id || !connection.provider_config_key}
|
|
68
|
+
description="Syncs configured against this connection stop working. Records already synced stay."
|
|
69
|
+
run={() => g8Delete(`/integrations/connections/${connection!.connection_id}`, { query: { provider_config_key: connection!.provider_config_key } })} onDone={changed} />
|
|
70
|
+
) : (
|
|
71
|
+
<a className="text-sm underline" href={connectUrl} target="_blank" rel="noreferrer">Connect in {brand.linkLabel}</a>
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const tabs = [
|
|
75
|
+
{
|
|
76
|
+
id: "overview",
|
|
77
|
+
label: "Overview",
|
|
78
|
+
content: (
|
|
79
|
+
<KeyValueList columns={2} items={[
|
|
80
|
+
{ label: "Provider", value: label },
|
|
81
|
+
{ label: "Status", value: row?.status ?? "not connected" },
|
|
82
|
+
{ label: "Connected", value: formatWhen(row?.connected_at) },
|
|
83
|
+
{ label: "Last sync", value: formatWhen(row?.last_sync_at) },
|
|
84
|
+
{ label: "Connection", value: row?.connection_id ?? connection?.connection_id ?? "-" },
|
|
85
|
+
{ label: "Config key", value: connection?.provider_config_key ?? "-" },
|
|
86
|
+
]} />
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
{ id: "sync", label: "Sync", content: <SyncStatus provider={provider} records={records} /> },
|
|
90
|
+
];
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<DetailPanel title={label} subtitle="CRM integration" avatar={<EntityAvatar name={label} kind="company" size="lg" />}
|
|
94
|
+
badges={<StatusPill tone={connected ? "positive" : "neutral"}>{connected ? "Connected" : row?.status ?? "Not connected"}</StatusPill>}
|
|
95
|
+
actions={extraActions ? <>{actions}{extraActions}</> : actions} tabs={extraTabs?.length ? [...tabs, ...extraTabs] : tabs} onClose={onClose} onExpand={onExpand} onCollapse={onCollapse} />
|
|
96
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
97
|
+
<ConfirmSpendDialog {...dialog} />
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -26,17 +26,20 @@ import {
|
|
|
26
26
|
arrayOf,
|
|
27
27
|
CRM_PROVIDERS,
|
|
28
28
|
formatWhen,
|
|
29
|
-
|
|
29
|
+
GRAPH8_APP_INTEGRATIONS_PATH,
|
|
30
30
|
looksConnected,
|
|
31
31
|
PROVIDER_LABELS,
|
|
32
32
|
} from "../lib/integrations-utils";
|
|
33
33
|
import type { ConnectionRow, CrmProvider, CrmSyncRow } from "../lib/integrations-utils";
|
|
34
|
+
import { brandUrl, useBrand } from "@/g8/hooks/use-brand";
|
|
34
35
|
|
|
35
36
|
export interface IntegrationListProps {
|
|
36
37
|
/** Where "Connect" sends the person. Default: graph8 Settings > Integrations. */
|
|
37
38
|
connectUrl?: string;
|
|
38
39
|
/** Called when the person opens a CRM's sync panel (render SyncStatus for it). */
|
|
39
40
|
onOpenSync?: (provider: CrmProvider) => void;
|
|
41
|
+
/** Called when a person opens any CRM, connected or not (show a CrmDetail). */
|
|
42
|
+
onSelectCrm?: (provider: CrmProvider) => void;
|
|
40
43
|
className?: string;
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -78,7 +81,9 @@ function ChatTools() {
|
|
|
78
81
|
);
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
export function IntegrationList({ connectUrl
|
|
84
|
+
export function IntegrationList({ connectUrl: connectUrlProp, onOpenSync, onSelectCrm, className }: IntegrationListProps) {
|
|
85
|
+
const brand = useBrand();
|
|
86
|
+
const connectUrl = connectUrlProp ?? brandUrl(brand, GRAPH8_APP_INTEGRATIONS_PATH);
|
|
82
87
|
const [disconnecting, setDisconnecting] = useState<{ provider: CrmProvider; connection: ConnectionRow } | null>(null);
|
|
83
88
|
const syncs = useG8Query((signal) => g8Get("/crm-syncs", undefined, { signal }), []);
|
|
84
89
|
const connections = useG8Query((signal) => g8Get("/integrations/connections", undefined, { signal }), []);
|
|
@@ -113,7 +118,7 @@ export function IntegrationList({ connectUrl = GRAPH8_APP_INTEGRATIONS_URL, onOp
|
|
|
113
118
|
return (
|
|
114
119
|
<li key={p} className="flex flex-wrap items-center justify-between gap-2 px-3 py-3" data-provider={p}>
|
|
115
120
|
<div>
|
|
116
|
-
<div className="font-medium">{PROVIDER_LABELS[p]}</div>
|
|
121
|
+
{onSelectCrm ? <button type="button" className="font-medium hover:underline" onClick={() => onSelectCrm(p)}>{PROVIDER_LABELS[p]}</button> : <div className="font-medium">{PROVIDER_LABELS[p]}</div>}
|
|
117
122
|
<div className="text-xs text-gray-500">
|
|
118
123
|
{connected ? `Last sync: ${formatWhen(row?.last_sync_at)}` : "Connect to sync contacts and companies."}
|
|
119
124
|
</div>
|
|
@@ -137,7 +142,7 @@ export function IntegrationList({ connectUrl = GRAPH8_APP_INTEGRATIONS_URL, onOp
|
|
|
137
142
|
) : (
|
|
138
143
|
<Button size="sm" asChild>
|
|
139
144
|
<a href={connectUrl} target="_blank" rel="noreferrer">
|
|
140
|
-
Connect in
|
|
145
|
+
Connect in {brand.linkLabel}
|
|
141
146
|
</a>
|
|
142
147
|
</Button>
|
|
143
148
|
)}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IntegrationsScreen: the finished integrations settings page. Two tabs:
|
|
5
|
+
* Connections the five CRMs and the workspace tools with their status (IntegrationList). Every CRM, connected or not, opens its
|
|
6
|
+
* complete detail screen (CrmDetail: connection, push/pull switches, job progress, manual push, disconnect; `?id=hubspot`).
|
|
7
|
+
* "Connect in graph8" links out: the OAuth consent screen only runs in the graph8 app.
|
|
8
|
+
* Webhooks create webhooks (WebhookManager); each opens its complete detail screen (WebhookDetail: every field, edit, deliveries,
|
|
9
|
+
* pause, rotate secret, delete; `?id=`). Every detail also opens as a full page with `?view=full`.
|
|
10
|
+
*
|
|
11
|
+
* SDK operations: two reads for the stat strip (GET /crm-syncs, GET /webhooks). Composes IntegrationList (read +
|
|
12
|
+
* destructive, confirmed), SyncStatus (external, confirmed) and WebhookManager (external + destructive, confirmed).
|
|
13
|
+
* Prerequisites: a CRM connected in the app (where: "app"); a public https route with G8_WEBHOOK_SECRET for webhooks.
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactNode } from "react";
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { Plug } from "lucide-react";
|
|
18
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
19
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
20
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
21
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
22
|
+
import type { StatItem } from "@/g8/components/screen/stat-strip";
|
|
23
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
24
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
25
|
+
import { arrayOf, CRM_PROVIDERS, GRAPH8_APP_INTEGRATIONS_PATH, PROVIDER_LABELS } from "../lib/integrations-utils";
|
|
26
|
+
import type { CrmProvider, CrmSyncRow } from "../lib/integrations-utils";
|
|
27
|
+
import { IntegrationList } from "./integration-list";
|
|
28
|
+
import { CrmDetail } from "./crm-detail";
|
|
29
|
+
import { WebhookDetail } from "./webhook-detail";
|
|
30
|
+
import { WebhookManager } from "./webhook-manager";
|
|
31
|
+
import { brandUrl, useBrand } from "@/g8/hooks/use-brand";
|
|
32
|
+
|
|
33
|
+
export interface IntegrationsScreenProps {
|
|
34
|
+
/** Where "Connect" sends people. Default: graph8 Settings > Integrations. */
|
|
35
|
+
connectUrl?: string;
|
|
36
|
+
/** Pre-fill the webhook endpoint URL (your /api/webhooks/graph8 route). */
|
|
37
|
+
defaultWebhookUrl?: string;
|
|
38
|
+
/** Contact records (in the CRM's field names) offered for a manual push in the sync panel. */
|
|
39
|
+
pushRecords?: ReadonlyArray<Record<string, unknown>>;
|
|
40
|
+
toolbarActions?: ReactNode;
|
|
41
|
+
/** Buttons added to the open record's header: a CRM connection or a webhook. */
|
|
42
|
+
renderDetailActions?: (record: IntegrationRecord) => ReactNode;
|
|
43
|
+
/** Tabs added after the built-in ones on the open record. */
|
|
44
|
+
extraDetailTabs?: { id: string; label: string; render: (record: IntegrationRecord) => ReactNode }[];
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The record open in the detail panel, passed to the slot props. */
|
|
49
|
+
export type IntegrationRecord = { kind: "crm"; provider: CrmProvider } | { kind: "webhook"; id: string };
|
|
50
|
+
|
|
51
|
+
const CONNECTED = ["connected", "active", "ok", "healthy", "syncing", "synced", "enabled", "linked"];
|
|
52
|
+
|
|
53
|
+
function useStats(): StatItem[] | undefined {
|
|
54
|
+
const syncs = useG8Query((signal) => g8Get("/crm-syncs", undefined, { signal }), []);
|
|
55
|
+
const hooks = useG8Query((signal) => g8Get("/webhooks", undefined, { signal }), []);
|
|
56
|
+
if (syncs.error) return undefined;
|
|
57
|
+
const rows = arrayOf<CrmSyncRow>(syncs.data);
|
|
58
|
+
const connected = rows.filter((r) => CONNECTED.includes(r.status?.toLowerCase())).length;
|
|
59
|
+
const items: StatItem[] = [
|
|
60
|
+
{ label: "CRMs connected", value: `${connected} of ${CRM_PROVIDERS.length}`, tone: connected ? "positive" : "neutral" },
|
|
61
|
+
];
|
|
62
|
+
if (!hooks.error && hooks.data) items.push({ label: "Webhooks", value: arrayOf<unknown>(hooks.data).length });
|
|
63
|
+
return syncs.data ? items : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function IntegrationsScreen(props: IntegrationsScreenProps) {
|
|
67
|
+
const brand = useBrand();
|
|
68
|
+
const connectUrl = props.connectUrl ?? brandUrl(brand, GRAPH8_APP_INTEGRATIONS_PATH);
|
|
69
|
+
const [tabValue, setTab] = useUrlState("tab", "connections");
|
|
70
|
+
const tab = tabValue === "webhooks" ? "webhooks" : "connections";
|
|
71
|
+
const [id, setId] = useUrlState("id", "");
|
|
72
|
+
const [view, setView] = useUrlState("view", "");
|
|
73
|
+
const expanded = view === "full" && Boolean(id);
|
|
74
|
+
const [tick, setTick] = useState(0);
|
|
75
|
+
const stats = useStats();
|
|
76
|
+
const close = () => {
|
|
77
|
+
setId("");
|
|
78
|
+
setView("");
|
|
79
|
+
};
|
|
80
|
+
const panel = {
|
|
81
|
+
onClose: close,
|
|
82
|
+
onExpand: expanded ? undefined : () => setView("full"),
|
|
83
|
+
onCollapse: expanded ? () => setView("") : undefined,
|
|
84
|
+
onChanged: () => setTick((t) => t + 1),
|
|
85
|
+
};
|
|
86
|
+
const provider = (CRM_PROVIDERS as readonly string[]).includes(id) ? (id as CrmProvider) : null;
|
|
87
|
+
const slots = (record: IntegrationRecord) => ({
|
|
88
|
+
extraActions: props.renderDetailActions?.(record),
|
|
89
|
+
extraTabs: (props.extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(record) })),
|
|
90
|
+
});
|
|
91
|
+
const split = (list: ReactNode, detail: ReactNode, open: boolean) => (
|
|
92
|
+
<SplitView detailOpen={open} onCloseDetail={close} expanded={expanded} onExpandedChange={(next) => setView(next ? "full" : "")} detailWidth="lg" list={list} detail={open ? detail : null} />
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<ScreenLayout
|
|
97
|
+
title="Integrations"
|
|
98
|
+
description={`Connect your CRM, keep it in sync, and send ${brand.linkLabel} events to your own systems.`}
|
|
99
|
+
icon={<Plug className="size-5" />}
|
|
100
|
+
actions={props.toolbarActions}
|
|
101
|
+
tabs={[
|
|
102
|
+
{ id: "connections", label: "Connections" },
|
|
103
|
+
{ id: "webhooks", label: "Webhooks" },
|
|
104
|
+
]}
|
|
105
|
+
activeTab={tab}
|
|
106
|
+
onTabChange={(next) => {
|
|
107
|
+
setTab(next);
|
|
108
|
+
close();
|
|
109
|
+
}}
|
|
110
|
+
stats={stats}
|
|
111
|
+
className={props.className}
|
|
112
|
+
>
|
|
113
|
+
{tab === "connections" ? (
|
|
114
|
+
<div className="space-y-4">
|
|
115
|
+
<SectionCard title="Connecting an account" description={`Sign-in with your CRM happens in ${brand.linkLabel}. Connect there, then manage the sync here.`}>
|
|
116
|
+
<a className="text-sm underline" href={connectUrl} target="_blank" rel="noreferrer">
|
|
117
|
+
Connect in {brand.linkLabel}
|
|
118
|
+
</a>
|
|
119
|
+
</SectionCard>
|
|
120
|
+
{split(
|
|
121
|
+
<IntegrationList key={tick} connectUrl={connectUrl} onSelectCrm={(p) => setId(p)} onOpenSync={(p) => setId(p)} />,
|
|
122
|
+
provider ? <CrmDetail key={provider} provider={provider} connectUrl={connectUrl} records={props.pushRecords} {...panel} {...slots({ kind: "crm", provider })} /> : null,
|
|
123
|
+
provider != null,
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
) : (
|
|
127
|
+
split(
|
|
128
|
+
<WebhookManager key={tick} defaultUrl={props.defaultWebhookUrl} onSelectWebhook={(h) => setId(h.id)} />,
|
|
129
|
+
<WebhookDetail key={id} webhookId={id} {...panel} {...slots({ kind: "webhook", id })} />,
|
|
130
|
+
Boolean(id),
|
|
131
|
+
)
|
|
132
|
+
)}
|
|
133
|
+
</ScreenLayout>
|
|
134
|
+
);
|
|
135
|
+
}
|