@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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SuppressionsScreen: the finished do-not-contact page. A filterable ledger with a detail panel for the contact behind
|
|
5
|
+
* a row, a "Suppress contacts" sheet (paste ids or tick people from the CRM) and a confirmed Reinstate.
|
|
6
|
+
*
|
|
7
|
+
* Composes: ScreenLayout, StatStrip, Toolbar, SplitView, DetailPanel, FormSheet (screen kit), ContactTable and
|
|
8
|
+
* ContactDetail (crm), ConfirmSpendDialog.
|
|
9
|
+
*
|
|
10
|
+
* User flows:
|
|
11
|
+
* - filter the ledger by channel and reason, page through it, see how many are suppressed on each channel
|
|
12
|
+
* - Suppress contacts: paste ids, or pick contacts from the CRM, choose the channel (default all) and a reason
|
|
13
|
+
* - open a ledger row: the contact's record plus its live suppression status (read-only check)
|
|
14
|
+
* - check any contact id without opening it
|
|
15
|
+
* - tick rows and Reinstate (confirmed: it lets sequences, campaigns and newsletters contact them again)
|
|
16
|
+
*
|
|
17
|
+
* SDK operations, in order:
|
|
18
|
+
* 1. listSuppressions GET /contacts/suppressions?channel&category&limit&offset (read)
|
|
19
|
+
* 2. bulkAddSuppressions POST /contacts/suppressions/bulk-add { contact_ids, channel, reason } (write; only reduces outreach, no confirm)
|
|
20
|
+
* 3. checkContactSuppression GET /contacts/{contact_id}/suppression (read)
|
|
21
|
+
* 4. reinstateSuppressions POST /contacts/suppressions/reinstate { contact_ids, reason } (write, ConfirmSpendDialog "external")
|
|
22
|
+
* 5. listContacts / getContact via ContactTable / ContactDetail (read)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { useEffect, useState } from "react";
|
|
26
|
+
import type { ReactNode } from "react";
|
|
27
|
+
import { BadgeCheck, Ban, Search, ShieldOff, Undo2 } from "lucide-react";
|
|
28
|
+
import { Button, Input, Label, Skeleton, Textarea } from "@graph8/sdk/ui";
|
|
29
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
30
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
31
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
32
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
33
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
34
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
35
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
36
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
37
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
38
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
39
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
40
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
41
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
42
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
43
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
44
|
+
import { ContactDetail } from "@/g8/crm/components/contact-detail";
|
|
45
|
+
import { ContactTable } from "@/g8/crm/components/contact-table";
|
|
46
|
+
import { plural } from "@/g8/lib/plural";
|
|
47
|
+
import { parseIds } from "./suppression-manager";
|
|
48
|
+
import type { SuppressionRecord } from "./suppression-manager";
|
|
49
|
+
|
|
50
|
+
export interface SuppressionsScreenProps {
|
|
51
|
+
pageSize?: number;
|
|
52
|
+
toolbarActions?: ReactNode;
|
|
53
|
+
renderRowActions?: (row: SuppressionRecord) => ReactNode;
|
|
54
|
+
renderDetailActions?: (contactId: number) => ReactNode;
|
|
55
|
+
extraDetailTabs?: { id: string; label: string; render: (contactId: number) => ReactNode }[];
|
|
56
|
+
className?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ContactStatus { contact_id: number; is_suppressed: boolean; active_channels: string[]; suppressions: SuppressionRecord[] }
|
|
60
|
+
interface BulkAdd { suppressed: number; total_requested: number; channel: string; not_found: number[] }
|
|
61
|
+
interface Reinstate { reinstated: number[]; skipped: Array<{ contact_id: number; reason: string }>; not_found: number[] }
|
|
62
|
+
|
|
63
|
+
const CHANNELS = [["all", "All channels"], ["email", "Email"], ["phone", "Phone"], ["linkedin", "LinkedIn"]] as const;
|
|
64
|
+
const SELECT = "h-9 rounded-md border bg-background px-2 text-sm";
|
|
65
|
+
|
|
66
|
+
function ContactStatusPanel({ contactId }: { contactId: number }) {
|
|
67
|
+
const status = useG8Query((signal) => g8Get<ContactStatus>(`/contacts/${contactId}/suppression`, undefined, { signal }), [contactId]);
|
|
68
|
+
if (status.error) return <ApiError error={status.error} title="Could not check that contact" onRetry={() => void status.refetch()} />;
|
|
69
|
+
if (!status.data) return <Skeleton className="h-6 w-48" />;
|
|
70
|
+
const s = status.data.data;
|
|
71
|
+
return (
|
|
72
|
+
<p role="status" className="flex items-center gap-2 text-sm">
|
|
73
|
+
{s.is_suppressed ? <StatusPill tone="negative" dot>Suppressed</StatusPill> : <StatusPill tone="positive" dot>Can be contacted</StatusPill>}
|
|
74
|
+
{s.is_suppressed ? <span className="text-muted-foreground">on {s.active_channels.join(", ")}</span> : null}
|
|
75
|
+
</p>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function SuppressionsScreen({ pageSize = 50, toolbarActions, renderRowActions, renderDetailActions, extraDetailTabs, className }: SuppressionsScreenProps) {
|
|
80
|
+
const [channel, setChannel] = useState("");
|
|
81
|
+
const [category, setCategory] = useState("");
|
|
82
|
+
const [offset, setOffset] = useState(0);
|
|
83
|
+
const [openId, setOpenId] = useUrlState("id");
|
|
84
|
+
const [view, setView] = useUrlState("view");
|
|
85
|
+
const full = view === "full";
|
|
86
|
+
const [adding, setAdding] = useState(false);
|
|
87
|
+
const [idsText, setIdsText] = useState("");
|
|
88
|
+
const [addChannel, setAddChannel] = useState("all");
|
|
89
|
+
const [reason, setReason] = useState("");
|
|
90
|
+
const [picked, setPicked] = useState<Set<number>>(new Set());
|
|
91
|
+
const [confirmReinstate, setConfirmReinstate] = useState(false);
|
|
92
|
+
/** Set when Reinstate is started from a row's panel: the confirm acts on that contact only and leaves the ticked rows alone. */
|
|
93
|
+
const [panelTarget, setPanelTarget] = useState<number | null>(null);
|
|
94
|
+
const [reinstateReason, setReinstateReason] = useState("");
|
|
95
|
+
const [checkId, setCheckId] = useState("");
|
|
96
|
+
const [checked, setChecked] = useState<number | null>(null);
|
|
97
|
+
|
|
98
|
+
const ledger = useG8Query((signal) => g8Get<SuppressionRecord[]>("/contacts/suppressions", { channel: channel || undefined, category: category || undefined, limit: pageSize, offset }, { signal }), [channel, category, offset, pageSize]);
|
|
99
|
+
const rows = (ledger.data?.data ?? []) as SuppressionRecord[];
|
|
100
|
+
// Selection belongs to the rows on screen: prune it when the page or a filter changes.
|
|
101
|
+
useEffect(() => { setPicked((p) => new Set([...p].filter((id) => rows.some((r) => r.contact_id === id)))); }, [ledger.data]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
102
|
+
|
|
103
|
+
const add = useG8Mutation((v: { ids: number[] }) => g8Post<BulkAdd>("/contacts/suppressions/bulk-add", { contact_ids: v.ids, channel: addChannel, reason: reason.trim() || undefined }), {
|
|
104
|
+
onSuccess: () => { setIdsText(""); setReason(""); void ledger.refetch(); },
|
|
105
|
+
});
|
|
106
|
+
const reinstate = useG8Mutation((ids: number[]) => g8Post<Reinstate>("/contacts/suppressions/reinstate", { contact_ids: ids, reason: reinstateReason.trim() || undefined }), {
|
|
107
|
+
onSuccess: () => { setConfirmReinstate(false); setReinstateReason(""); if (panelTarget != null) { const done = panelTarget; setPicked((p) => { const n = new Set(p); n.delete(done); return n; }); } else setPicked(new Set()); setPanelTarget(null); void ledger.refetch(); },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const ids = parseIds(idsText);
|
|
111
|
+
const chosen = panelTarget != null ? [panelTarget] : [...picked];
|
|
112
|
+
const togglePick = (id: number) => setPicked((p) => { const n = new Set(p); if (!n.delete(id)) n.add(id); return n; });
|
|
113
|
+
const byChannel = (c: string) => rows.filter((r) => r.channel === c).length;
|
|
114
|
+
const resetPage = () => { setOffset(0); setPicked(new Set()); };
|
|
115
|
+
|
|
116
|
+
const list = (
|
|
117
|
+
<div className="flex flex-col gap-3">
|
|
118
|
+
{ledger.error ? <ApiError error={ledger.error} title="Could not load the ledger" onRetry={() => void ledger.refetch()} /> : null}
|
|
119
|
+
{reinstate.error ? <ApiError error={reinstate.error} title="Could not reinstate" /> : null}
|
|
120
|
+
{reinstate.data ? <p role="status" className="text-sm">Reinstated {reinstate.data.data.reinstated.length}.{reinstate.data.data.skipped.length ? ` Skipped ${reinstate.data.data.skipped.length}: ${reinstate.data.data.skipped.map((s) => `${s.contact_id} (${s.reason})`).join(", ")}.` : ""}</p> : null}
|
|
121
|
+
<div className="divide-y rounded-md border">
|
|
122
|
+
{ledger.loading && rows.length === 0
|
|
123
|
+
? Array.from({ length: 5 }, (_, i) => <div key={`sk${i}`} className="p-3"><Skeleton className="h-6 w-full" /></div>)
|
|
124
|
+
: rows.map((r) => (
|
|
125
|
+
<div key={r.id} data-testid="suppression-row" className={`flex items-center gap-3 p-3 ${String(r.contact_id) === openId ? "bg-muted/50" : ""}`}>
|
|
126
|
+
<input type="checkbox" aria-label={`Select contact ${r.contact_id}`} checked={picked.has(r.contact_id)} onChange={() => togglePick(r.contact_id)} />
|
|
127
|
+
<button type="button" className="flex min-w-0 flex-1 items-center gap-3 text-left" onClick={() => setOpenId(String(r.contact_id))}>
|
|
128
|
+
<span className="text-sm font-medium tabular-nums">#{r.contact_id}</span>
|
|
129
|
+
<StatusPill tone="negative">{r.channel === "all" ? "All channels" : r.channel}</StatusPill>
|
|
130
|
+
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{r.category.replace(/_/g, " ")}{r.reason ? `: ${r.reason}` : ""}</span>
|
|
131
|
+
<span className="text-xs text-muted-foreground">{r.created_at ? new Date(r.created_at).toLocaleDateString() : ""}</span>
|
|
132
|
+
</button>
|
|
133
|
+
{renderRowActions?.(r)}
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
{!ledger.loading && !ledger.error && rows.length === 0 ? (
|
|
137
|
+
<EmptyState icon={<ShieldOff className="size-6" aria-hidden />} title="Nobody is suppressed" description="Suppressed contacts are skipped by every sequence, campaign and newsletter."
|
|
138
|
+
action={<Button size="sm" onClick={() => setAdding(true)}>Suppress contacts</Button>} />
|
|
139
|
+
) : null}
|
|
140
|
+
</div>
|
|
141
|
+
<div className="flex items-center justify-end gap-2 text-sm">
|
|
142
|
+
<Button variant="outline" size="sm" disabled={offset === 0 || ledger.loading} onClick={() => { setOffset(Math.max(0, offset - pageSize)); setPicked(new Set()); }}>Previous</Button>
|
|
143
|
+
<Button variant="outline" size="sm" disabled={rows.length < pageSize || ledger.loading} onClick={() => { setOffset(offset + pageSize); setPicked(new Set()); }}>Next</Button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const contactId = openId ? Number(openId) : null;
|
|
149
|
+
const detail = contactId ? (
|
|
150
|
+
<DetailPanel
|
|
151
|
+
key={contactId}
|
|
152
|
+
onExpand={full ? undefined : () => setView("full")}
|
|
153
|
+
onCollapse={full ? () => setView(null) : undefined}
|
|
154
|
+
title={`Contact #${contactId}`}
|
|
155
|
+
subtitle="Suppression status and record"
|
|
156
|
+
onClose={() => setOpenId(null)}
|
|
157
|
+
actions={(<>
|
|
158
|
+
<Button size="sm" variant="outline" onClick={() => { setPanelTarget(contactId); setConfirmReinstate(true); }}><Undo2 className="mr-1 size-4" aria-hidden />Reinstate</Button>
|
|
159
|
+
{renderDetailActions?.(contactId)}
|
|
160
|
+
</>)}
|
|
161
|
+
tabs={[
|
|
162
|
+
{ id: "status", label: "Status", content: (
|
|
163
|
+
<div className="flex flex-col gap-3">
|
|
164
|
+
<ContactStatusPanel contactId={contactId} />
|
|
165
|
+
{rows.filter((r) => r.contact_id === contactId).map((r) => (
|
|
166
|
+
<SectionCard key={r.id} title={r.channel === "all" ? "All channels" : `${r.channel} suppression`} description={r.category.replace(/_/g, " ")}>
|
|
167
|
+
<KeyValueList columns={2} items={[
|
|
168
|
+
{ label: "Reason", value: r.reason ?? "", wide: true },
|
|
169
|
+
{ label: "Source", value: r.source ?? "" },
|
|
170
|
+
{ label: "Source reference", value: r.source_ref ?? "", mono: true },
|
|
171
|
+
{ label: "Added by", value: r.created_by ?? "" },
|
|
172
|
+
{ label: "Since", value: r.created_at ? new Date(r.created_at).toLocaleString() : "" },
|
|
173
|
+
{ label: "Revision", value: r.revision != null ? String(r.revision) : "" },
|
|
174
|
+
{ label: "Entry id", value: r.id, mono: true },
|
|
175
|
+
]} />
|
|
176
|
+
</SectionCard>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
) },
|
|
180
|
+
{ id: "contact", label: "Contact", content: <ContactDetail contactId={contactId} /> },
|
|
181
|
+
...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(contactId) })),
|
|
182
|
+
]}
|
|
183
|
+
/>
|
|
184
|
+
) : null;
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<ScreenLayout
|
|
188
|
+
className={className}
|
|
189
|
+
title="Suppressions"
|
|
190
|
+
description="Everyone on this list is skipped by every sequence, campaign and newsletter."
|
|
191
|
+
icon={<Ban className="size-5" aria-hidden />}
|
|
192
|
+
actions={<>{toolbarActions}<Button size="sm" onClick={() => setAdding(true)}><Ban className="mr-1 size-4" aria-hidden />Suppress contacts</Button></>}
|
|
193
|
+
stats={[
|
|
194
|
+
{ label: "On this page", value: rows.length.toLocaleString() },
|
|
195
|
+
{ label: "All channels", value: byChannel("all") },
|
|
196
|
+
{ label: "Email", value: byChannel("email") },
|
|
197
|
+
{ label: "Phone and LinkedIn", value: byChannel("phone") + byChannel("linkedin") },
|
|
198
|
+
]}
|
|
199
|
+
toolbar={(
|
|
200
|
+
<Toolbar
|
|
201
|
+
filters={(<>
|
|
202
|
+
<select aria-label="Filter by channel" className={SELECT} value={channel} onChange={(e) => { setChannel(e.target.value); resetPage(); }}>
|
|
203
|
+
<option value="">Any channel</option><option value="all">All</option><option value="email">Email</option><option value="phone">Phone</option><option value="linkedin">LinkedIn</option>
|
|
204
|
+
</select>
|
|
205
|
+
<select aria-label="Filter by reason" className={SELECT} value={category} onChange={(e) => { setCategory(e.target.value); resetPage(); }}>
|
|
206
|
+
<option value="">Any reason</option><option value="contact_initiated">Contact asked</option><option value="org_initiated">Our team</option><option value="system">System</option>
|
|
207
|
+
</select>
|
|
208
|
+
<Input aria-label="Check a contact id" inputMode="numeric" placeholder="Check contact id" className="w-40" value={checkId} onChange={(e) => setCheckId(e.target.value)} />
|
|
209
|
+
<Button variant="outline" size="sm" disabled={!Number(checkId)} onClick={() => setChecked(Number(checkId))}><Search className="mr-1 size-4" aria-hidden />Check</Button>
|
|
210
|
+
{checked ? <span className="flex items-center gap-1"><ContactStatusPanel key={checked} contactId={checked} /><span className="text-xs text-muted-foreground">#{checked}</span></span> : null}
|
|
211
|
+
</>)}
|
|
212
|
+
actions={<Button variant="outline" size="sm" disabled={chosen.length === 0} onClick={() => { setPanelTarget(null); setConfirmReinstate(true); }}><BadgeCheck className="mr-1 size-4" aria-hidden />Reinstate {chosen.length || ""}</Button>}
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
>
|
|
216
|
+
<SplitView expanded={full} onExpandedChange={(e) => setView(e ? "full" : null)} list={list} detail={detail} detailOpen={contactId != null} onCloseDetail={() => setOpenId(null)} />
|
|
217
|
+
|
|
218
|
+
<FormSheet open={adding} onOpenChange={setAdding} title="Suppress contacts" description="They will be skipped by every sequence, campaign and newsletter on the chosen channels.">
|
|
219
|
+
<div className="flex flex-col gap-4">
|
|
220
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
221
|
+
<div className="flex flex-col gap-1"><Label htmlFor="suppress-channel">Channel</Label>
|
|
222
|
+
<select id="suppress-channel" className={SELECT} value={addChannel} onChange={(e) => setAddChannel(e.target.value)}>{CHANNELS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select>
|
|
223
|
+
</div>
|
|
224
|
+
<div className="flex min-w-40 flex-1 flex-col gap-1"><Label htmlFor="suppress-reason">Reason (optional)</Label><Input id="suppress-reason" value={reason} onChange={(e) => setReason(e.target.value)} /></div>
|
|
225
|
+
</div>
|
|
226
|
+
<div className="flex flex-col gap-2">
|
|
227
|
+
<Label htmlFor="suppress-ids">Contact ids</Label>
|
|
228
|
+
<Textarea id="suppress-ids" rows={3} placeholder="101, 102, 103" value={idsText} onChange={(e) => setIdsText(e.target.value)} />
|
|
229
|
+
<Button size="sm" loading={add.loading} disabled={ids.length === 0} onClick={() => void add.mutate({ ids })}>{ids.length ? `Suppress ${plural(ids.length, "contact")}` : "Suppress contacts"}</Button>
|
|
230
|
+
</div>
|
|
231
|
+
<div className="flex flex-col gap-2">
|
|
232
|
+
<Label>Or pick people from your CRM</Label>
|
|
233
|
+
<ContactTable selectable allowDelete={false} pageSize={8}
|
|
234
|
+
renderBulkActions={({ ids: sel, clear }) => (
|
|
235
|
+
<Button size="sm" variant="outline" loading={add.loading} onClick={async () => { if (await add.mutate({ ids: sel })) clear(); }}>Suppress {sel.length} selected</Button>
|
|
236
|
+
)} />
|
|
237
|
+
</div>
|
|
238
|
+
{add.data ? <p role="status" className="text-sm">Suppressed {add.data.data.suppressed} of {add.data.data.total_requested}.{add.data.data.not_found.length ? ` ${add.data.data.not_found.length} ids do not exist.` : ""}</p> : null}
|
|
239
|
+
{add.error ? <ApiError error={add.error} title="Could not suppress" /> : null}
|
|
240
|
+
</div>
|
|
241
|
+
</FormSheet>
|
|
242
|
+
|
|
243
|
+
<ConfirmSpendDialog open={confirmReinstate} tier="external" title={`Reinstate ${plural(chosen.length, "contact")}`}
|
|
244
|
+
description={(
|
|
245
|
+
<div className="flex flex-col gap-2">
|
|
246
|
+
<p>Reinstating a contact lifts EVERY suppression on it (all channels), not only the row you ticked. They can be messaged again by sequences, campaigns and newsletters. Contacts who unsubscribed themselves may be refused.</p>
|
|
247
|
+
<Label htmlFor="reinstate-reason">Reason (kept in the audit trail)</Label>
|
|
248
|
+
<Input id="reinstate-reason" value={reinstateReason} onChange={(e) => setReinstateReason(e.target.value)} />
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
recipients={chosen.length} confirmLabel="Reinstate" confirming={reinstate.loading}
|
|
252
|
+
onConfirm={() => void reinstate.mutate(chosen)} onCancel={() => { setConfirmReinstate(false); setPanelTarget(null); }} />
|
|
253
|
+
</ScreenLayout>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -5,6 +5,23 @@ and check deliverability and warmup before a send. The Apollo / Outreach "mailbo
|
|
|
5
5
|
|
|
6
6
|
Depends on: `foundation`. Install: `npx @graph8/sdk add mailboxes`.
|
|
7
7
|
|
|
8
|
+
## Screens
|
|
9
|
+
|
|
10
|
+
`MailboxesScreen` is the finished page: mount it as the route. Every record type has a complete detail view,
|
|
11
|
+
deep-linkable with `?id=` (plus `?tab=` for the section and `?dtab=` for the detail tab).
|
|
12
|
+
|
|
13
|
+
- **Mailboxes tab**: fleet stat strip and the mailbox list (show archived). `MailboxDetail` opens beside it: every
|
|
14
|
+
field, its domain, warmup analytics, start/stop warmup, placement test (confirmed), test connection, archive,
|
|
15
|
+
restore and delete (confirmed).
|
|
16
|
+
- **Domains tab**: your domains plus `DomainPurchase` (suggest, check, buy). `DomainDetail` (`?tab=domains&id=`):
|
|
17
|
+
every field, the mailboxes on it, connect and check DNS (confirmed), buy mailboxes on it.
|
|
18
|
+
- **Add mailbox** (header button): IMAP/SMTP connect, the Google/Microsoft link-out to the graph8 app (OAuth cannot
|
|
19
|
+
run through the API), and buy-mailboxes on a connected domain.
|
|
20
|
+
- **Deliverability tab**: safe-to-send verdict, fleet counts, degraded mailboxes.
|
|
21
|
+
|
|
22
|
+
Slot props: `toolbarActions`, `renderDetailActions(mailbox)`, `extraDetailTabs`, `appUrl`. `WarmupHealth` gained an
|
|
23
|
+
optional `showFleet` prop and `ConnectMailbox` optional `initialMode` and `domainId`.
|
|
24
|
+
|
|
8
25
|
## Components
|
|
9
26
|
|
|
10
27
|
| Component | What it does | Tiers |
|
|
@@ -13,26 +13,28 @@
|
|
|
13
13
|
"foundation"
|
|
14
14
|
],
|
|
15
15
|
"operations": [
|
|
16
|
-
"list_mailboxes_mailboxes_get",
|
|
17
|
-
"test_mailbox_connection_mailboxes__mailbox_id__test_connection_post",
|
|
18
16
|
"archive_mailbox_mailboxes__mailbox_id__archive_post",
|
|
19
|
-
"delete_mailbox_mailboxes__mailbox_id__delete",
|
|
20
|
-
"create_mailbox_mailboxes_post",
|
|
21
|
-
"list_provider_domains_provider_domains_get",
|
|
22
|
-
"get_sending_pricing_provider_domains_pricing_get",
|
|
23
|
-
"checkout_purchase_mailboxes_provider_domains__domain_id__checkout_mailboxes_post",
|
|
24
|
-
"generate_domain_names_provider_domains_generate_domains_post",
|
|
25
17
|
"check_domain_availability_provider_domains_check_availability_post",
|
|
18
|
+
"check_domain_propagation_provider_domains__domain_id__check_propagation_post",
|
|
26
19
|
"checkout_purchase_domains_provider_domains_checkout_purchase_post",
|
|
20
|
+
"checkout_purchase_mailboxes_provider_domains__domain_id__checkout_mailboxes_post",
|
|
27
21
|
"connect_provider_provider_domains__domain_id__connect_provider_post",
|
|
28
|
-
"
|
|
22
|
+
"create_mailbox_mailboxes_post",
|
|
23
|
+
"delete_mailbox_mailboxes__mailbox_id__delete",
|
|
24
|
+
"disable_warmup_warmup__mailbox_id__disable_post",
|
|
25
|
+
"enable_warmup_warmup__mailbox_id__enable_post",
|
|
26
|
+
"generate_domain_names_provider_domains_generate_domains_post",
|
|
29
27
|
"get_fleet_summary_deliverability_fleet_summary_get",
|
|
28
|
+
"get_mailbox_mailboxes__mailbox_id__get",
|
|
29
|
+
"get_mailbox_warmup_analytics_mailboxes__mailbox_id__warmup_get",
|
|
30
30
|
"get_safe_to_send_deliverability_safe_to_send_get",
|
|
31
|
+
"get_sending_pricing_provider_domains_pricing_get",
|
|
31
32
|
"list_degraded_mailboxes_deliverability_degraded_get",
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
33
|
+
"list_mailboxes_mailboxes_get",
|
|
34
|
+
"list_provider_domains_provider_domains_get",
|
|
35
|
+
"run_mailbox_placement_test_mailboxes__mailbox_id__health_placement_test_post",
|
|
36
|
+
"test_mailbox_connection_mailboxes__mailbox_id__test_connection_post",
|
|
37
|
+
"update_mailbox_mailboxes__mailbox_id__patch"
|
|
36
38
|
],
|
|
37
39
|
"components": [
|
|
38
40
|
{
|
|
@@ -56,11 +58,12 @@
|
|
|
56
58
|
"file": "components/connect-mailbox.tsx",
|
|
57
59
|
"purpose": "Connect over IMAP/SMTP and test, link to the app for Google/Microsoft, or buy mailboxes on a connected domain (confirmed).",
|
|
58
60
|
"operations": [
|
|
61
|
+
"checkout_purchase_mailboxes_provider_domains__domain_id__checkout_mailboxes_post",
|
|
59
62
|
"create_mailbox_mailboxes_post",
|
|
60
|
-
"test_mailbox_connection_mailboxes__mailbox_id__test_connection_post",
|
|
61
|
-
"list_provider_domains_provider_domains_get",
|
|
62
63
|
"get_sending_pricing_provider_domains_pricing_get",
|
|
63
|
-
"
|
|
64
|
+
"list_provider_domains_provider_domains_get",
|
|
65
|
+
"test_mailbox_connection_mailboxes__mailbox_id__test_connection_post",
|
|
66
|
+
"update_mailbox_mailboxes__mailbox_id__patch"
|
|
64
67
|
],
|
|
65
68
|
"tiers": [
|
|
66
69
|
"read",
|
|
@@ -103,6 +106,50 @@
|
|
|
103
106
|
"write",
|
|
104
107
|
"billable"
|
|
105
108
|
]
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "MailboxesScreen",
|
|
112
|
+
"file": "components/mailboxes-screen.tsx",
|
|
113
|
+
"purpose": "Finished mailboxes page: fleet stats, mailbox list with warmup detail panel, add-mailbox sheet, deliverability and domains tabs.",
|
|
114
|
+
"operations": [
|
|
115
|
+
"get_fleet_summary_deliverability_fleet_summary_get",
|
|
116
|
+
"list_provider_domains_provider_domains_get"
|
|
117
|
+
],
|
|
118
|
+
"tiers": [
|
|
119
|
+
"read"
|
|
120
|
+
]
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"name": "MailboxDetail",
|
|
124
|
+
"file": "components/mailbox-detail.tsx",
|
|
125
|
+
"purpose": "One mailbox, complete: every field, its domain, warmup and health; test, archive, restore and delete (confirmed).",
|
|
126
|
+
"operations": [
|
|
127
|
+
"get_mailbox_mailboxes__mailbox_id__get",
|
|
128
|
+
"list_provider_domains_provider_domains_get",
|
|
129
|
+
"test_mailbox_connection_mailboxes__mailbox_id__test_connection_post",
|
|
130
|
+
"archive_mailbox_mailboxes__mailbox_id__archive_post",
|
|
131
|
+
"delete_mailbox_mailboxes__mailbox_id__delete"
|
|
132
|
+
],
|
|
133
|
+
"tiers": [
|
|
134
|
+
"read",
|
|
135
|
+
"write",
|
|
136
|
+
"destructive"
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"name": "DomainDetail",
|
|
141
|
+
"file": "components/domain-detail.tsx",
|
|
142
|
+
"purpose": "One sending domain, complete: every field, the mailboxes on it; connect and check DNS (confirmed), buy mailboxes.",
|
|
143
|
+
"operations": [
|
|
144
|
+
"list_provider_domains_provider_domains_get",
|
|
145
|
+
"list_mailboxes_mailboxes_get",
|
|
146
|
+
"connect_provider_provider_domains__domain_id__connect_provider_post",
|
|
147
|
+
"check_domain_propagation_provider_domains__domain_id__check_propagation_post"
|
|
148
|
+
],
|
|
149
|
+
"tiers": [
|
|
150
|
+
"read",
|
|
151
|
+
"billable"
|
|
152
|
+
]
|
|
106
153
|
}
|
|
107
154
|
],
|
|
108
155
|
"hooks": [],
|
|
@@ -182,6 +229,7 @@
|
|
|
182
229
|
}
|
|
183
230
|
],
|
|
184
231
|
"peerDependencies": {
|
|
185
|
-
"react": ">=18"
|
|
232
|
+
"react": ">=18",
|
|
233
|
+
"lucide-react": ">=0.400.0"
|
|
186
234
|
}
|
|
187
235
|
}
|
|
@@ -24,8 +24,10 @@ import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
|
24
24
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
25
25
|
import { ApiError } from "@/g8/components/api-error";
|
|
26
26
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
27
|
-
import { DEFAULT_MAILBOX_CREDITS,
|
|
27
|
+
import { DEFAULT_MAILBOX_CREDITS, GRAPH8_APP_MAILBOXES_PATH, listOf, newIdempotencyKey, objectOf } from "../lib/mailboxes-utils";
|
|
28
28
|
import type { DomainRow } from "../lib/mailboxes-utils";
|
|
29
|
+
import { plural, pluralWord } from "@/g8/lib/plural";
|
|
30
|
+
import { brandUrl, useBrand } from "@/g8/hooks/use-brand";
|
|
29
31
|
|
|
30
32
|
export interface ConnectMailboxProps {
|
|
31
33
|
/** Where the person connects Google or Microsoft. Default: graph8 Settings > Purchase > Mailboxes. */
|
|
@@ -34,6 +36,10 @@ export interface ConnectMailboxProps {
|
|
|
34
36
|
onConnected?: (mailboxId: number | string) => void;
|
|
35
37
|
/** Called with the domain id after mailboxes were bought on it. */
|
|
36
38
|
onPurchased?: (domainId: number | string) => void;
|
|
39
|
+
/** Open on this tab (default "imap"). */
|
|
40
|
+
initialMode?: "imap" | "oauth" | "buy";
|
|
41
|
+
/** Preselect this connected domain on the Buy tab. */
|
|
42
|
+
domainId?: number | string;
|
|
37
43
|
className?: string;
|
|
38
44
|
}
|
|
39
45
|
|
|
@@ -54,6 +60,7 @@ const EMPTY = {
|
|
|
54
60
|
};
|
|
55
61
|
|
|
56
62
|
function ImapForm({ onConnected }: { onConnected?: (id: number | string) => void }) {
|
|
63
|
+
const brand = useBrand();
|
|
57
64
|
const [form, setForm] = useState(EMPTY);
|
|
58
65
|
const [result, setResult] = useState<string | null>(null);
|
|
59
66
|
// Step 1 (create) must not repeat when step 2 (test) fails, even if the person then fixes a password.
|
|
@@ -73,7 +80,7 @@ function ImapForm({ onConnected }: { onConnected?: (id: number | string) => void
|
|
|
73
80
|
const known = created.current?.email === form.email.trim().toLowerCase() ? created.current : null;
|
|
74
81
|
if (!known) {
|
|
75
82
|
const res = await g8Post<{ id: number }>("/mailboxes", body);
|
|
76
|
-
if (res.data?.id == null) throw new Error(
|
|
83
|
+
if (res.data?.id == null) throw new Error(`${brand.linkLabel} did not return the new mailbox id`);
|
|
77
84
|
created.current = { email: form.email.trim().toLowerCase(), id: res.data.id, sig };
|
|
78
85
|
} else if (known.sig !== sig) {
|
|
79
86
|
const { email: _email, ...changes } = body;
|
|
@@ -136,10 +143,11 @@ function ImapForm({ onConnected }: { onConnected?: (id: number | string) => void
|
|
|
136
143
|
);
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
function BuyForm({ onPurchased }: { onPurchased?: (id: number | string) => void }) {
|
|
146
|
+
function BuyForm({ onPurchased, domainId: initialDomainId }: { onPurchased?: (id: number | string) => void; domainId?: number | string }) {
|
|
147
|
+
const brand = useBrand();
|
|
140
148
|
const domains = useG8Query((signal) => g8Get("/provider-domains", { page: 1, limit: 100 }, { signal }), []);
|
|
141
149
|
const pricing = useG8Query((signal) => g8Get("/provider-domains/pricing", undefined, { signal }), []);
|
|
142
|
-
const [domainId, setDomainId] = useState("");
|
|
150
|
+
const [domainId, setDomainId] = useState(initialDomainId != null ? String(initialDomainId) : "");
|
|
143
151
|
const [usernames, setUsernames] = useState("");
|
|
144
152
|
const [person, setPerson] = useState({ first_name: "", last_name: "" });
|
|
145
153
|
const [platform, setPlatform] = useState<"GOOGLE" | "MICROSOFT">("GOOGLE");
|
|
@@ -159,7 +167,7 @@ function BuyForm({ onPurchased }: { onPurchased?: (id: number | string) => void
|
|
|
159
167
|
const sig = `${domainId}:${JSON.stringify(body)}`;
|
|
160
168
|
if (key.current?.sig !== sig) key.current = { sig, value: newIdempotencyKey() };
|
|
161
169
|
const res = objectOf(await g8Post(`/provider-domains/${domainId}/checkout-mailboxes`, body, { idempotencyKey: key.current.value }));
|
|
162
|
-
setDone(`Accepted ${res.count ?? names.length
|
|
170
|
+
setDone(`Accepted ${plural(Number(res.count ?? names.length), "mailbox", "mailboxes")}; provisioning continues in the background.`);
|
|
163
171
|
onPurchased?.(domainId);
|
|
164
172
|
});
|
|
165
173
|
const connected = listOf<DomainRow>(domains.data).filter((d) => d.provider_connected);
|
|
@@ -219,7 +227,7 @@ function BuyForm({ onPurchased }: { onPurchased?: (id: number | string) => void
|
|
|
219
227
|
</div>
|
|
220
228
|
)}
|
|
221
229
|
<Button disabled={!ready} onClick={() => setConfirming(true)}>
|
|
222
|
-
Buy {names.length || ""}
|
|
230
|
+
Buy {names.length || ""} {pluralWord(names.length, "mailbox", "mailboxes")}
|
|
223
231
|
</Button>
|
|
224
232
|
{buy.error ? <ApiError error={buy.error} onRetry={buy.reset} /> : null}
|
|
225
233
|
{done ? (
|
|
@@ -231,8 +239,8 @@ function BuyForm({ onPurchased }: { onPurchased?: (id: number | string) => void
|
|
|
231
239
|
<ConfirmSpendDialog
|
|
232
240
|
open
|
|
233
241
|
tier="billable"
|
|
234
|
-
title={`Buy ${names.length
|
|
235
|
-
description=
|
|
242
|
+
title={`Buy ${plural(names.length, "mailbox", "mailboxes")}: ${names.join(", ")}`}
|
|
243
|
+
description={`Mailboxes are billed monthly while they exist, plus warmup if enabled. This is an estimate: ${brand.linkLabel} has no balance read.`}
|
|
236
244
|
estimatedCredits={names.length * perMailbox}
|
|
237
245
|
confirmLabel="Buy mailboxes"
|
|
238
246
|
confirming={buy.loading}
|
|
@@ -244,8 +252,10 @@ function BuyForm({ onPurchased }: { onPurchased?: (id: number | string) => void
|
|
|
244
252
|
);
|
|
245
253
|
}
|
|
246
254
|
|
|
247
|
-
export function ConnectMailbox({ appUrl
|
|
248
|
-
const
|
|
255
|
+
export function ConnectMailbox({ appUrl: appUrlProp, onConnected, onPurchased, initialMode = "imap", domainId, className }: ConnectMailboxProps) {
|
|
256
|
+
const brand = useBrand();
|
|
257
|
+
const appUrl = appUrlProp ?? brandUrl(brand, GRAPH8_APP_MAILBOXES_PATH);
|
|
258
|
+
const [mode, setMode] = useState<Mode>(initialMode);
|
|
249
259
|
return (
|
|
250
260
|
<div className={`space-y-4 ${className ?? ""}`}>
|
|
251
261
|
<div className="inline-flex gap-1" role="group" aria-label="How to add a mailbox">
|
|
@@ -268,17 +278,17 @@ export function ConnectMailbox({ appUrl = GRAPH8_APP_MAILBOXES_URL, onConnected,
|
|
|
268
278
|
{mode === "oauth" ? (
|
|
269
279
|
<div className="space-y-2 rounded border border-gray-200 p-3 text-sm">
|
|
270
280
|
<p>
|
|
271
|
-
Google and Microsoft mailboxes connect through a sign-in screen that only runs inside the
|
|
281
|
+
Google and Microsoft mailboxes connect through a sign-in screen that only runs inside the {brand.linkLabel} app. Connect it there, then
|
|
272
282
|
come back: it appears in your mailbox list.
|
|
273
283
|
</p>
|
|
274
284
|
<Button asChild>
|
|
275
285
|
<a href={appUrl} target="_blank" rel="noreferrer">
|
|
276
|
-
Open
|
|
286
|
+
Open {brand.linkLabel} to connect
|
|
277
287
|
</a>
|
|
278
288
|
</Button>
|
|
279
289
|
</div>
|
|
280
290
|
) : null}
|
|
281
|
-
{mode === "buy" ? <BuyForm onPurchased={onPurchased} /> : null}
|
|
291
|
+
{mode === "buy" ? <BuyForm onPurchased={onPurchased} domainId={domainId} /> : null}
|
|
282
292
|
</div>
|
|
283
293
|
);
|
|
284
294
|
}
|