@graph8/sdk 0.361.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 +172 -1
- package/blocks/_foundation/block.json +234 -13
- package/blocks/_foundation/components/api-error.tsx +71 -25
- 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/empty-state.tsx +39 -7
- package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
- package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
- package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
- package/blocks/_foundation/components/screen/index.ts +22 -0
- package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
- package/blocks/_foundation/components/screen/section-card.tsx +47 -0
- package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
- package/blocks/_foundation/components/screen/split-view.tsx +125 -0
- package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
- package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +32 -0
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/components/screen/toolbar.tsx +144 -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/hooks/use-url-state.ts +74 -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/README.md +21 -1
- package/blocks/appointments/block.json +43 -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 +77 -0
- package/blocks/appointments/components/meeting-list.tsx +14 -6
- package/blocks/appointments/components/meetings-screen.tsx +157 -0
- package/blocks/appointments/components/scheduling-screen.tsx +122 -0
- package/blocks/appointments/hooks/use-event-types.ts +17 -0
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +26 -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/README.md +34 -3
- package/blocks/deals/block.json +45 -4
- package/blocks/deals/components/deal-detail.tsx +27 -13
- package/blocks/deals/components/deal-related.tsx +87 -0
- package/blocks/deals/components/deals-screen.tsx +298 -0
- package/blocks/deals/components/pipeline-board.tsx +4 -2
- package/blocks/deals/components/stage-editor.tsx +4 -4
- package/blocks/deals/hooks/use-default-owner.ts +60 -0
- package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/deals/lib/deals-types.ts +14 -0
- 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/README.md +12 -0
- package/blocks/forms/block.json +39 -17
- 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 +353 -0
- package/blocks/forms/components/submissions-table.tsx +16 -6
- 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/README.md +16 -0
- package/blocks/lifecycle/block.json +34 -7
- package/blocks/lifecycle/components/account-related.tsx +66 -0
- package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +149 -0
- 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/README.md +13 -0
- package/blocks/newsletter/block.json +47 -23
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
- package/blocks/newsletter/components/newsletter-screen.tsx +404 -0
- package/blocks/newsletter/components/subscriber-table.tsx +12 -6
- package/blocks/nurture/README.md +12 -0
- package/blocks/nurture/block.json +35 -13
- 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 +283 -0
- 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/README.md +12 -0
- package/blocks/pages/block.json +35 -22
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +46 -11
- package/blocks/pages/components/page-list.tsx +6 -3
- package/blocks/pages/components/pages-screen.tsx +154 -0
- package/blocks/quotes/README.md +17 -0
- package/blocks/quotes/block.json +38 -9
- package/blocks/quotes/components/product-picker.tsx +3 -1
- package/blocks/quotes/components/quote-list.tsx +7 -3
- package/blocks/quotes/components/quote-related.tsx +123 -0
- package/blocks/quotes/components/quotes-screen.tsx +169 -0
- 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 +2785 -1637
- 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 +25 -8
- package/dist/types/blocks/doctor.d.ts +6 -1
- package/dist/types/blocks/flows.d.ts +20 -0
- 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 +32 -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/stack.schema.json +6 -1
- 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 +55 -8
- package/templates/next-app/app/globals.css.tmpl +105 -0
- package/templates/next-app/app/layout.tsx.tmpl +18 -4
- package/templates/next-app/components/app-shell.tsx.tmpl +248 -23
- package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
- package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
- package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/lib/nav.ts.tmpl +1 -1
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -1,49 +1,316 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* InboxScreen: the finished
|
|
5
|
-
* reply composer and tag/assign panel.
|
|
4
|
+
* InboxScreen: the finished inbox. A stat strip, a searchable and filterable conversation list on the left, and on the
|
|
5
|
+
* right the open conversation with its reply composer and tag/assign panel. Deep link: `?id=<channel>:<thread id>`.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* (
|
|
7
|
+
* Flows: browse and page the queue; filter by channel, status and tag; search the loaded page; open a conversation;
|
|
8
|
+
* read it (unsent drafts flagged); draft with AI (billable, confirmed) and send (external, confirmed); tag and assign
|
|
9
|
+
* (external, confirmed); clear filters; connect a mailbox (link out to graph8, OAuth only exists there).
|
|
10
|
+
*
|
|
11
|
+
* SDK operations: none of its own. It composes useInboxThreads (GET /inbox, read), ThreadView (read), ReplyComposer
|
|
12
|
+
* (billable draft, external send) and TagAndAssign (external). The confirm dialogs live in those parts.
|
|
13
|
+
*
|
|
14
|
+
* BEHAVIOUR CHANGE (versus the original two-pane InboxScreen): it now writes `?id=` and `?view=` to the host page URL and
|
|
15
|
+
* renders its own ScreenLayout header ("Inbox" h1, stats). Opt out with `urlState={false}` (selection kept in component
|
|
16
|
+
* state, the URL untouched) and/or `hideHeader` (no title or stats, just the toolbar and the two panes). Defaults keep the new look.
|
|
17
|
+
*
|
|
18
|
+
* Props are backwards compatible: `initialFilters`, `availableTags`, `className` as before; everything else is optional glue.
|
|
9
19
|
*/
|
|
10
|
-
import { useMemo, useState } from "react";
|
|
20
|
+
import { useMemo, useState, type ReactNode } from "react";
|
|
21
|
+
import { Inbox as InboxIcon, Mail, RefreshCw } from "lucide-react";
|
|
22
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
23
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
11
24
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
25
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
26
|
+
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
27
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
28
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
29
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
30
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
31
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
32
|
+
import { useInboxThread } from "../hooks/use-inbox-thread";
|
|
33
|
+
import { useInboxThreads, type InboxFilters } from "../hooks/use-inbox-threads";
|
|
34
|
+
import {
|
|
35
|
+
INBOX_CHANNELS,
|
|
36
|
+
INBOX_STATUSES,
|
|
37
|
+
contactLabel,
|
|
38
|
+
formatWhen,
|
|
39
|
+
latestMessage,
|
|
40
|
+
previewText,
|
|
41
|
+
type InboxChannel,
|
|
42
|
+
type InboxTag,
|
|
43
|
+
type InboxThread,
|
|
44
|
+
} from "../lib/inbox-types";
|
|
15
45
|
import { ReplyComposer } from "./reply-composer";
|
|
16
46
|
import { TagAndAssign } from "./tag-and-assign";
|
|
17
47
|
import { ThreadView } from "./thread-view";
|
|
48
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
18
49
|
|
|
19
50
|
export interface InboxScreenProps {
|
|
20
51
|
initialFilters?: InboxFilters;
|
|
21
52
|
/** Tags the person can apply. There is no list-tags operation; keep this list in your app. */
|
|
22
53
|
availableTags?: InboxTag[];
|
|
23
54
|
className?: string;
|
|
55
|
+
/** Extra buttons next to Refresh. */
|
|
56
|
+
toolbarActions?: ReactNode;
|
|
57
|
+
/** Rendered at the end of each conversation row. */
|
|
58
|
+
renderRowActions?: (thread: InboxThread) => ReactNode;
|
|
59
|
+
/** Rendered in the header of the open conversation. */
|
|
60
|
+
renderDetailActions?: (thread: InboxThread) => ReactNode;
|
|
61
|
+
/** Extra tabs next to "Conversation" (for example the contact's CRM record). */
|
|
62
|
+
extraDetailTabs?: Array<{ id: string; label: string; render: (thread: InboxThread) => ReactNode }>;
|
|
63
|
+
/** Page size of the list. Default 25. */
|
|
64
|
+
pageSize?: number;
|
|
65
|
+
/** Keep the open conversation and full-page view in the page URL (`?id=`, `?view=`). Default true; false keeps them in component state and leaves the URL alone. */
|
|
66
|
+
urlState?: boolean;
|
|
67
|
+
/** Skip the screen header (title, description, stats) when the host page already has one. Default false. */
|
|
68
|
+
hideHeader?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `useUrlState` or plain state, chosen once per mount (the flag must not change while mounted). */
|
|
72
|
+
function useSelectionState(key: string, urlState: boolean): [string | undefined, (v: string | null) => void] {
|
|
73
|
+
const [fromUrl, setFromUrl] = useUrlState(key);
|
|
74
|
+
const [local, setLocal] = useState<string | null>(null);
|
|
75
|
+
return urlState ? [fromUrl, setFromUrl] : [local ?? undefined, setLocal];
|
|
24
76
|
}
|
|
25
77
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
78
|
+
type Selected = { channel: string; id: string };
|
|
79
|
+
const keyOf = (t: Selected) => `${t.channel}:${t.id}`;
|
|
80
|
+
function parseKey(value: string): Selected | null {
|
|
81
|
+
const at = value.indexOf(":");
|
|
82
|
+
return at > 0 && at < value.length - 1 ? { channel: value.slice(0, at), id: value.slice(at + 1) } : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function statusTone(status?: string | null) {
|
|
86
|
+
const s = (status ?? "").toUpperCase();
|
|
87
|
+
return s === "DRAFT" ? "warning" : s === "SENT" ? "positive" : s === "AI_RESPONDED" ? "info" : "neutral";
|
|
88
|
+
}
|
|
29
89
|
|
|
90
|
+
function ThreadRow({ thread, selected, onOpen, actions }: { thread: InboxThread; selected: boolean; onOpen: () => void; actions?: ReactNode }) {
|
|
91
|
+
const hasDraft = thread.messages.some((m) => m.is_draft);
|
|
30
92
|
return (
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
{(thread
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
93
|
+
<li className={`flex items-start gap-1 border-b border-border ${selected ? "bg-muted" : "hover:bg-muted/50"}`}>
|
|
94
|
+
<button type="button" aria-current={selected ? "true" : undefined} onClick={onOpen} className="flex min-w-0 flex-1 items-start gap-3 p-3 text-left">
|
|
95
|
+
<EntityAvatar name={contactLabel(thread)} kind="person" size="sm" />
|
|
96
|
+
<span className="min-w-0 flex-1">
|
|
97
|
+
<span className="flex items-center justify-between gap-2">
|
|
98
|
+
<span className="truncate text-sm font-medium">{contactLabel(thread)}</span>
|
|
99
|
+
<span className="shrink-0 text-xs text-muted-foreground">{formatWhen(thread.updated_at ?? latestMessage(thread)?.date)}</span>
|
|
100
|
+
</span>
|
|
101
|
+
<span className="block truncate text-sm">{thread.subject ?? ""}</span>
|
|
102
|
+
<span className="block truncate text-xs text-muted-foreground">{previewText(thread)}</span>
|
|
103
|
+
<span className="mt-1 flex flex-wrap items-center gap-1">
|
|
104
|
+
<StatusPill tone="neutral">{String(thread.channel)}</StatusPill>
|
|
105
|
+
{thread.status ? <StatusPill tone={statusTone(thread.status)}>{thread.status.toLowerCase().replace("_", " ")}</StatusPill> : null}
|
|
106
|
+
{hasDraft ? <StatusPill tone="warning" dot>Draft waiting</StatusPill> : null}
|
|
107
|
+
{thread.tags.map((t) => <StatusPill key={t.id ?? t.name} tone="info">{t.name ?? t.id}</StatusPill>)}
|
|
108
|
+
</span>
|
|
109
|
+
</span>
|
|
110
|
+
</button>
|
|
111
|
+
{actions ? <div className="shrink-0 p-2">{actions}</div> : null}
|
|
112
|
+
</li>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ChannelFilter({ value, onChange }: { value?: InboxChannel; onChange: (next?: InboxChannel) => void }) {
|
|
117
|
+
return (
|
|
118
|
+
<div role="group" aria-label="Channel" className="flex gap-1">
|
|
119
|
+
<Button size="sm" variant={value ? "outline" : "default"} onClick={() => onChange(undefined)}>All</Button>
|
|
120
|
+
{INBOX_CHANNELS.map((c) => (
|
|
121
|
+
<Button key={c.value} size="sm" variant={value === c.value ? "default" : "outline"} onClick={() => onChange(c.value)}>{c.label}</Button>
|
|
122
|
+
))}
|
|
47
123
|
</div>
|
|
48
124
|
);
|
|
49
125
|
}
|
|
126
|
+
|
|
127
|
+
function Filters({ filters, tags, update }: { filters: InboxFilters; tags: InboxTag[]; update: (patch: Partial<InboxFilters>) => void }) {
|
|
128
|
+
const selectClass = "h-8 rounded-md border border-border bg-background px-2 text-sm";
|
|
129
|
+
return (
|
|
130
|
+
<>
|
|
131
|
+
<ChannelFilter value={filters.channel} onChange={(channel) => update({ channel })} />
|
|
132
|
+
<select aria-label="Status" className={selectClass} value={filters.status ?? ""} onChange={(e) => update({ status: e.target.value || undefined })}>
|
|
133
|
+
<option value="">Any status</option>
|
|
134
|
+
{INBOX_STATUSES.map((s) => <option key={s} value={s}>{s.replace("_", " ").toLowerCase()}</option>)}
|
|
135
|
+
</select>
|
|
136
|
+
{tags.length > 0 ? (
|
|
137
|
+
<select aria-label="Tag" className={selectClass} value={filters.tag ?? ""} onChange={(e) => update({ tag: e.target.value || undefined })}>
|
|
138
|
+
<option value="">Any tag</option>
|
|
139
|
+
{tags.map((t) => <option key={t.id} value={t.id ?? ""}>{t.name ?? t.id}</option>)}
|
|
140
|
+
</select>
|
|
141
|
+
) : null}
|
|
142
|
+
</>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ListEmpty({ filtered, onClear }: { filtered: boolean; onClear: () => void }) {
|
|
147
|
+
const brand = useBrand();
|
|
148
|
+
if (filtered) {
|
|
149
|
+
return <EmptyState title="No conversations match" description="Nothing fits these filters or this search." action={<Button size="sm" variant="outline" onClick={onClear}>Clear filters</Button>} />;
|
|
150
|
+
}
|
|
151
|
+
return (
|
|
152
|
+
<EmptyState
|
|
153
|
+
icon={<Mail className="size-6" />}
|
|
154
|
+
title="No conversations yet"
|
|
155
|
+
description={`Replies from sequences, SMS and LinkedIn land here. Connect a mailbox in ${brand.linkLabel} (Google and Microsoft sign-in only exist there), then enroll contacts in a sequence.`}
|
|
156
|
+
action={<a className="text-sm underline" href={brand.appUrl} target="_blank" rel="noreferrer">Connect in {brand.linkLabel}</a>}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function ThreadDetails({ thread }: { thread: Selected }) {
|
|
162
|
+
const { data: t, loading, error, refetch } = useInboxThread(thread.id, thread.channel);
|
|
163
|
+
if (error) return <ApiError error={error} title="Could not load this conversation" onRetry={() => void refetch()} />;
|
|
164
|
+
if (!t) return loading ? <Skeleton className="h-24 w-full" /> : null;
|
|
165
|
+
const dash = "";
|
|
166
|
+
return (
|
|
167
|
+
<KeyValueList
|
|
168
|
+
columns={1}
|
|
169
|
+
items={[
|
|
170
|
+
{ label: "Contact", value: t.contact?.name || dash },
|
|
171
|
+
{ label: "Email", value: t.contact?.email || dash },
|
|
172
|
+
{ label: "Phone", value: t.contact?.phone || dash },
|
|
173
|
+
{ label: "Company", value: t.contact?.company || dash },
|
|
174
|
+
{ label: "Channel", value: String(t.channel) },
|
|
175
|
+
{ label: "Status", value: t.status ? t.status.toLowerCase().replace("_", " ") : dash },
|
|
176
|
+
{ label: "Tags", value: t.tags.length ? t.tags.map((x) => x.name ?? x.id).join(", ") : dash },
|
|
177
|
+
{ label: "Assigned to", value: t.assignees.length ? t.assignees.map((a) => a.name || a.email).join(", ") : dash },
|
|
178
|
+
{ label: "Messages", value: `${t.messages.length}${t.messages.some((m) => m.is_draft) ? " (includes an unsent draft)" : ""}` },
|
|
179
|
+
{ label: "Started", value: formatWhen(t.created_at) || dash },
|
|
180
|
+
{ label: "Last activity", value: formatWhen(t.updated_at) || dash },
|
|
181
|
+
]}
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function ExtraTab({ thread, render }: { thread: Selected; render: (thread: InboxThread) => ReactNode }) {
|
|
187
|
+
const { data, loading, error, refetch } = useInboxThread(thread.id, thread.channel);
|
|
188
|
+
if (error) return <ApiError error={error} title="Could not load this conversation" onRetry={() => void refetch()} />;
|
|
189
|
+
if (!data) return loading ? <Skeleton className="h-24 w-full" /> : null;
|
|
190
|
+
return <>{render(data)}</>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function InboxScreen({ initialFilters, availableTags, className, toolbarActions, renderRowActions, renderDetailActions, extraDetailTabs, pageSize = 25, urlState = true, hideHeader = false }: InboxScreenProps) {
|
|
194
|
+
const [filters, setFilters] = useState<InboxFilters>(initialFilters ?? {});
|
|
195
|
+
const [page, setPage] = useState(1);
|
|
196
|
+
const [search, setSearch] = useState("");
|
|
197
|
+
const [openKey, setOpenKey] = useSelectionState("id", urlState);
|
|
198
|
+
const [view, setView] = useSelectionState("view", urlState);
|
|
199
|
+
const { threads, loading, error, hasNext, total, refetch } = useInboxThreads(filters, page, pageSize);
|
|
200
|
+
const open = openKey ? parseKey(openKey) : null;
|
|
201
|
+
const expanded = view === "full" && open != null;
|
|
202
|
+
|
|
203
|
+
const tags = useMemo(() => {
|
|
204
|
+
const seen = new Map<string, InboxTag>();
|
|
205
|
+
for (const t of [...(availableTags ?? []), ...threads.flatMap((th) => th.tags)]) if (t.id) seen.set(t.id, t);
|
|
206
|
+
return [...seen.values()];
|
|
207
|
+
}, [availableTags, threads]);
|
|
208
|
+
const needle = search.trim().toLowerCase();
|
|
209
|
+
const rows = useMemo(
|
|
210
|
+
() => (needle ? threads.filter((t) => `${contactLabel(t)} ${t.contact?.email ?? ""} ${t.contact?.company ?? ""} ${t.subject ?? ""} ${previewText(t, 300)}`.toLowerCase().includes(needle)) : threads),
|
|
211
|
+
[threads, needle],
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const update = (patch: Partial<InboxFilters>) => {
|
|
215
|
+
setFilters((f) => ({ ...f, ...patch }));
|
|
216
|
+
setPage(1);
|
|
217
|
+
setOpenKey(null);
|
|
218
|
+
};
|
|
219
|
+
const clear = () => { setSearch(""); setFilters({}); setPage(1); setOpenKey(null); };
|
|
220
|
+
const filtered = Boolean(needle || filters.channel || filters.status || filters.tag || filters.sequenceId);
|
|
221
|
+
const drafts = threads.filter((t) => t.messages.some((m) => m.is_draft)).length;
|
|
222
|
+
const count = (c: string) => threads.filter((t) => t.channel === c).length;
|
|
223
|
+
const first = open ? threads.find((t) => t.id === open.id && t.channel === open.channel) : undefined;
|
|
224
|
+
|
|
225
|
+
const panes = (
|
|
226
|
+
<>
|
|
227
|
+
<div className={expanded ? "grid gap-4" : "grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"}>
|
|
228
|
+
<div className={expanded ? "hidden" : "min-w-0 space-y-2"}>
|
|
229
|
+
{error ? (
|
|
230
|
+
<ApiError error={error} title="Could not load the inbox" onRetry={() => void refetch()} />
|
|
231
|
+
) : loading && threads.length === 0 ? (
|
|
232
|
+
<div aria-busy="true" className="space-y-2">{Array.from({ length: 5 }, (_, i) => <Skeleton key={i} className="h-16 w-full" />)}</div>
|
|
233
|
+
) : rows.length === 0 ? (
|
|
234
|
+
<ListEmpty filtered={filtered} onClear={clear} />
|
|
235
|
+
) : (
|
|
236
|
+
<ul aria-label="Conversations" className="rounded-md border border-border">
|
|
237
|
+
{rows.map((t) => (
|
|
238
|
+
<ThreadRow key={keyOf(t)} thread={t} selected={open != null && keyOf(open) === keyOf(t)} onOpen={() => setOpenKey(keyOf(t))} actions={renderRowActions?.(t)} />
|
|
239
|
+
))}
|
|
240
|
+
</ul>
|
|
241
|
+
)}
|
|
242
|
+
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
243
|
+
<span>{total != null ? `${total.toLocaleString()} conversations` : `Page ${page}`}</span>
|
|
244
|
+
<div className="flex gap-2">
|
|
245
|
+
<Button size="sm" variant="outline" disabled={page <= 1 || loading} onClick={() => { setPage((p) => p - 1); setOpenKey(null); }}>Previous</Button>
|
|
246
|
+
<Button size="sm" variant="outline" disabled={!hasNext || loading} onClick={() => { setPage((p) => p + 1); setOpenKey(null); }}>Next</Button>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
<div className="min-w-0">
|
|
251
|
+
{open ? (
|
|
252
|
+
<DetailPanel
|
|
253
|
+
key={keyOf(open)}
|
|
254
|
+
title={first ? first.subject || contactLabel(first) : "Conversation"}
|
|
255
|
+
subtitle={first ? `${contactLabel(first)}${first.contact?.company ? ` at ${first.contact.company}` : ""}` : undefined}
|
|
256
|
+
avatar={first ? <EntityAvatar name={contactLabel(first)} kind="person" /> : undefined}
|
|
257
|
+
onClose={() => { setOpenKey(null); setView(null); }}
|
|
258
|
+
onExpand={expanded ? undefined : () => setView("full")}
|
|
259
|
+
onCollapse={expanded ? () => setView(null) : undefined}
|
|
260
|
+
tabs={[
|
|
261
|
+
{
|
|
262
|
+
id: "conversation",
|
|
263
|
+
label: "Conversation",
|
|
264
|
+
content: (
|
|
265
|
+
<ThreadView thread={first ?? open}>
|
|
266
|
+
{(thread, reload) => (
|
|
267
|
+
<div className="space-y-3">
|
|
268
|
+
{renderDetailActions ? <div className="flex flex-wrap gap-2">{renderDetailActions(thread)}</div> : null}
|
|
269
|
+
<ReplyComposer thread={thread} onSent={reload} />
|
|
270
|
+
<TagAndAssign thread={thread} availableTags={tags} onChanged={reload} />
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</ThreadView>
|
|
274
|
+
),
|
|
275
|
+
},
|
|
276
|
+
{ id: "details", label: "Details", content: <ThreadDetails thread={open} /> },
|
|
277
|
+
...(extraDetailTabs ?? []).map((tab) => ({ id: tab.id, label: tab.label, content: <ExtraTab thread={open} render={tab.render} /> })),
|
|
278
|
+
]}
|
|
279
|
+
/>
|
|
280
|
+
) : (
|
|
281
|
+
<EmptyState title="Select a conversation" description="Pick a reply on the left to read it and respond." />
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
</>
|
|
286
|
+
);
|
|
287
|
+
if (hideHeader) {
|
|
288
|
+
return (
|
|
289
|
+
<div className={className ? `space-y-4 ${className}` : "space-y-4"}>
|
|
290
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
291
|
+
<Toolbar search={{ value: search, onChange: setSearch, placeholder: "Search this page" }} filters={<Filters filters={filters} tags={tags} update={update} />} />
|
|
292
|
+
<div className="flex gap-2"><Button size="sm" variant="outline" onClick={() => void refetch()}><RefreshCw className="mr-1 size-4" />Refresh</Button>{toolbarActions}</div>
|
|
293
|
+
</div>
|
|
294
|
+
{panes}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return (
|
|
299
|
+
<ScreenLayout
|
|
300
|
+
className={className}
|
|
301
|
+
title="Inbox"
|
|
302
|
+
description="Every reply across email, SMS and LinkedIn in one queue."
|
|
303
|
+
icon={<InboxIcon className="size-5" />}
|
|
304
|
+
actions={<><Button size="sm" variant="outline" onClick={() => void refetch()}><RefreshCw className="mr-1 size-4" />Refresh</Button>{toolbarActions}</>}
|
|
305
|
+
stats={[
|
|
306
|
+
{ label: "Conversations", value: total != null ? total.toLocaleString() : threads.length, hint: total != null ? "in this view" : "on this page" },
|
|
307
|
+
{ label: "Drafts waiting", value: drafts, hint: "on this page", tone: drafts > 0 ? "warning" : "neutral" },
|
|
308
|
+
{ label: "Email threads", value: count("email"), hint: "on this page" },
|
|
309
|
+
{ label: "Text and LinkedIn", value: count("sms") + count("linkedin"), hint: "on this page" },
|
|
310
|
+
]}
|
|
311
|
+
toolbar={<Toolbar search={{ value: search, onChange: setSearch, placeholder: "Search this page" }} filters={<Filters filters={filters} tags={tags} update={update} />} />}
|
|
312
|
+
>
|
|
313
|
+
{panes}
|
|
314
|
+
</ScreenLayout>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
@@ -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 |
|