@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,159 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SequencesScreen: the finished "Sequences" page (the Apollo / Outreach sequencer).
|
|
5
|
+
*
|
|
6
|
+
* Composes: SequenceList (status, launch, pause, resume, duplicate, archive), SequenceBuilder (create a draft,
|
|
7
|
+
* edit steps), SequenceStats (totals, per-step progress, per-contact pause/resume), EnrollDialog, plus the
|
|
8
|
+
* lists block ListPicker and the crm block ContactTable to choose who to enroll.
|
|
9
|
+
*
|
|
10
|
+
* Every record has a complete detail, beside the list or as a full page (`?view=full`): the sequence
|
|
11
|
+
* (SequenceDetail), each step (`?step=`) and each enrollment (`?enrollment=`).
|
|
12
|
+
*
|
|
13
|
+
* User flows:
|
|
14
|
+
* - Browse sequences, filter by status, see live/paused/draft counts at a glance.
|
|
15
|
+
* - "New sequence" opens a sheet with the builder (sender, audience list, email/LinkedIn/call steps); the new
|
|
16
|
+
* draft opens right away. Nothing is sent until it is launched.
|
|
17
|
+
* - Launch (after a preview that checks each step's copy and sender; a launch that would 400 is refused with the
|
|
18
|
+
* reasons), pause, resume, duplicate, archive from the list (all confirmed).
|
|
19
|
+
* - Open a sequence (deep link `?id=`): Results (stats, contacts, pause/resume one), Steps (edit copy), and
|
|
20
|
+
* "Enroll contacts": pick a list, tick contacts, confirm the recipient count.
|
|
21
|
+
*
|
|
22
|
+
* SDK operations of its own (both read):
|
|
23
|
+
* 1. listSequences GET /sequences?limit=100 (counts for the stat strip)
|
|
24
|
+
* 2. getSequence GET /sequences/{id} (header of the open sequence; also gives associated_list_id)
|
|
25
|
+
* Everything that reaches real people is confirmed in the composed parts.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { useState } from "react";
|
|
29
|
+
import type { ReactNode } from "react";
|
|
30
|
+
import { ListOrdered, Plus } from "lucide-react";
|
|
31
|
+
import { Button } from "@graph8/sdk/ui";
|
|
32
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
33
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
34
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
35
|
+
import type { StatItem } from "@/g8/components/screen/stat-strip";
|
|
36
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
37
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
38
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
39
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
40
|
+
import { listOf, statusGroup } from "../lib/sequencer-utils";
|
|
41
|
+
import type { SequenceRow } from "../lib/sequencer-utils";
|
|
42
|
+
import { SequenceBuilder } from "./sequence-builder";
|
|
43
|
+
import { SequenceDetail } from "./sequence-detail";
|
|
44
|
+
import { SequenceList } from "./sequence-list";
|
|
45
|
+
|
|
46
|
+
export interface SequencesScreenProps {
|
|
47
|
+
/** Owner email sent with a new sequence (`user_email`). */
|
|
48
|
+
ownerEmail?: string;
|
|
49
|
+
/** Extra buttons in the toolbar. */
|
|
50
|
+
toolbarActions?: ReactNode;
|
|
51
|
+
/** Extra buttons in the open sequence's header. */
|
|
52
|
+
renderDetailActions?: (sequence: SequenceRow) => ReactNode;
|
|
53
|
+
/** Extra tabs in the open sequence's panel. */
|
|
54
|
+
extraDetailTabs?: { id: string; label: string; render: (sequence: SequenceRow) => ReactNode }[];
|
|
55
|
+
className?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const FILTERS = [
|
|
59
|
+
{ value: "", label: "All statuses" },
|
|
60
|
+
{ value: "drafted", label: "Drafts" },
|
|
61
|
+
{ value: "live", label: "Live" },
|
|
62
|
+
{ value: "paused", label: "Paused" },
|
|
63
|
+
];
|
|
64
|
+
const SUMMARY_LIMIT = 100;
|
|
65
|
+
|
|
66
|
+
/** Counts by status group for the stat strip. Exported for tests. */
|
|
67
|
+
export function sequenceStats(rows: SequenceRow[]): StatItem[] {
|
|
68
|
+
const n = (g: ReturnType<typeof statusGroup>) => rows.filter((r) => statusGroup(r.status) === g).length;
|
|
69
|
+
const hint = rows.length >= SUMMARY_LIMIT ? `First ${SUMMARY_LIMIT} sequences` : undefined;
|
|
70
|
+
const live = rows.filter((r) => statusGroup(r.status) === "live");
|
|
71
|
+
return [
|
|
72
|
+
{ label: "Live", value: n("live"), tone: "positive", hint },
|
|
73
|
+
{ label: "Paused", value: n("paused"), tone: "warning", hint },
|
|
74
|
+
{ label: "Drafts", value: n("draft"), hint },
|
|
75
|
+
{ label: "Contacts in live", value: live.reduce((a, r) => a + (r.contact_count ?? 0), 0).toLocaleString(), hint },
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function SequencesScreen(props: SequencesScreenProps) {
|
|
80
|
+
const { ownerEmail, toolbarActions, className } = props;
|
|
81
|
+
const [selectedId, setSelectedId] = useUrlState("id", "");
|
|
82
|
+
const [detailTab, setDetailTab] = useUrlState("dtab", "");
|
|
83
|
+
const [view, setView] = useUrlState("view", "");
|
|
84
|
+
const [stepId, setStepId] = useUrlState("step", "");
|
|
85
|
+
const [contactId, setContactId] = useUrlState("enrollment", "");
|
|
86
|
+
const [status, setStatus] = useUrlState("status", "");
|
|
87
|
+
const [creating, setCreating] = useState(false);
|
|
88
|
+
const [version, setVersion] = useState(0);
|
|
89
|
+
const [listKey, setListKey] = useState(0);
|
|
90
|
+
const summary = useG8Query((signal) => g8Get("/sequences", { limit: SUMMARY_LIMIT }, { signal }), [version], { key: ["sequences", "summary"] });
|
|
91
|
+
const stats = summary.data ? sequenceStats(listOf<SequenceRow>(summary.data)) : undefined;
|
|
92
|
+
const bump = () => setVersion((v) => v + 1);
|
|
93
|
+
const open = (id: string) => { setSelectedId(id); setDetailTab(null); setStepId(null); setContactId(null); };
|
|
94
|
+
const close = () => { setSelectedId(null); setDetailTab(null); setStepId(null); setContactId(null); setView(null); };
|
|
95
|
+
const refreshList = () => { setListKey((k) => k + 1); bump(); };
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<ScreenLayout
|
|
99
|
+
className={className}
|
|
100
|
+
title="Sequences"
|
|
101
|
+
description="Multi-channel outreach that runs on its own: build steps, enroll contacts, watch replies."
|
|
102
|
+
icon={<ListOrdered className="h-5 w-5" aria-hidden />}
|
|
103
|
+
actions={
|
|
104
|
+
<Button onClick={() => setCreating(true)}>
|
|
105
|
+
<Plus className="mr-1 h-4 w-4" aria-hidden /> New sequence
|
|
106
|
+
</Button>
|
|
107
|
+
}
|
|
108
|
+
stats={stats}
|
|
109
|
+
>
|
|
110
|
+
<div className="space-y-3">
|
|
111
|
+
<Toolbar
|
|
112
|
+
actions={toolbarActions}
|
|
113
|
+
filters={
|
|
114
|
+
<select aria-label="Status" className="h-9 rounded-md border bg-background px-2 text-sm" value={status} onChange={(e) => setStatus(e.target.value || null)}>
|
|
115
|
+
{FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
|
116
|
+
</select>
|
|
117
|
+
}
|
|
118
|
+
/>
|
|
119
|
+
<SplitView
|
|
120
|
+
expanded={view === "full"} onExpandedChange={(e) => setView(e ? "full" : null)}
|
|
121
|
+
detailOpen={Boolean(selectedId)}
|
|
122
|
+
onCloseDetail={close}
|
|
123
|
+
list={
|
|
124
|
+
<SequenceList key={`${listKey}:${status}`} status={status || undefined} onOpen={(s) => open(s.id)} onCreate={() => setCreating(true)} onChanged={bump} />
|
|
125
|
+
}
|
|
126
|
+
detail={
|
|
127
|
+
selectedId ? (
|
|
128
|
+
<SequenceDetail
|
|
129
|
+
key={selectedId}
|
|
130
|
+
sequenceId={selectedId}
|
|
131
|
+
onExpand={view === "full" ? undefined : () => setView("full")} onCollapse={view === "full" ? () => setView(null) : undefined}
|
|
132
|
+
tab={detailTab || undefined}
|
|
133
|
+
onTabChange={(t) => setDetailTab(t === "details" ? null : t)}
|
|
134
|
+
stepId={stepId || undefined}
|
|
135
|
+
onStepChange={(v) => setStepId(v)}
|
|
136
|
+
contactId={contactId || undefined}
|
|
137
|
+
onContactChange={(v) => setContactId(v)}
|
|
138
|
+
onClose={close}
|
|
139
|
+
onChanged={refreshList}
|
|
140
|
+
refreshKey={version}
|
|
141
|
+
onOpenSequence={open}
|
|
142
|
+
renderActions={props.renderDetailActions}
|
|
143
|
+
extraTabs={props.extraDetailTabs}
|
|
144
|
+
/>
|
|
145
|
+
) : null
|
|
146
|
+
}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
<FormSheet open={creating} onOpenChange={setCreating} width="xl" title="New sequence" description="Saved as a draft. Nobody is contacted until you launch it.">
|
|
150
|
+
{creating ? (
|
|
151
|
+
<SequenceBuilder
|
|
152
|
+
ownerEmail={ownerEmail}
|
|
153
|
+
onSaved={(id) => { setCreating(false); refreshList(); open(id); }}
|
|
154
|
+
/>
|
|
155
|
+
) : null}
|
|
156
|
+
</FormSheet>
|
|
157
|
+
</ScreenLayout>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -20,6 +20,7 @@ import { g8Get } from "@/g8/lib/g8-client";
|
|
|
20
20
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
21
21
|
import { hasBrokenTokens, KIND_LABEL, LINKEDIN_ACTIONS, stepIssues } from "../lib/sequencer-utils";
|
|
22
22
|
import type { DraftStep, InputType } from "../lib/sequencer-utils";
|
|
23
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
23
24
|
|
|
24
25
|
export interface StepEditorProps {
|
|
25
26
|
step: DraftStep;
|
|
@@ -38,6 +39,7 @@ const COPY_KEY: Record<string, string | undefined> = {
|
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
function LinkedinReadiness() {
|
|
42
|
+
const brand = useBrand();
|
|
41
43
|
const conn = useG8Query(
|
|
42
44
|
(signal) =>
|
|
43
45
|
g8Get<{ connected?: boolean; is_token_valid?: boolean; requires_manual_login?: boolean }>(
|
|
@@ -53,7 +55,7 @@ function LinkedinReadiness() {
|
|
|
53
55
|
return <p className="text-xs text-green-700">LinkedIn sender connected.</p>;
|
|
54
56
|
return (
|
|
55
57
|
<p role="alert" className="text-xs text-amber-800">
|
|
56
|
-
LinkedIn is not ready to send. Reconnect the LinkedIn account in the
|
|
58
|
+
LinkedIn is not ready to send. Reconnect the LinkedIn account in the {brand.linkLabel} app before launching.
|
|
57
59
|
</p>
|
|
58
60
|
);
|
|
59
61
|
}
|
|
@@ -10,7 +10,8 @@ export type StepKind = "email" | "linkedin" | "call" | "task";
|
|
|
10
10
|
|
|
11
11
|
/** UI step kind -> the API `step_type`. `linkedin` is HeyReach; `task` is a manual dialer task. */
|
|
12
12
|
export const STEP_TYPE: Record<StepKind, string> = { email: "EMAIL", linkedin: "HEYREACH", call: "PHONE", task: "MANUAL_DIALER" };
|
|
13
|
-
|
|
13
|
+
/** `call` is an AI voice-agent call (PHONE); `task` is a call a rep makes from the dialer (MANUAL_DIALER). */
|
|
14
|
+
export const KIND_LABEL: Record<StepKind, string> = { email: "Email", linkedin: "LinkedIn", call: "AI call", task: "Call task" };
|
|
14
15
|
export const KINDS = Object.keys(STEP_TYPE) as StepKind[];
|
|
15
16
|
|
|
16
17
|
export const LINKEDIN_ACTIONS = ["CONNECTION_REQUEST", "MESSAGE", "INMAIL", "FOLLOW", "VIEW_PROFILE", "LIKE_POST"] as const;
|
|
@@ -29,6 +30,8 @@ export interface DraftStep {
|
|
|
29
30
|
step_data: Record<string, unknown>;
|
|
30
31
|
/** True once changed since it was loaded or saved. */
|
|
31
32
|
dirty?: boolean;
|
|
33
|
+
/** The API `step_type` a loaded step had (for example NETRION), so saving it never rewrites the provider. */
|
|
34
|
+
step_type?: string;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export interface SequenceRow {
|
|
@@ -77,17 +80,23 @@ export function fromApiStep(s: ApiStep): DraftStep {
|
|
|
77
80
|
key: s.id ?? newKey(),
|
|
78
81
|
id: s.id,
|
|
79
82
|
kind: kindOf(s.step_type),
|
|
83
|
+
step_type: s.step_type,
|
|
80
84
|
input_type: (s.input_type as InputType) ?? "MANUAL_TEMPLATE",
|
|
81
85
|
wait_days: (s.time_interval ?? 0) / 86400,
|
|
82
86
|
step_data: { ...(s.step_data ?? {}) },
|
|
83
87
|
};
|
|
84
88
|
}
|
|
85
89
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
90
|
+
/**
|
|
91
|
+
* The request shape for create, update and add-steps. `order` is 1-based. `linkedinStepType` is the LinkedIn
|
|
92
|
+
* provider step for NEW LinkedIn steps (`HEYREACH`, or `NETRION`); a loaded step keeps its own `step_type`.
|
|
93
|
+
*/
|
|
94
|
+
export function toApiStep(step: DraftStep, order: number, linkedinStepType?: string) {
|
|
95
|
+
// A loaded step keeps its own type (a NETRION step stays NETRION) as long as its kind did not change.
|
|
96
|
+
const own = step.step_type && kindOf(step.step_type) === step.kind ? step.step_type : undefined;
|
|
88
97
|
return {
|
|
89
98
|
step_order: order,
|
|
90
|
-
step_type: STEP_TYPE[step.kind],
|
|
99
|
+
step_type: own ?? (step.kind === "linkedin" && linkedinStepType ? linkedinStepType : STEP_TYPE[step.kind]),
|
|
91
100
|
input_type: step.input_type,
|
|
92
101
|
time_interval: Math.max(0, Math.round(step.wait_days * 86400)),
|
|
93
102
|
step_data: step.step_data,
|
|
@@ -153,7 +162,96 @@ export function objectOf(envelope: unknown): Record<string, unknown> {
|
|
|
153
162
|
return data && typeof data === "object" && !Array.isArray(data) ? (data as Record<string, unknown>) : {};
|
|
154
163
|
}
|
|
155
164
|
|
|
156
|
-
|
|
165
|
+
export type EmailChannelType = "GMAIL" | "INBOXKIT" | "SMTP";
|
|
166
|
+
|
|
167
|
+
export interface MailboxLike {
|
|
168
|
+
provider?: string | null;
|
|
169
|
+
/** inboxkit | manual_google | manual_microsoft | manual_smtp | opensrs | purchased (GET /mailboxes). */
|
|
170
|
+
source?: string | null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The sequence channel type for a mailbox, decided by `source` first, as the graph8 app does: an InboxKit mailbox
|
|
175
|
+
* is INBOXKIT whatever its provider (a Google-backed InboxKit mailbox attached as GMAIL fails at send time),
|
|
176
|
+
* Google is GMAIL, SMTP is SMTP. `null` = this mailbox cannot send from a sequence (see `mailboxUnsupportedReason`).
|
|
177
|
+
*/
|
|
178
|
+
export function mailboxChannelType(mailbox: MailboxLike): EmailChannelType | null {
|
|
179
|
+
const source = (mailbox.source ?? "").toLowerCase();
|
|
180
|
+
const provider = (mailbox.provider ?? "").toLowerCase();
|
|
181
|
+
if (source === "inboxkit") return "INBOXKIT";
|
|
182
|
+
if (source === "manual_microsoft" || provider === "microsoft") return null;
|
|
183
|
+
if (provider === "google_mailbox" || source === "manual_google") return "GMAIL";
|
|
184
|
+
if (provider === "smtp" || source === "manual_smtp") return "SMTP";
|
|
185
|
+
// An API without `source` (older graph8): fall back to the provider guess.
|
|
186
|
+
return source ? null : channelTypeFor(provider);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Why a mailbox cannot be picked as a sequence sender, or null when it can. */
|
|
190
|
+
export function mailboxUnsupportedReason(mailbox: MailboxLike): string | null {
|
|
191
|
+
if (mailboxChannelType(mailbox)) return null;
|
|
192
|
+
const microsoft = (mailbox.source ?? "").toLowerCase() === "manual_microsoft" || (mailbox.provider ?? "").toLowerCase() === "microsoft";
|
|
193
|
+
return microsoft
|
|
194
|
+
? "Directly connected Microsoft mailboxes cannot send from sequences yet"
|
|
195
|
+
: "This mailbox type cannot send from sequences yet";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The senders a set of steps needs. `call` (AI) needs an AI number, `task` a dialer number. */
|
|
199
|
+
export function requiredSenders(kinds: Iterable<StepKind>): { email: boolean; aiPhone: boolean; dialerPhone: boolean; linkedin: boolean } {
|
|
200
|
+
const set = new Set(kinds);
|
|
201
|
+
return { email: set.has("email"), aiPhone: set.has("call"), dialerPhone: set.has("task"), linkedin: set.has("linkedin") };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const EMAIL_TYPES = new Set(["SMTP", "GMAIL", "INBOXKIT"]);
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* What stops a sequence from launching, read from GET /sequences/{id}/preview (`steps[].step_type` and
|
|
208
|
+
* `channels[].channel_type`): the API refuses `/run` with 400 when a step type has no sender channel. Checked
|
|
209
|
+
* client-side so a launch is never offered that can only fail.
|
|
210
|
+
*/
|
|
211
|
+
export function senderGaps(steps: Array<{ step_type?: string | null }>, channels: Array<{ channel_type?: string | null }>): string[] {
|
|
212
|
+
const types = new Set(steps.map((s) => (s.step_type ?? "").toUpperCase()));
|
|
213
|
+
const have = new Set(channels.map((c) => (c.channel_type ?? "").toUpperCase()));
|
|
214
|
+
const gaps: string[] = [];
|
|
215
|
+
if (types.has("EMAIL") && ![...have].some((t) => EMAIL_TYPES.has(t))) gaps.push("Email steps have no mailbox to send from.");
|
|
216
|
+
if ((types.has("PHONE") || types.has("MANUAL_DIALER")) && !have.has("PHONE"))
|
|
217
|
+
gaps.push("Call steps have no phone number to call from.");
|
|
218
|
+
if (types.has("HEYREACH") && !have.has("LINKEDIN")) gaps.push("LinkedIn steps have no LinkedIn account to send from.");
|
|
219
|
+
if (types.has("NETRION") && !have.has("LINKEDIN_NETRION")) gaps.push("LinkedIn steps have no LinkedIn account to send from.");
|
|
220
|
+
if (types.has("SMS") && !have.has("SMS")) gaps.push("SMS steps have no number to text from.");
|
|
221
|
+
return gaps;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface PreviewIssue {
|
|
225
|
+
message?: string;
|
|
226
|
+
severity?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Launch check from GET /sequences/{id}/preview. The verdict lives on each step: `steps[].rendered.will_send_as_shown`
|
|
231
|
+
* and `steps[].rendered.content_issues` (a top-level copy is read too, for older APIs). An error-severity issue
|
|
232
|
+
* makes `/run` answer 400, so it blocks; anything else is a warning. A missing sender also blocks.
|
|
233
|
+
*/
|
|
234
|
+
export function launchCheck(preview: Record<string, unknown>): { warnings: string[]; blockers: string[] } {
|
|
235
|
+
const steps = (Array.isArray(preview.steps) ? preview.steps : []) as Array<Record<string, unknown>>;
|
|
236
|
+
const channels = (Array.isArray(preview.channels) ? preview.channels : []) as Array<{ channel_type?: string | null }>;
|
|
237
|
+
const warnings: string[] = [];
|
|
238
|
+
const blockers: string[] = [];
|
|
239
|
+
const read = (verdict: Record<string, unknown> | null | undefined, where: string) => {
|
|
240
|
+
if (!verdict) return;
|
|
241
|
+
const issues = (Array.isArray(verdict.content_issues) ? verdict.content_issues : []) as PreviewIssue[];
|
|
242
|
+
for (const issue of issues) {
|
|
243
|
+
const text = `${where}${issue.message ?? "The copy will not send as shown."}`;
|
|
244
|
+
((issue.severity ?? "").toLowerCase() === "error" ? blockers : warnings).push(text);
|
|
245
|
+
}
|
|
246
|
+
if (verdict.will_send_as_shown === false && issues.length === 0) warnings.push(`${where}The copy will not send as shown.`);
|
|
247
|
+
};
|
|
248
|
+
steps.forEach((step, i) => read(step.rendered as Record<string, unknown> | undefined, `Step ${Number(step.step_order ?? i + 1)}: `));
|
|
249
|
+
read(preview, "");
|
|
250
|
+
blockers.push(...senderGaps(steps as Array<{ step_type?: string | null }>, channels));
|
|
251
|
+
return { warnings: [...new Set(warnings)], blockers: [...new Set(blockers)] };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Map a mailbox's provider to the sequence channel type the API takes. Prefer `mailboxChannelType`, which reads `source`. */
|
|
157
255
|
export function channelTypeFor(provider: string | null | undefined): "GMAIL" | "INBOXKIT" | "SMTP" {
|
|
158
256
|
const p = (provider ?? "").toLowerCase();
|
|
159
257
|
if (p.includes("gmail") || p.includes("google")) return "GMAIL";
|
|
@@ -166,3 +264,28 @@ export function newIdempotencyKey(): string {
|
|
|
166
264
|
? crypto.randomUUID()
|
|
167
265
|
: `g8-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
168
266
|
}
|
|
267
|
+
|
|
268
|
+
const HIDDEN_FIELDS = /password|secret|token|credential/i;
|
|
269
|
+
|
|
270
|
+
/** Every readable field of an open-shaped record as label/value pairs (secrets are never shown). */
|
|
271
|
+
export function fieldItems(obj: Record<string, unknown>): Array<{ label: string; value: string }> {
|
|
272
|
+
return Object.entries(obj)
|
|
273
|
+
.filter(([k, v]) => !HIDDEN_FIELDS.test(k) && v !== null && v !== undefined && v !== "")
|
|
274
|
+
.map(([k, v]) => {
|
|
275
|
+
const label = k.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
|
|
276
|
+
if (typeof v === "string" && /_at$|date$/.test(k) && !Number.isNaN(new Date(v).getTime()))
|
|
277
|
+
return { label, value: new Date(v).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }) + " UTC" };
|
|
278
|
+
if (typeof v === "boolean") return { label, value: v ? "Yes" : "No" };
|
|
279
|
+
if (typeof v === "object") return { label, value: JSON.stringify(v) };
|
|
280
|
+
return { label, value: String(v) };
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** One line that says what a step does, for lists. */
|
|
285
|
+
export function stepSummary(step: DraftStep): string {
|
|
286
|
+
const d = step.step_data;
|
|
287
|
+
const str = (v: unknown) => (typeof v === "string" ? v.trim() : "");
|
|
288
|
+
if (step.kind === "email") return str(d.subject) || str(d.instructions) || "Email";
|
|
289
|
+
if (step.kind === "linkedin") return str(d.linkedin_action_type).replace(/_/g, " ").toLowerCase() || "LinkedIn";
|
|
290
|
+
return KIND_LABEL[step.kind];
|
|
291
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @title Create a draft sequence with email, LinkedIn and
|
|
2
|
+
* @title Create a draft sequence with email, LinkedIn and an AI call
|
|
3
3
|
* @when Building outreach. Creating the draft is tier `external` in the contract but contacts nobody; the
|
|
4
|
-
* LAUNCH is what reaches people (see launch-and-enroll.ts).
|
|
4
|
+
* LAUNCH is what reaches people (see launch-and-enroll.ts). Rules the API only enforces at send or launch time:
|
|
5
5
|
* 1) an EMAIL step needs `input_type: "MANUAL_TEMPLATE"` or its subject/body are ignored; 2) a LinkedIn
|
|
6
6
|
* (HEYREACH) step needs `linkedin_action_type` and the action's own copy key, never `body`; 3) `time_interval`
|
|
7
|
-
* is in SECONDS
|
|
7
|
+
* is in SECONDS; 4) EVERY step type needs its own sender channel or POST /sequences/{id}/run answers 400: the
|
|
8
|
+
* mailbox (channel type from its `source`), a Voice AI number for PHONE (an AI voice-agent call; a rep's call is
|
|
9
|
+
* MANUAL_DIALER with a dialer number), a LinkedIn account for HEYREACH; 5) `associated_list_id` is required to
|
|
10
|
+
* launch and cannot be set later.
|
|
8
11
|
*/
|
|
9
12
|
import { g8 } from "@graph8/sdk";
|
|
10
13
|
|
|
@@ -12,7 +15,28 @@ g8.init({ apiKey: process.env.G8_API_KEY! });
|
|
|
12
15
|
|
|
13
16
|
const DAY = 86400;
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
/** Sequence channel type for a mailbox from GET /mailboxes: by `source` first, as the graph8 app does. */
|
|
19
|
+
function mailboxChannelType(m: { source?: string | null; provider?: string | null }): "INBOXKIT" | "GMAIL" | "SMTP" {
|
|
20
|
+
if (m.source === "inboxkit") return "INBOXKIT";
|
|
21
|
+
if (m.source === "manual_microsoft" || m.provider === "microsoft")
|
|
22
|
+
throw new Error("Directly connected Microsoft mailboxes cannot send from sequences yet: pick another mailbox");
|
|
23
|
+
if (m.provider === "google_mailbox" || m.source === "manual_google") return "GMAIL";
|
|
24
|
+
return "SMTP";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function createDraftSequence(name: string, listId: number, sender: { id: number; email: string }) {
|
|
28
|
+
// The senders, one per step type. Rows are open-shaped (no response schema in the contract): read loosely.
|
|
29
|
+
const mailboxes = (await g8.api.gtmContext.listMailboxes()).data as Array<{ id: string; email: string; source?: string; provider?: string }>;
|
|
30
|
+
const mailbox = mailboxes.find((m) => Number(m.id) === sender.id);
|
|
31
|
+
if (!mailbox) throw new Error(`Mailbox ${sender.id} not found`);
|
|
32
|
+
const mailboxId = sender.id;
|
|
33
|
+
const phones = (await g8.api.sequences.listSequencerPhoneNumbers()).data as { phone_numbers?: Array<{ id: number; phone_number: string; inbound_type?: string }> };
|
|
34
|
+
const aiNumber = (phones.phone_numbers ?? []).find((p) => p.inbound_type === "ai");
|
|
35
|
+
const linkedin = (await g8.api.sequences.listLinkedinAccounts()).data as { linkedin_accounts?: Array<{ id: number; name?: string }>; provider?: string };
|
|
36
|
+
const account = linkedin.linkedin_accounts?.[0];
|
|
37
|
+
if (!aiNumber || !account || linkedin.provider !== "HEYREACH")
|
|
38
|
+
throw new Error("This recipe needs a Voice AI number and a HeyReach LinkedIn account (connect them in the graph8 app)");
|
|
39
|
+
|
|
16
40
|
const created = await g8.api.sequenceLifecycle.createSequence({
|
|
17
41
|
body: {
|
|
18
42
|
name,
|
|
@@ -44,7 +68,11 @@ export async function createDraftSequence(name: string, listId: number, mailbox:
|
|
|
44
68
|
step_data: { dial_dnc: false, phone_options: ["mobile", "direct"] },
|
|
45
69
|
},
|
|
46
70
|
],
|
|
47
|
-
channels: [
|
|
71
|
+
channels: [
|
|
72
|
+
{ channel_id: mailboxId, channel_value: mailbox.email, channel_type: mailboxChannelType(mailbox) },
|
|
73
|
+
{ channel_id: aiNumber.id, channel_value: aiNumber.phone_number, channel_type: "PHONE" },
|
|
74
|
+
{ channel_id: account.id, channel_value: account.name ?? String(account.id), channel_type: "LINKEDIN" },
|
|
75
|
+
],
|
|
48
76
|
},
|
|
49
77
|
});
|
|
50
78
|
return created.data?.id;
|
|
@@ -19,9 +19,26 @@ export async function launchAndEnroll(sequenceId: string, listId: number, contac
|
|
|
19
19
|
if ((verdict.data as { safe_to_send?: boolean } | undefined)?.safe_to_send === false)
|
|
20
20
|
throw new Error("Deliverability says not safe to send right now");
|
|
21
21
|
|
|
22
|
+
// The copy verdict is PER STEP (`steps[].rendered`), not on the preview itself. An error-severity issue makes
|
|
23
|
+
// /run answer 400; anything else is a warning to show the human. Every step type also needs a sender channel.
|
|
22
24
|
const preview = await g8.api.sequenceLifecycle.previewSequence({ path: { sequence_id: sequenceId } });
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
+
const steps = preview.data?.steps ?? [];
|
|
26
|
+
const issues = steps.flatMap((s) =>
|
|
27
|
+
(s.rendered?.content_issues ?? []).map((i) => ({ step: s.step_order, ...(i as { severity?: string; message?: string }) })),
|
|
28
|
+
);
|
|
29
|
+
const errors = issues.filter((i) => (i.severity ?? "").toLowerCase() === "error");
|
|
30
|
+
if (errors.length) throw new Error(`Fix the copy first: ${errors.map((e) => `step ${e.step}: ${e.message}`).join("; ")}`);
|
|
31
|
+
if (steps.some((s) => s.rendered?.will_send_as_shown === false))
|
|
32
|
+
console.warn("Some copy will not send exactly as previewed:", JSON.stringify(issues));
|
|
33
|
+
const types = new Set(steps.map((s) => s.step_type.toUpperCase()));
|
|
34
|
+
const senders = new Set((preview.data?.channels ?? []).map((c) => (c.channel_type ?? "").toUpperCase()));
|
|
35
|
+
const missing = [
|
|
36
|
+
types.has("EMAIL") && !["SMTP", "GMAIL", "INBOXKIT"].some((t) => senders.has(t)) && "a mailbox",
|
|
37
|
+
(types.has("PHONE") || types.has("MANUAL_DIALER")) && !senders.has("PHONE") && "a phone number",
|
|
38
|
+
types.has("HEYREACH") && !senders.has("LINKEDIN") && "a LinkedIn account",
|
|
39
|
+
types.has("NETRION") && !senders.has("LINKEDIN_NETRION") && "a LinkedIn account",
|
|
40
|
+
].filter(Boolean);
|
|
41
|
+
if (missing.length) throw new Error(`Attach ${missing.join(", ")} first (POST /sequences/{id}/channels): /run refuses it`);
|
|
25
42
|
|
|
26
43
|
const current = await g8.api.sequences.getSequence({ path: { sequence_id: sequenceId } });
|
|
27
44
|
const status = (current.data as { status?: string } | undefined)?.status;
|
|
@@ -5,6 +5,17 @@ Publish to social, watch your ads, make ad creatives and feed ad platforms with
|
|
|
5
5
|
|
|
6
6
|
Install: `npx @graph8/sdk add social-ads` (installs `foundation`, `crm` and `lists` first).
|
|
7
7
|
|
|
8
|
+
## Screens
|
|
9
|
+
|
|
10
|
+
Two finished pages, each a route you can copy. No props are required.
|
|
11
|
+
|
|
12
|
+
- `SocialScreen` (`components/social-screen.tsx`): **Posts** by status with per-status counts, publish/cancel/delete (confirmed), and a
|
|
13
|
+
**New post** side sheet holding the composer (AI drafts, schedule, publish). **Accounts** lists connected accounts and publishing
|
|
14
|
+
capacity, and links out to graph8 to connect LinkedIn (browser sign-in only).
|
|
15
|
+
- `AdsScreen` (`components/ads-screen.tsx`): **Campaigns** with a 30-day results strip, **Audiences** (with sync run history, a billable read that asks first; sync a list to Meta, LinkedIn, Google or X
|
|
16
|
+
and launch a campaign; launching moves you to Campaigns), **Creatives** (generate, edit, organize). Ad accounts are connected in graph8.
|
|
17
|
+
- Everything that sends data, spends or deletes keeps its confirmation. Slot: `toolbarActions`; `connectUrl` overrides the link-out.
|
|
18
|
+
|
|
8
19
|
## What you get
|
|
9
20
|
|
|
10
21
|
| Component | Use it for | Props |
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"trigger_audience_sync_audience_syncs__config_id__trigger_post",
|
|
46
46
|
"update_ad_creative_ad_creatives__creative_id__patch",
|
|
47
47
|
"update_audience_sync_audience_syncs__config_id__patch",
|
|
48
|
-
"update_social_post_social_posts__post_id__patch"
|
|
48
|
+
"update_social_post_social_posts__post_id__patch",
|
|
49
|
+
"get_ad_creative_ad_creatives__creative_id__get"
|
|
49
50
|
],
|
|
50
51
|
"components": [
|
|
51
52
|
{
|
|
@@ -139,6 +140,103 @@
|
|
|
139
140
|
"external",
|
|
140
141
|
"destructive"
|
|
141
142
|
]
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"name": "SocialScreen",
|
|
146
|
+
"file": "components/social-screen.tsx",
|
|
147
|
+
"purpose": "Finished social page: posts by status with a New post composer sheet, and connected accounts.",
|
|
148
|
+
"operations": [
|
|
149
|
+
"list_social_posts_social_posts_get"
|
|
150
|
+
],
|
|
151
|
+
"tiers": [
|
|
152
|
+
"read"
|
|
153
|
+
]
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"name": "AdsScreen",
|
|
157
|
+
"file": "components/ads-screen.tsx",
|
|
158
|
+
"purpose": "Finished ads page: campaigns with results, audience syncs and launch, and AI creatives.",
|
|
159
|
+
"operations": [
|
|
160
|
+
"get_per_campaign_metrics_ad_campaigns_metrics_get"
|
|
161
|
+
],
|
|
162
|
+
"tiers": [
|
|
163
|
+
"read"
|
|
164
|
+
]
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"name": "PostDetail",
|
|
168
|
+
"file": "components/post-detail.tsx",
|
|
169
|
+
"purpose": "Complete screen for one social post: content, edit, schedule, publish, cancel, delete.",
|
|
170
|
+
"operations": [
|
|
171
|
+
"get_social_post_social_posts__post_id__get",
|
|
172
|
+
"update_social_post_social_posts__post_id__patch",
|
|
173
|
+
"publish_social_post_social_posts__post_id__publish_post",
|
|
174
|
+
"cancel_social_post_social_posts__post_id__cancel_post",
|
|
175
|
+
"delete_social_post_social_posts__post_id__delete",
|
|
176
|
+
"schedule_social_post_social_posts__post_id__schedule_post"
|
|
177
|
+
],
|
|
178
|
+
"tiers": [
|
|
179
|
+
"read",
|
|
180
|
+
"write",
|
|
181
|
+
"external",
|
|
182
|
+
"destructive"
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"name": "CampaignDetail",
|
|
187
|
+
"file": "components/campaign-detail.tsx",
|
|
188
|
+
"purpose": "Complete screen for one ad campaign: fields, results, refresh, retry, stop and delete.",
|
|
189
|
+
"operations": [
|
|
190
|
+
"list_ad_campaigns_ad_campaigns_get",
|
|
191
|
+
"get_per_campaign_metrics_ad_campaigns_metrics_get",
|
|
192
|
+
"refresh_campaign_status_ad_campaigns__campaign_id__status_refresh_post",
|
|
193
|
+
"retry_campaign_launch_ad_campaigns__campaign_id__retry_post",
|
|
194
|
+
"delete_ad_campaign_ad_campaigns__campaign_id__delete"
|
|
195
|
+
],
|
|
196
|
+
"tiers": [
|
|
197
|
+
"read",
|
|
198
|
+
"external",
|
|
199
|
+
"destructive"
|
|
200
|
+
]
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"name": "CreativeDetail",
|
|
204
|
+
"file": "components/creative-detail.tsx",
|
|
205
|
+
"purpose": "Complete screen for one ad creative: image, copy, edit, new image, gallery, delete.",
|
|
206
|
+
"operations": [
|
|
207
|
+
"get_ad_creative_ad_creatives__creative_id__get",
|
|
208
|
+
"get_ad_creative_status_ad_creatives__creative_id__status_get",
|
|
209
|
+
"update_ad_creative_ad_creatives__creative_id__patch",
|
|
210
|
+
"move_ad_creative_to_gallery_ad_creatives__creative_id__move_to_gallery_post",
|
|
211
|
+
"regenerate_ad_creative_image_ad_creatives__creative_id__regenerate_image_post",
|
|
212
|
+
"delete_ad_creative_ad_creatives__creative_id__delete"
|
|
213
|
+
],
|
|
214
|
+
"tiers": [
|
|
215
|
+
"read",
|
|
216
|
+
"write",
|
|
217
|
+
"billable",
|
|
218
|
+
"destructive"
|
|
219
|
+
]
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
"name": "AudienceSyncDetail",
|
|
223
|
+
"file": "components/audience-sync-detail.tsx",
|
|
224
|
+
"purpose": "Complete screen for one audience sync: status, runs, errors, sync now, pause, launch campaign, delete.",
|
|
225
|
+
"operations": [
|
|
226
|
+
"list_sync_runs_audience_syncs__config_id__runs_get",
|
|
227
|
+
"list_audience_syncs_audience_syncs_get",
|
|
228
|
+
"list_sync_errors_audience_syncs__config_id__errors_get",
|
|
229
|
+
"launch_ad_campaign_audience_syncs__config_id__launch_post",
|
|
230
|
+
"trigger_audience_sync_audience_syncs__config_id__trigger_post",
|
|
231
|
+
"update_audience_sync_audience_syncs__config_id__patch",
|
|
232
|
+
"delete_audience_sync_audience_syncs__config_id__delete"
|
|
233
|
+
],
|
|
234
|
+
"tiers": [
|
|
235
|
+
"read",
|
|
236
|
+
"billable",
|
|
237
|
+
"external",
|
|
238
|
+
"destructive"
|
|
239
|
+
]
|
|
142
240
|
}
|
|
143
241
|
],
|
|
144
242
|
"hooks": [
|
|
@@ -251,6 +349,7 @@
|
|
|
251
349
|
}
|
|
252
350
|
],
|
|
253
351
|
"peerDependencies": {
|
|
254
|
-
"react": ">=18"
|
|
352
|
+
"react": ">=18",
|
|
353
|
+
"lucide-react": ">=0.400"
|
|
255
354
|
}
|
|
256
355
|
}
|
|
@@ -27,26 +27,30 @@ import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
|
27
27
|
import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
28
28
|
import { AD_PLATFORMS, formatDateOnly, formatMoney, launchOutcome, listOf, metricsFor } from "../lib/social-ads-types";
|
|
29
29
|
import type { AdCampaign, CampaignMetric } from "../lib/social-ads-types";
|
|
30
|
+
import { DEFAULT_BRAND, useBrand } from "@/g8/hooks/use-brand";
|
|
30
31
|
|
|
31
32
|
export interface AdCampaignListProps {
|
|
32
33
|
/** Change to reload, for example after AudienceSync launched a campaign. */
|
|
33
34
|
refreshKey?: number;
|
|
35
|
+
/** Called when a person opens a campaign (show a CampaignDetail). */
|
|
36
|
+
onSelectCampaign?: (campaign: AdCampaign) => void;
|
|
34
37
|
className?: string;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
type Pending = { kind: "refresh" | "retry" | "delete"; campaign: AdCampaign } | { kind: "refreshAll" };
|
|
38
41
|
const COLUMNS = 8;
|
|
39
42
|
|
|
40
|
-
function copyFor(p: Pending | null): { tier: ConfirmTier; title: string; text: string; label: string } {
|
|
43
|
+
function copyFor(p: Pending | null, platform: string = DEFAULT_BRAND.linkLabel): { tier: ConfirmTier; title: string; text: string; label: string } {
|
|
41
44
|
if (!p) return { tier: "external", title: "", text: "", label: "Confirm" };
|
|
42
45
|
if (p.kind === "refreshAll") return { tier: "external", title: "Refresh every campaign's status", text: "Asks each ad platform for the current review state of every campaign. Can take a while; campaigns that did refresh keep their new state if it times out.", label: "Refresh all" };
|
|
43
46
|
const name = p.campaign.name;
|
|
44
47
|
if (p.kind === "refresh") return { tier: "external", title: `Refresh "${name}"`, text: `Asks ${p.campaign.platform} for this campaign's current review state.`, label: "Refresh" };
|
|
45
48
|
if (p.kind === "retry") return { tier: "external", title: `Retry launching "${name}"`, text: `Sends the campaign to ${p.campaign.platform} again. It may start spending on that platform once approved.`, label: "Retry launch" };
|
|
46
|
-
return { tier: "destructive", title: `Stop and delete "${name}"`, text: `Stops the campaign on ${p.campaign.platform} first, then removes it from
|
|
49
|
+
return { tier: "destructive", title: `Stop and delete "${name}"`, text: `Stops the campaign on ${p.campaign.platform} first, then removes it from ${platform} together with its history. This cannot be undone. If ${p.campaign.platform} refuses to stop it, the campaign stays live and keeps spending.`, label: "Stop and delete" };
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
export function AdCampaignList({ refreshKey = 0, className }: AdCampaignListProps) {
|
|
52
|
+
export function AdCampaignList({ refreshKey = 0, onSelectCampaign, className }: AdCampaignListProps) {
|
|
53
|
+
const brand = useBrand();
|
|
50
54
|
const [platform, setPlatform] = useState("");
|
|
51
55
|
const [days, setDays] = useState(30);
|
|
52
56
|
const [pending, setPending] = useState<Pending | null>(null);
|
|
@@ -84,7 +88,7 @@ export function AdCampaignList({ refreshKey = 0, className }: AdCampaignListProp
|
|
|
84
88
|
}
|
|
85
89
|
};
|
|
86
90
|
|
|
87
|
-
const copy = copyFor(pending);
|
|
91
|
+
const copy = copyFor(pending, brand.linkLabel);
|
|
88
92
|
return (
|
|
89
93
|
<div className={`flex flex-col gap-3 ${className ?? ""}`}>
|
|
90
94
|
<div className="flex flex-wrap items-center gap-2">
|
|
@@ -110,7 +114,7 @@ export function AdCampaignList({ refreshKey = 0, className }: AdCampaignListProp
|
|
|
110
114
|
const mine = busy === String(c.id);
|
|
111
115
|
return (
|
|
112
116
|
<TableRow key={c.id} data-testid="campaign">
|
|
113
|
-
<TableCell
|
|
117
|
+
<TableCell>{onSelectCampaign ? <button type="button" className="font-medium hover:underline" onClick={() => onSelectCampaign(c)}>{c.name}</button> : <div className="font-medium">{c.name}</div>}{m ? <div className="text-xs text-muted-foreground">{formatMoney(m.spend, m.currency ?? c.currency)} spent</div> : null}</TableCell>
|
|
114
118
|
<TableCell>{c.platform}</TableCell>
|
|
115
119
|
<TableCell>{c.status}</TableCell>
|
|
116
120
|
<TableCell>{formatMoney(c.daily_budget, c.currency)}</TableCell>
|