@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,259 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SequenceDetail: one sequence, complete. Every field, the audience list, and every action: launch, pause,
|
|
5
|
+
* resume, duplicate, archive, enroll contacts. Tabs: Details, Steps (each step a record), Contacts (each
|
|
6
|
+
* enrollment a record, with the contact), Results (totals and per-step progress).
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* 1. getSequence GET /sequences/{id} (read; all fields, associated_list_id)
|
|
10
|
+
* 2. previewSequence GET /sequences/{id}/preview (read; checked before a launch: each step's
|
|
11
|
+
* `rendered.will_send_as_shown` / `content_issues`, and a sender channel for every step type.
|
|
12
|
+
* A missing sender, a missing audience list or an error-severity issue would make /run
|
|
13
|
+
* answer 400, so the launch is refused here with the reasons instead of offered)
|
|
14
|
+
* 3. runSequence / pauseSequence / resumeSequence POST /sequences/{id}/run|pause|resume
|
|
15
|
+
* (external: confirmed with the recipient count)
|
|
16
|
+
* 4. duplicateSequence POST /sequences/{id}/duplicate (write; the copy starts as a draft)
|
|
17
|
+
* 5. archiveSequence POST /sequences/{id}/archive (destructive: confirmed)
|
|
18
|
+
* Enroll contacts: EnrollSheet (ListPicker + ContactTable, or the whole list) then EnrollDialog (external: confirmed).
|
|
19
|
+
* Steps, Contacts and Results tabs compose SequenceStepsPanel, SequenceEnrollments and SequenceStats.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useState } from "react";
|
|
23
|
+
import type { ReactNode } from "react";
|
|
24
|
+
import { Copy, UserPlus } from "lucide-react";
|
|
25
|
+
import { Button } from "@graph8/sdk/ui";
|
|
26
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
27
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
28
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
29
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
30
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
31
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
32
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
33
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
34
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
35
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
36
|
+
import { ContactTable } from "@/g8/crm/components/contact-table";
|
|
37
|
+
import { ListPicker } from "@/g8/lists/components/list-picker";
|
|
38
|
+
import { fieldItems, launchCheck, listOf, objectOf, statusGroup } from "../lib/sequencer-utils";
|
|
39
|
+
import type { SequenceRow } from "../lib/sequencer-utils";
|
|
40
|
+
import { EnrollDialog } from "./enroll-dialog";
|
|
41
|
+
import { SequenceEnrollments } from "./sequence-enrollments";
|
|
42
|
+
import { SequenceStats } from "./sequence-stats";
|
|
43
|
+
import { SequenceStepsPanel } from "./sequence-steps-panel";
|
|
44
|
+
import { pluralWord } from "@/g8/lib/plural";
|
|
45
|
+
|
|
46
|
+
/** Whole-list enrolment reads at most this many pages of 100 contacts (5,000). */
|
|
47
|
+
const MAX_ENROLL_PAGES = 50;
|
|
48
|
+
const TONE = { draft: "neutral", live: "positive", paused: "warning", ended: "neutral", busy: "info" } as const;
|
|
49
|
+
|
|
50
|
+
export function EnrollSheet({ sequence, onClose, onEnrolled }: { sequence: SequenceRow & { associated_list_id?: number | null }; onClose: () => void; onEnrolled: () => void }) {
|
|
51
|
+
const [listId, setListId] = useState<number | null>(sequence.associated_list_id ?? null);
|
|
52
|
+
const [ids, setIds] = useState<number[]>([]);
|
|
53
|
+
const [confirm, setConfirm] = useState(false);
|
|
54
|
+
const [gathering, setGathering] = useState(false);
|
|
55
|
+
const [gatherError, setGatherError] = useState<unknown>(null);
|
|
56
|
+
// True when "Enroll the whole list" stopped at the cap, so the confirm says only part is included.
|
|
57
|
+
const [truncated, setTruncated] = useState(false);
|
|
58
|
+
const gatherAll = async () => {
|
|
59
|
+
if (listId == null) return;
|
|
60
|
+
setGathering(true);
|
|
61
|
+
setGatherError(null);
|
|
62
|
+
try {
|
|
63
|
+
const all: number[] = [];
|
|
64
|
+
let more = false;
|
|
65
|
+
for (let page = 1; page <= MAX_ENROLL_PAGES; page++) {
|
|
66
|
+
const rows = listOf<{ id: number }>(await g8Get("/contacts", { list_id: listId, page, limit: 100 }));
|
|
67
|
+
all.push(...rows.map((r) => r.id));
|
|
68
|
+
if (rows.length < 100) break;
|
|
69
|
+
if (page === MAX_ENROLL_PAGES) {
|
|
70
|
+
// A full last page: ask whether anything follows, so the confirm can say the list was cut.
|
|
71
|
+
more = listOf<{ id: number }>(await g8Get("/contacts", { list_id: listId, page: page + 1, limit: 100 })).length > 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
setTruncated(more);
|
|
75
|
+
setIds([...new Set(all)]);
|
|
76
|
+
setConfirm(true);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
setGatherError(err);
|
|
79
|
+
} finally {
|
|
80
|
+
setGathering(false);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
return (
|
|
84
|
+
<FormSheet open width="xl" onOpenChange={(o) => !o && onClose()} title={`Enroll contacts in ${sequence.name ?? sequence.id}`} description="Choose a list, tick the contacts, then confirm.">
|
|
85
|
+
<div className="space-y-4">
|
|
86
|
+
<ListPicker value={listId} allowCreate={false} onChange={(l) => { setListId(l.id); setIds([]); setTruncated(false); }} />
|
|
87
|
+
{listId != null ? (
|
|
88
|
+
<ContactTable key={listId} listId={listId} selectable allowDelete={false} pageSize={10} onSelectionChange={(sel) => { setIds(sel); setTruncated(false); }} />
|
|
89
|
+
) : null}
|
|
90
|
+
<Button disabled={ids.length === 0 || listId == null} onClick={() => setConfirm(true)}>
|
|
91
|
+
<UserPlus className="mr-1 h-4 w-4" aria-hidden /> {ids.length === 0 ? "Enroll contacts" : `Enroll ${ids.length.toLocaleString()} ${pluralWord(ids.length, "contact")}`}
|
|
92
|
+
</Button>
|
|
93
|
+
{listId != null ? (
|
|
94
|
+
<Button variant="outline" loading={gathering} onClick={() => void gatherAll()}>
|
|
95
|
+
Enroll the whole list
|
|
96
|
+
</Button>
|
|
97
|
+
) : null}
|
|
98
|
+
{gatherError ? <ApiError error={gatherError} title="Could not read the list" onRetry={() => void gatherAll()} /> : null}
|
|
99
|
+
</div>
|
|
100
|
+
<EnrollDialog
|
|
101
|
+
open={confirm}
|
|
102
|
+
sequenceId={sequence.id}
|
|
103
|
+
sequenceName={sequence.name ?? undefined}
|
|
104
|
+
contactIds={ids}
|
|
105
|
+
listId={listId ?? undefined}
|
|
106
|
+
note={truncated ? (
|
|
107
|
+
<p role="alert" className="text-sm">
|
|
108
|
+
This list has more than {(MAX_ENROLL_PAGES * 100).toLocaleString()} contacts. Only the first {(MAX_ENROLL_PAGES * 100).toLocaleString()} are enrolled. Enroll the rest from a smaller list.
|
|
109
|
+
</p>
|
|
110
|
+
) : undefined}
|
|
111
|
+
onClose={() => setConfirm(false)}
|
|
112
|
+
onEnrolled={() => { setConfirm(false); onEnrolled(); }}
|
|
113
|
+
/>
|
|
114
|
+
</FormSheet>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
type Action = "launch" | "pause" | "resume" | "archive";
|
|
120
|
+
const COPY: Record<Action, { title: string; description: string; label: string }> = {
|
|
121
|
+
launch: { title: "Launch", description: "Contacts in the audience start receiving the steps in order.", label: "Launch sequence" },
|
|
122
|
+
pause: { title: "Pause", description: "Sending stops. Contacts keep their place.", label: "Pause sequence" },
|
|
123
|
+
resume: { title: "Resume", description: "Contacts continue from where they stopped.", label: "Resume sequence" },
|
|
124
|
+
archive: { title: "Archive", description: "The sequence is hidden and stops sending. The API cannot restore an archived sequence.", label: "Archive sequence" },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export interface SequenceDetailProps {
|
|
128
|
+
sequenceId: string;
|
|
129
|
+
/** Controlled tab ("details" | "steps" | "contacts" | "results" | an extra tab id) for deep links. */
|
|
130
|
+
tab?: string;
|
|
131
|
+
onTabChange?: (tab: string) => void;
|
|
132
|
+
stepId?: string;
|
|
133
|
+
onStepChange?: (stepId: string | null) => void;
|
|
134
|
+
contactId?: string;
|
|
135
|
+
onContactChange?: (contactId: string | null) => void;
|
|
136
|
+
onClose?: () => void;
|
|
137
|
+
/** Full-page mode: show an "Open full page" (onExpand) or "Back to split view" (onCollapse) button. */
|
|
138
|
+
onExpand?: () => void;
|
|
139
|
+
onCollapse?: () => void;
|
|
140
|
+
/** Called after any change (status, steps, enrollment, duplicate) so a list can refresh. */
|
|
141
|
+
onChanged?: () => void;
|
|
142
|
+
/** Change it to make the detail reload (a list action changed this sequence). */
|
|
143
|
+
refreshKey?: number;
|
|
144
|
+
/** Open another sequence (the duplicate). */
|
|
145
|
+
onOpenSequence?: (sequenceId: string) => void;
|
|
146
|
+
renderActions?: (sequence: SequenceRow) => ReactNode;
|
|
147
|
+
extraTabs?: { id: string; label: string; render: (sequence: SequenceRow) => ReactNode }[];
|
|
148
|
+
className?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function SequenceDetail(props: SequenceDetailProps) {
|
|
152
|
+
const { sequenceId: id, onClose, onChanged, className } = props;
|
|
153
|
+
const [enrolling, setEnrolling] = useState(false);
|
|
154
|
+
const [pending, setPending] = useState<{ action: Action; warnings: string[] } | null>(null);
|
|
155
|
+
const [blocked, setBlocked] = useState<string[] | null>(null);
|
|
156
|
+
const [statsKey, setStatsKey] = useState(0);
|
|
157
|
+
const [version, setVersion] = useState(0);
|
|
158
|
+
const seq = useG8Query((signal) => g8Get(`/sequences/${id}`, undefined, { signal }), [id, version, props.refreshKey], { key: ["sequences", "detail", id] });
|
|
159
|
+
const row = { id, ...objectOf(seq.data) } as SequenceRow & { associated_list_id?: number | null };
|
|
160
|
+
const group = statusGroup(row.status);
|
|
161
|
+
const changed = () => { setVersion((v) => v + 1); onChanged?.(); };
|
|
162
|
+
|
|
163
|
+
const prepare = useG8Mutation(async (action: Action) => {
|
|
164
|
+
let warnings: string[] = [];
|
|
165
|
+
setBlocked(null);
|
|
166
|
+
if (action === "launch") {
|
|
167
|
+
const check = launchCheck(objectOf(await g8Get(`/sequences/${id}/preview`)));
|
|
168
|
+
if (seq.data && row.associated_list_id == null) check.blockers.unshift("It has no audience list. Create the sequence again with one.");
|
|
169
|
+
if (check.blockers.length > 0) {
|
|
170
|
+
setBlocked(check.blockers);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
warnings = check.warnings;
|
|
174
|
+
}
|
|
175
|
+
setPending({ action, warnings });
|
|
176
|
+
});
|
|
177
|
+
const run = useG8Mutation(async (action: Action) => {
|
|
178
|
+
await g8Post(`/sequences/${id}/${action === "launch" ? "run" : action}`, action === "archive" ? {} : undefined);
|
|
179
|
+
setPending(null);
|
|
180
|
+
changed();
|
|
181
|
+
}, { invalidates: [["sequences", "list"], ["sequences", "summary"]], onError: () => setPending(null) });
|
|
182
|
+
const duplicate = useG8Mutation(async () => {
|
|
183
|
+
const res = objectOf(await g8Post(`/sequences/${id}/duplicate`, {}));
|
|
184
|
+
changed();
|
|
185
|
+
if (res.id != null) props.onOpenSequence?.(String(res.id));
|
|
186
|
+
}, { invalidates: [["sequences", "list"], ["sequences", "summary"]] });
|
|
187
|
+
|
|
188
|
+
if (seq.error) return <ApiError error={seq.error} title="Could not load the sequence" onRetry={() => void seq.refetch()} className={className} />;
|
|
189
|
+
const p = pending;
|
|
190
|
+
return (
|
|
191
|
+
<DetailPanel
|
|
192
|
+
className={className}
|
|
193
|
+
title={row.name ?? `Sequence ${id}`}
|
|
194
|
+
subtitle={row.step_count != null ? `${row.step_count} steps` : undefined}
|
|
195
|
+
badges={row.status ? <StatusPill tone={TONE[group]} dot>{row.status}</StatusPill> : null}
|
|
196
|
+
actions={
|
|
197
|
+
<>
|
|
198
|
+
{group === "draft" ? <Button size="sm" loading={prepare.loading} onClick={() => void prepare.mutate("launch")}>Launch</Button> : null}
|
|
199
|
+
{group === "live" ? <Button size="sm" variant="outline" onClick={() => void prepare.mutate("pause")}>Pause</Button> : null}
|
|
200
|
+
{group === "paused" ? <Button size="sm" variant="outline" onClick={() => void prepare.mutate("resume")}>Resume</Button> : null}
|
|
201
|
+
<Button size="sm" variant="outline" onClick={() => setEnrolling(true)}><UserPlus className="mr-1 h-4 w-4" aria-hidden /> Enroll contacts</Button>
|
|
202
|
+
<Button size="sm" variant="ghost" aria-label="Duplicate sequence" loading={duplicate.loading} onClick={() => void duplicate.mutate(undefined)}><Copy className="h-4 w-4" aria-hidden /></Button>
|
|
203
|
+
{group !== "ended" ? <Button size="sm" variant="ghost" onClick={() => void prepare.mutate("archive")}>Archive sequence</Button> : null}
|
|
204
|
+
{props.renderActions?.(row)}
|
|
205
|
+
{enrolling ? <EnrollSheet sequence={row} onClose={() => setEnrolling(false)} onEnrolled={() => { setEnrolling(false); setStatsKey((k) => k + 1); changed(); }} /> : null}
|
|
206
|
+
{p ? (
|
|
207
|
+
<ConfirmSpendDialog
|
|
208
|
+
open
|
|
209
|
+
tier={p.action === "archive" ? "destructive" : "external"}
|
|
210
|
+
title={`${COPY[p.action].title} "${row.name ?? id}"`}
|
|
211
|
+
description={
|
|
212
|
+
<>
|
|
213
|
+
<p>{COPY[p.action].description}</p>
|
|
214
|
+
{p.warnings.length > 0 ? <ul className="mt-2 list-disc pl-5 text-amber-800">{p.warnings.map((w) => <li key={w}>{w}</li>)}</ul> : null}
|
|
215
|
+
</>
|
|
216
|
+
}
|
|
217
|
+
recipients={p.action === "archive" ? undefined : (row.contact_count ?? undefined)}
|
|
218
|
+
affected={p.action === "archive" ? 1 : undefined}
|
|
219
|
+
confirmLabel={COPY[p.action].label}
|
|
220
|
+
confirming={run.loading}
|
|
221
|
+
onCancel={() => setPending(null)}
|
|
222
|
+
onConfirm={() => void run.mutate(p.action)}
|
|
223
|
+
/>
|
|
224
|
+
) : null}
|
|
225
|
+
</>
|
|
226
|
+
}
|
|
227
|
+
onClose={onClose}
|
|
228
|
+
onExpand={props.onExpand}
|
|
229
|
+
onCollapse={props.onCollapse}
|
|
230
|
+
activeTab={props.tab}
|
|
231
|
+
onTabChange={props.onTabChange}
|
|
232
|
+
tabs={[
|
|
233
|
+
{
|
|
234
|
+
id: "details",
|
|
235
|
+
label: "Details",
|
|
236
|
+
content: (
|
|
237
|
+
<div className="space-y-4">
|
|
238
|
+
{[prepare.error, run.error, duplicate.error].map((e, i) => (e ? <ApiError key={i} error={e} /> : null))}
|
|
239
|
+
{blocked ? (
|
|
240
|
+
<div role="alert" className="rounded-md border border-border p-3 text-sm">
|
|
241
|
+
<p className="font-medium">This sequence cannot launch yet</p>
|
|
242
|
+
<ul className="mt-1 list-disc pl-5 text-muted-foreground">{blocked.map((r) => <li key={r}>{r}</li>)}</ul>
|
|
243
|
+
</div>
|
|
244
|
+
) : null}
|
|
245
|
+
<KeyValueList columns={2} items={fieldItems(objectOf(seq.data))} />
|
|
246
|
+
<SectionCard title="Audience" description="The list this sequence sends to.">
|
|
247
|
+
<p className="text-sm">{row.associated_list_id != null ? `List ${row.associated_list_id}` : "No audience list yet."}</p>
|
|
248
|
+
</SectionCard>
|
|
249
|
+
</div>
|
|
250
|
+
),
|
|
251
|
+
},
|
|
252
|
+
{ id: "steps", label: "Steps", count: row.step_count ?? undefined, content: <SequenceStepsPanel sequenceId={id} stepId={props.stepId} onStepChange={props.onStepChange} onChanged={changed} /> },
|
|
253
|
+
{ id: "contacts", label: "Contacts", count: row.contact_count ?? undefined, content: <SequenceEnrollments key={statsKey} sequenceId={id} contactId={props.contactId} onContactChange={props.onContactChange} /> },
|
|
254
|
+
{ id: "results", label: "Results", content: <SequenceStats key={`${id}:${statsKey}`} sequenceId={id} refreshSeconds={group === "live" ? 60 : undefined} /> },
|
|
255
|
+
...(props.extraTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(row) })),
|
|
256
|
+
]}
|
|
257
|
+
/>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SequenceEnrollments: the contacts enrolled in one sequence, each one a record. A filterable list; open one
|
|
5
|
+
* (deep link via `contactId`) for its state in this sequence, every field, the contact itself and the actions.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. listSequenceContacts GET /sequences/{id}/contacts?state&page&limit (read)
|
|
9
|
+
* 2. pauseSequenceContact POST /sequences/{id}/contacts/{contact_id}/pause (write: direct)
|
|
10
|
+
* 3. resumeSequenceContact POST /sequences/{id}/contacts/{contact_id}/resume (external: confirmed)
|
|
11
|
+
* The embedded ContactDetail (crm block) owns the contact's own reads and edits.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
16
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
17
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
18
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
19
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
20
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
21
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
22
|
+
import type { Tone } from "@/g8/components/screen/status-pill";
|
|
23
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
24
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
25
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
26
|
+
import { ContactDetail } from "@/g8/crm/components/contact-detail";
|
|
27
|
+
import { fieldItems, listOf } from "../lib/sequencer-utils";
|
|
28
|
+
|
|
29
|
+
export interface SequenceEnrollmentsProps {
|
|
30
|
+
sequenceId: string;
|
|
31
|
+
/** The open enrollment, by contact id. Controlled so a screen can deep-link it. */
|
|
32
|
+
contactId?: string;
|
|
33
|
+
onContactChange?: (contactId: string | null) => void;
|
|
34
|
+
pageSize?: number;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Enrollment { id?: string; contact_id?: number; state?: string; current_step_order?: number | null; name?: string; email?: string; [k: string]: unknown }
|
|
39
|
+
|
|
40
|
+
const STATES = ["", "waiting", "paused", "completed", "replied", "bounced", "skipped"];
|
|
41
|
+
const TONE: Record<string, Tone> = { waiting: "info", paused: "warning", completed: "positive", replied: "positive", bounced: "negative", skipped: "neutral" };
|
|
42
|
+
const nameOf = (e: Enrollment) => e.name || e.email || (e.contact_id != null ? `Contact ${e.contact_id}` : "Contact");
|
|
43
|
+
|
|
44
|
+
export function SequenceEnrollments({ sequenceId, contactId, onContactChange, pageSize = 25, className }: SequenceEnrollmentsProps) {
|
|
45
|
+
const [state, setState] = useState("");
|
|
46
|
+
const [page, setPage] = useState(1);
|
|
47
|
+
const [resume, setResume] = useState<Enrollment | null>(null);
|
|
48
|
+
const list = useG8Query(
|
|
49
|
+
(signal) => g8Get(`/sequences/${sequenceId}/contacts`, { page, limit: pageSize, state: state || undefined }, { signal }),
|
|
50
|
+
[sequenceId, page, pageSize, state],
|
|
51
|
+
{ key: ["sequences", "contacts", sequenceId, { page, pageSize, state }] },
|
|
52
|
+
);
|
|
53
|
+
const pause = useG8Mutation(async (e: Enrollment) => { await g8Post(`/sequences/${sequenceId}/contacts/${e.contact_id}/pause`); await list.refetch(); });
|
|
54
|
+
const resumeRun = useG8Mutation(async (e: Enrollment) => { await g8Post(`/sequences/${sequenceId}/contacts/${e.contact_id}/resume`); setResume(null); await list.refetch(); }, { onError: () => setResume(null) });
|
|
55
|
+
|
|
56
|
+
const rows = listOf<Enrollment>(list.data);
|
|
57
|
+
const open = rows.find((r) => String(r.contact_id) === contactId);
|
|
58
|
+
const actions = (e: Enrollment) =>
|
|
59
|
+
e.state === "paused" ? (
|
|
60
|
+
<Button size="sm" variant="outline" onClick={() => setResume(e)}>Resume contact</Button>
|
|
61
|
+
) : e.state === "waiting" ? (
|
|
62
|
+
<Button size="sm" variant="outline" loading={pause.loading} onClick={() => void pause.mutate(e)}>Pause contact</Button>
|
|
63
|
+
) : null;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={`space-y-4 ${className ?? ""}`}>
|
|
67
|
+
<div className="flex items-center gap-2">
|
|
68
|
+
<select aria-label="Enrollment state" className="h-9 rounded-md border bg-background px-2 text-sm" value={state} onChange={(e) => { setState(e.target.value); setPage(1); onContactChange?.(null); }}>
|
|
69
|
+
{STATES.map((s) => <option key={s} value={s}>{s || "All states"}</option>)}
|
|
70
|
+
</select>
|
|
71
|
+
</div>
|
|
72
|
+
{list.error ? <ApiError error={list.error} onRetry={() => void list.refetch()} /> : null}
|
|
73
|
+
{[pause.error, resumeRun.error].map((e, i) => (e ? <ApiError key={i} error={e} /> : null))}
|
|
74
|
+
{list.loading && !list.data ? <Skeleton className="h-32 w-full" /> : null}
|
|
75
|
+
{!list.loading && !list.error && rows.length === 0 ? <EmptyState title="No enrolled contacts" description={state ? "No contact is in this state." : "Enroll contacts to start the sequence."} /> : null}
|
|
76
|
+
{rows.length > 0 ? (
|
|
77
|
+
<ul aria-label="Enrolled contacts" className="divide-y rounded-md border">
|
|
78
|
+
{rows.map((e) => (
|
|
79
|
+
<li key={e.id ?? e.contact_id} className={`flex items-center gap-3 px-3 py-2 text-sm ${String(e.contact_id) === contactId ? "bg-muted" : ""}`}>
|
|
80
|
+
<button type="button" className="min-w-0 flex-1 truncate text-left font-medium hover:underline" aria-label={`Open ${nameOf(e)}`} onClick={() => onContactChange?.(String(e.contact_id))}>{nameOf(e)}</button>
|
|
81
|
+
{e.current_step_order != null ? <span className="text-xs text-muted-foreground">step {e.current_step_order}</span> : null}
|
|
82
|
+
<StatusPill tone={TONE[e.state ?? ""] ?? "neutral"} dot>{e.state ?? "unknown"}</StatusPill>
|
|
83
|
+
</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
) : null}
|
|
87
|
+
<div className="flex justify-end gap-2">
|
|
88
|
+
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</Button>
|
|
89
|
+
<Button size="sm" variant="outline" disabled={rows.length < pageSize} onClick={() => setPage((p) => p + 1)}>Next</Button>
|
|
90
|
+
</div>
|
|
91
|
+
{open ? (
|
|
92
|
+
<SectionCard title={`${nameOf(open)} in this sequence`} actions={<>{actions(open)}<Button size="sm" variant="ghost" onClick={() => onContactChange?.(null)}>Close</Button></>}>
|
|
93
|
+
<KeyValueList columns={2} items={fieldItems(open as Record<string, unknown>)} />
|
|
94
|
+
{open.contact_id != null ? (
|
|
95
|
+
<SectionCard title="Contact" description="The full contact record.">
|
|
96
|
+
<ContactDetail contactId={open.contact_id} />
|
|
97
|
+
</SectionCard>
|
|
98
|
+
) : null}
|
|
99
|
+
</SectionCard>
|
|
100
|
+
) : null}
|
|
101
|
+
<ConfirmSpendDialog
|
|
102
|
+
open={resume !== null}
|
|
103
|
+
tier="external"
|
|
104
|
+
title={`Resume ${resume ? nameOf(resume) : "this contact"}`}
|
|
105
|
+
description="They continue from where they stopped and may receive the next step."
|
|
106
|
+
recipients={1}
|
|
107
|
+
confirmLabel="Resume contact"
|
|
108
|
+
confirming={resumeRun.loading}
|
|
109
|
+
onCancel={() => setResume(null)}
|
|
110
|
+
onConfirm={() => resume && void resumeRun.mutate(resume)}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
*
|
|
7
7
|
* SDK operations, in order:
|
|
8
8
|
* 1. listSequences GET /sequences?status=&page=&limit= (read)
|
|
9
|
-
* 2. previewSequence GET /sequences/{id}/preview (read; checked before a launch: does
|
|
10
|
-
* send as shown
|
|
9
|
+
* 2. previewSequence GET /sequences/{id}/preview (read; checked before a launch: does each step's
|
|
10
|
+
* copy send as shown (`steps[].rendered.will_send_as_shown` / `content_issues`), and does every
|
|
11
|
+
* step type have a sender channel? A missing sender or an error-severity issue makes /run
|
|
12
|
+
* answer 400, so the launch is refused here with the reasons instead of offered)
|
|
11
13
|
* 3. runSequence POST /sequences/{id}/run (external: launches a draft; confirmed with the
|
|
12
14
|
* recipient count)
|
|
13
15
|
* pauseSequence POST /sequences/{id}/pause (external: confirmed)
|
|
@@ -27,7 +29,7 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
|
27
29
|
import { ApiError } from "@/g8/components/api-error";
|
|
28
30
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
29
31
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
30
|
-
import { listOf, objectOf, STATUS_CLASS, statusGroup } from "../lib/sequencer-utils";
|
|
32
|
+
import { launchCheck, listOf, objectOf, STATUS_CLASS, statusGroup } from "../lib/sequencer-utils";
|
|
31
33
|
import type { SequenceRow } from "../lib/sequencer-utils";
|
|
32
34
|
|
|
33
35
|
export interface SequenceListProps {
|
|
@@ -36,6 +38,8 @@ export interface SequenceListProps {
|
|
|
36
38
|
pageSize?: number;
|
|
37
39
|
onOpen?: (sequence: SequenceRow) => void;
|
|
38
40
|
onCreate?: () => void;
|
|
41
|
+
/** Called after a launch, pause, resume, duplicate or archive succeeded (a screen refreshes its header). */
|
|
42
|
+
onChanged?: () => void;
|
|
39
43
|
className?: string;
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -58,20 +62,26 @@ const COPY: Record<Action, { title: string; description: string; label: string }
|
|
|
58
62
|
},
|
|
59
63
|
};
|
|
60
64
|
|
|
61
|
-
export function SequenceList({ status, pageSize = 25, onOpen, onCreate, className }: SequenceListProps) {
|
|
65
|
+
export function SequenceList({ status, pageSize = 25, onOpen, onCreate, onChanged, className }: SequenceListProps) {
|
|
62
66
|
const [page, setPage] = useState(1);
|
|
63
67
|
const [pending, setPending] = useState<Pending | null>(null);
|
|
64
68
|
const [preparing, setPreparing] = useState<string | null>(null);
|
|
65
|
-
const
|
|
69
|
+
const [blocked, setBlocked] = useState<{ sequence: SequenceRow; reasons: string[] } | null>(null);
|
|
70
|
+
const list = useG8Query((signal) => g8Get("/sequences", { page, limit: pageSize, status }, { signal }), [page, pageSize, status], { key: ["sequences", "list", { page, pageSize, status }] });
|
|
66
71
|
|
|
67
72
|
const prepare = useG8Mutation(async (v: { action: Action; sequence: SequenceRow }) => {
|
|
68
73
|
let warnings: string[] = [];
|
|
69
74
|
setPreparing(v.sequence.id);
|
|
75
|
+
setBlocked(null);
|
|
70
76
|
try {
|
|
71
77
|
if (v.action === "launch") {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
const check = launchCheck(objectOf(await g8Get(`/sequences/${v.sequence.id}/preview`)));
|
|
79
|
+
if (v.sequence.associated_list_id === null) check.blockers.unshift("It has no audience list. Create the sequence again with one.");
|
|
80
|
+
if (check.blockers.length > 0) {
|
|
81
|
+
setBlocked({ sequence: v.sequence, reasons: check.blockers });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
warnings = check.warnings;
|
|
75
85
|
}
|
|
76
86
|
setPending({ ...v, warnings });
|
|
77
87
|
} finally {
|
|
@@ -81,10 +91,12 @@ export function SequenceList({ status, pageSize = 25, onOpen, onCreate, classNam
|
|
|
81
91
|
const run = useG8Mutation(async (p: Pending) => {
|
|
82
92
|
await g8Post(`/sequences/${p.sequence.id}/${PATH[p.action]}`, p.action === "archive" ? {} : undefined);
|
|
83
93
|
await list.refetch();
|
|
94
|
+
onChanged?.();
|
|
84
95
|
});
|
|
85
96
|
const duplicate = useG8Mutation(async (s: SequenceRow) => {
|
|
86
97
|
await g8Post(`/sequences/${s.id}/duplicate`, {});
|
|
87
98
|
await list.refetch();
|
|
99
|
+
onChanged?.();
|
|
88
100
|
});
|
|
89
101
|
|
|
90
102
|
const rows = listOf<SequenceRow>(list.data);
|
|
@@ -105,6 +117,19 @@ export function SequenceList({ status, pageSize = 25, onOpen, onCreate, classNam
|
|
|
105
117
|
{[prepare.error, run.error, duplicate.error].filter(Boolean).map((e, i) => (
|
|
106
118
|
<ApiError key={i} error={e} />
|
|
107
119
|
))}
|
|
120
|
+
{blocked ? (
|
|
121
|
+
<div role="alert" className="rounded-md border border-border p-3 text-sm">
|
|
122
|
+
<p className="font-medium">"{blocked.sequence.name ?? blocked.sequence.id}" cannot launch yet</p>
|
|
123
|
+
<ul className="mt-1 list-disc pl-5 text-muted-foreground">
|
|
124
|
+
{blocked.reasons.map((r) => (
|
|
125
|
+
<li key={r}>{r}</li>
|
|
126
|
+
))}
|
|
127
|
+
</ul>
|
|
128
|
+
<Button size="sm" variant="ghost" className="mt-1" onClick={() => setBlocked(null)}>
|
|
129
|
+
Dismiss
|
|
130
|
+
</Button>
|
|
131
|
+
</div>
|
|
132
|
+
) : null}
|
|
108
133
|
<Table>
|
|
109
134
|
<TableHeader>
|
|
110
135
|
<TableRow>
|
|
@@ -67,11 +67,12 @@ export function SequenceStats({ sequenceId, pageSize = 25, refreshSeconds, class
|
|
|
67
67
|
setPage(1);
|
|
68
68
|
setResume(null);
|
|
69
69
|
}, [sequenceId]);
|
|
70
|
-
const stats = useG8Query((signal) => g8Get(`/sequences/${sequenceId}/stats`, undefined, { signal }), [sequenceId]);
|
|
71
|
-
const analytics = useG8Query((signal) => g8Get(`/sequences/${sequenceId}/analytics`, undefined, { signal }), [sequenceId]);
|
|
70
|
+
const stats = useG8Query((signal) => g8Get(`/sequences/${sequenceId}/stats`, undefined, { signal }), [sequenceId], { key: ["sequences", "stats", sequenceId] });
|
|
71
|
+
const analytics = useG8Query((signal) => g8Get(`/sequences/${sequenceId}/analytics`, undefined, { signal }), [sequenceId], { key: ["sequences", "analytics", sequenceId] });
|
|
72
72
|
const contacts = useG8Query(
|
|
73
73
|
(signal) => g8Get(`/sequences/${sequenceId}/contacts`, { page, limit: pageSize, state: state || undefined }, { signal }),
|
|
74
74
|
[sequenceId, page, pageSize, state],
|
|
75
|
+
{ key: ["sequences", "contacts", sequenceId, { page, pageSize, state }] },
|
|
75
76
|
);
|
|
76
77
|
|
|
77
78
|
useEffect(() => {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SequenceStepsPanel: the steps of one sequence as records. A list with each step's channel, wait and
|
|
5
|
+
* progress; open one (deep link via `stepId`) for every field, how many contacts are at each state on it, and
|
|
6
|
+
* the editor (change the copy, delete it, add steps).
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* 1. listSequenceSteps GET /sequences/{id}/steps (read; the steps in order)
|
|
10
|
+
* 2. getSequenceStats GET /sequences/{id}/stats (read; per-step contact counts)
|
|
11
|
+
* Editing opens SequenceBuilder, which owns addSequenceSteps (write), updateSequenceStep (external:
|
|
12
|
+
* confirmed) and deleteSequenceStep (destructive: confirmed).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState } from "react";
|
|
16
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
17
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
18
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
19
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
20
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
21
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
22
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
23
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
24
|
+
import { fieldItems, fromApiStep, KIND_LABEL, listOf, objectOf, stepSummary } from "../lib/sequencer-utils";
|
|
25
|
+
import type { ApiStep } from "../lib/sequencer-utils";
|
|
26
|
+
import { SequenceBuilder } from "./sequence-builder";
|
|
27
|
+
|
|
28
|
+
export interface SequenceStepsPanelProps {
|
|
29
|
+
sequenceId: string;
|
|
30
|
+
/** The open step (the API step id). Controlled so a screen can deep-link it. */
|
|
31
|
+
stepId?: string;
|
|
32
|
+
onStepChange?: (stepId: string | null) => void;
|
|
33
|
+
/** Called after any edit so the sequence header can refresh. */
|
|
34
|
+
onChanged?: () => void;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface StepStat { step_id?: string; step_order: number; total_contacts?: number; counts?: Record<string, number> }
|
|
39
|
+
|
|
40
|
+
export function SequenceStepsPanel({ sequenceId, stepId, onStepChange, onChanged, className }: SequenceStepsPanelProps) {
|
|
41
|
+
const [editing, setEditing] = useState(false);
|
|
42
|
+
const [version, setVersion] = useState(0);
|
|
43
|
+
const steps = useG8Query((signal) => g8Get(`/sequences/${sequenceId}/steps`, undefined, { signal }), [sequenceId, version], { key: ["sequences", "steps", sequenceId] });
|
|
44
|
+
const stats = useG8Query((signal) => g8Get(`/sequences/${sequenceId}/stats`, undefined, { signal }), [sequenceId, version], { key: ["sequences", "stats", sequenceId] });
|
|
45
|
+
const rows = listOf<ApiStep>(steps.data).sort((a, b) => a.step_order - b.step_order);
|
|
46
|
+
const statRows = (objectOf(stats.data).step_stats as StepStat[] | undefined) ?? [];
|
|
47
|
+
const statFor = (s: ApiStep) => statRows.find((x) => (x.step_id && x.step_id === s.id) || x.step_order === s.step_order);
|
|
48
|
+
const open = rows.find((s) => s.id === stepId);
|
|
49
|
+
const refresh = () => { setVersion((v) => v + 1); onChanged?.(); };
|
|
50
|
+
|
|
51
|
+
if (steps.error) return <ApiError error={steps.error} title="Could not load the steps" onRetry={() => void steps.refetch()} className={className} />;
|
|
52
|
+
if (steps.loading && !steps.data) return <Skeleton className={`h-40 w-full ${className ?? ""}`} />;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={`space-y-4 ${className ?? ""}`}>
|
|
56
|
+
<div className="flex items-center justify-between">
|
|
57
|
+
<h3 className="font-medium">Steps</h3>
|
|
58
|
+
<Button size="sm" variant="outline" onClick={() => setEditing((e) => !e)}>{editing ? "Done editing" : "Edit or add steps"}</Button>
|
|
59
|
+
</div>
|
|
60
|
+
{editing ? (
|
|
61
|
+
<SequenceBuilder sequenceId={sequenceId} onSaved={refresh} />
|
|
62
|
+
) : rows.length === 0 ? (
|
|
63
|
+
<EmptyState title="No steps yet" description="Add the first email, LinkedIn touch or call." action={<Button onClick={() => setEditing(true)}>Add a step</Button>} />
|
|
64
|
+
) : (
|
|
65
|
+
<ol aria-label="Steps" className="divide-y rounded-md border">
|
|
66
|
+
{rows.map((s) => {
|
|
67
|
+
const draft = fromApiStep(s);
|
|
68
|
+
const st = statFor(s);
|
|
69
|
+
return (
|
|
70
|
+
<li key={s.id ?? s.step_order} className={`flex items-center gap-3 px-3 py-2 text-sm ${s.id && s.id === stepId ? "bg-muted" : ""}`}>
|
|
71
|
+
<span className="w-6 text-muted-foreground tabular-nums">{s.step_order}</span>
|
|
72
|
+
<StatusPill tone="neutral">{KIND_LABEL[draft.kind]}</StatusPill>
|
|
73
|
+
<button type="button" className="min-w-0 flex-1 truncate text-left font-medium hover:underline" aria-label={`Open step ${s.step_order}`} onClick={() => onStepChange?.(s.id ?? null)}>
|
|
74
|
+
{stepSummary(draft)}
|
|
75
|
+
</button>
|
|
76
|
+
<span className="text-xs text-muted-foreground">after {draft.wait_days} d</span>
|
|
77
|
+
{st?.total_contacts != null ? <span className="text-xs tabular-nums">{st.total_contacts} contacts</span> : null}
|
|
78
|
+
</li>
|
|
79
|
+
);
|
|
80
|
+
})}
|
|
81
|
+
</ol>
|
|
82
|
+
)}
|
|
83
|
+
{!editing && open ? (
|
|
84
|
+
<SectionCard
|
|
85
|
+
title={`Step ${open.step_order}: ${stepSummary(fromApiStep(open))}`}
|
|
86
|
+
actions={<Button size="sm" variant="ghost" onClick={() => onStepChange?.(null)}>Close step</Button>}
|
|
87
|
+
>
|
|
88
|
+
<KeyValueList
|
|
89
|
+
columns={2}
|
|
90
|
+
items={[
|
|
91
|
+
...fieldItems({ step_type: open.step_type, input_type: open.input_type, wait_days: fromApiStep(open).wait_days }),
|
|
92
|
+
...fieldItems((open.step_data ?? {}) as Record<string, unknown>),
|
|
93
|
+
]}
|
|
94
|
+
/>
|
|
95
|
+
{statFor(open)?.counts ? (
|
|
96
|
+
<KeyValueList columns={2} items={Object.entries(statFor(open)!.counts!).map(([k, v]) => ({ label: `Contacts ${k.replace(/_/g, " ")}`, value: String(v) }))} />
|
|
97
|
+
) : null}
|
|
98
|
+
<Button size="sm" onClick={() => setEditing(true)}>Edit this sequence's steps</Button>
|
|
99
|
+
</SectionCard>
|
|
100
|
+
) : null}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|