@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,267 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CampaignsScreen: the finished "Campaigns" page (the AI campaign builder).
|
|
5
|
+
*
|
|
6
|
+
* Composes: CampaignList (browse, open, delete), CampaignWizard (idea -> create -> audience -> steps -> launch),
|
|
7
|
+
* CampaignMetrics, CampaignAssets (documents), CampaignAudienceStep, CampaignSequenceStep, CampaignLaunchStep,
|
|
8
|
+
* IcpEditor (company profile + ICPs) and PersonaEditor.
|
|
9
|
+
*
|
|
10
|
+
* Every record has a complete detail, beside the list or as a full page (`?view=full`): campaign, document
|
|
11
|
+
* (`?doc=`), ICP (IcpDetail) and persona (PersonaDetail).
|
|
12
|
+
*
|
|
13
|
+
* User flows:
|
|
14
|
+
* - Campaigns tab: browse campaigns, open one (deep link `?id=`), delete one (confirmed).
|
|
15
|
+
* - "New campaign" opens the wizard in a sheet: AI ideas (confirmed credit estimate), create, audience, steps
|
|
16
|
+
* and copy, launch (confirmed).
|
|
17
|
+
* - An open campaign has tabs: Details (read and edit name/goal/brief, confirmed), Metrics (7/30/90/365 days),
|
|
18
|
+
* Documents (read, edit, rewrite with AI, add, delete), Audience, Sequence and Launch.
|
|
19
|
+
* - Company profile tab (profile and ICPs) and Personas tab: the context the AI writes from.
|
|
20
|
+
*
|
|
21
|
+
* SDK operations of its own:
|
|
22
|
+
* 1. getCampaign GET /campaigns/{campaign_id} (read; header and Details of the open campaign)
|
|
23
|
+
* 2. updateCampaign PATCH /campaigns/{campaign_id} (external: confirmed; name, goal and brief only)
|
|
24
|
+
* Billable, external and destructive calls in the composed parts open ConfirmSpendDialog there.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useState } from "react";
|
|
28
|
+
import type { ReactNode } from "react";
|
|
29
|
+
import { Megaphone, Plus } from "lucide-react";
|
|
30
|
+
import { Button, Input, Skeleton, Textarea } from "@graph8/sdk/ui";
|
|
31
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
32
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
33
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
34
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
35
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
36
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
37
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
38
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
39
|
+
import type { Tone } from "@/g8/components/screen/status-pill";
|
|
40
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
41
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
42
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
43
|
+
import { g8Get, g8Patch } from "@/g8/lib/g8-client";
|
|
44
|
+
import { audienceOf, fieldItems, objectOf } from "../lib/campaigns-utils";
|
|
45
|
+
import type { CampaignRow } from "../lib/campaigns-utils";
|
|
46
|
+
import { CampaignAssets } from "./campaign-assets";
|
|
47
|
+
import { CampaignAudienceStep } from "./campaign-audience-step";
|
|
48
|
+
import { CampaignLaunchStep } from "./campaign-launch-step";
|
|
49
|
+
import { CampaignList } from "./campaign-list";
|
|
50
|
+
import { CampaignMetrics } from "./campaign-metrics";
|
|
51
|
+
import { CampaignSequenceStep } from "./campaign-sequence-step";
|
|
52
|
+
import { CampaignWizard } from "./campaign-wizard";
|
|
53
|
+
import { FormField } from "./form-field";
|
|
54
|
+
import { IcpDetail } from "./icp-detail";
|
|
55
|
+
import { IcpEditor } from "./icp-editor";
|
|
56
|
+
import { PersonaDetail } from "./persona-detail";
|
|
57
|
+
import { PersonaEditor } from "./persona-editor";
|
|
58
|
+
|
|
59
|
+
export interface CampaignsScreenProps {
|
|
60
|
+
/** Overrides the website from the saved company profile (needed before a profile exists). */
|
|
61
|
+
websiteUrl?: string;
|
|
62
|
+
/** Extra buttons in the screen header. */
|
|
63
|
+
toolbarActions?: ReactNode;
|
|
64
|
+
/** Extra buttons in the open campaign's header. */
|
|
65
|
+
renderDetailActions?: (campaign: CampaignRow) => ReactNode;
|
|
66
|
+
/** Extra tabs in the open campaign's panel. */
|
|
67
|
+
extraDetailTabs?: { id: string; label: string; render: (campaign: CampaignRow) => ReactNode }[];
|
|
68
|
+
className?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const TABS = [
|
|
72
|
+
{ id: "campaigns", label: "Campaigns" },
|
|
73
|
+
{ id: "profile", label: "Company profile" },
|
|
74
|
+
{ id: "personas", label: "Personas" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** Campaign status -> pill tone. Unknown states are a warning, never green. */
|
|
78
|
+
export function campaignTone(status: string | null | undefined): Tone {
|
|
79
|
+
const s = (status ?? "").toLowerCase();
|
|
80
|
+
if (["active", "live", "launched", "completed", "complete", "done"].includes(s)) return "positive";
|
|
81
|
+
if (["draft", "pending", "generating", "running", "in_progress", "copy_in_progress", "scheduling"].includes(s)) return "info";
|
|
82
|
+
if (["paused", "archived"].includes(s)) return "neutral";
|
|
83
|
+
return s ? "warning" : "neutral";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function Details({ campaign, full, onSaved }: { campaign: CampaignRow; full: unknown; onSaved: () => void }) {
|
|
87
|
+
const [edit, setEdit] = useState<{ name: string; goal: string; brief: string } | null>(null);
|
|
88
|
+
const [confirm, setConfirm] = useState(false);
|
|
89
|
+
const save = useG8Mutation(
|
|
90
|
+
(v: { name: string; goal: string; brief: string }) => g8Patch(`/campaigns/${campaign.id}`, v),
|
|
91
|
+
{ onSuccess: () => { setConfirm(false); setEdit(null); onSaved(); }, onError: () => setConfirm(false) },
|
|
92
|
+
);
|
|
93
|
+
const audience = audienceOf(full);
|
|
94
|
+
if (!edit)
|
|
95
|
+
return (
|
|
96
|
+
<div className="space-y-4">
|
|
97
|
+
<KeyValueList
|
|
98
|
+
columns={2}
|
|
99
|
+
items={[
|
|
100
|
+
...fieldItems(Object.fromEntries(Object.entries(campaign).filter(([k, v]) => k !== "brief" && typeof v !== "object"))),
|
|
101
|
+
{ label: "Audience", value: audience.listId ? `${audience.name || `List ${audience.listId}`}${audience.size != null ? ` (${audience.size.toLocaleString()} contacts)` : ""}` : "None attached" },
|
|
102
|
+
]}
|
|
103
|
+
/>
|
|
104
|
+
{campaign.brief ? <p className="whitespace-pre-wrap text-sm">{campaign.brief}</p> : null}
|
|
105
|
+
<Button size="sm" variant="outline" onClick={() => setEdit({ name: campaign.name ?? "", goal: campaign.goal ?? "", brief: campaign.brief ?? "" })}>
|
|
106
|
+
Edit details
|
|
107
|
+
</Button>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
return (
|
|
111
|
+
<div className="space-y-3">
|
|
112
|
+
<FormField label="Name" htmlFor="cmp-name"><Input id="cmp-name" value={edit.name} onChange={(e) => setEdit({ ...edit, name: e.target.value })} /></FormField>
|
|
113
|
+
<FormField label="Goal" htmlFor="cmp-goal"><Input id="cmp-goal" value={edit.goal} onChange={(e) => setEdit({ ...edit, goal: e.target.value })} /></FormField>
|
|
114
|
+
<FormField label="Brief" htmlFor="cmp-brief"><Textarea id="cmp-brief" rows={5} value={edit.brief} onChange={(e) => setEdit({ ...edit, brief: e.target.value })} /></FormField>
|
|
115
|
+
{save.error ? <ApiError error={save.error} title="Could not save the campaign" onRetry={save.reset} /> : null}
|
|
116
|
+
<div className="flex gap-2">
|
|
117
|
+
<Button size="sm" disabled={!edit.name.trim()} onClick={() => setConfirm(true)}>Save changes</Button>
|
|
118
|
+
<Button size="sm" variant="ghost" onClick={() => setEdit(null)}>Cancel</Button>
|
|
119
|
+
</div>
|
|
120
|
+
<ConfirmSpendDialog
|
|
121
|
+
open={confirm}
|
|
122
|
+
tier="external"
|
|
123
|
+
title={`Save changes to "${campaign.name ?? "this campaign"}"`}
|
|
124
|
+
description="The campaign details are replaced with what you see. Nobody is contacted."
|
|
125
|
+
confirmLabel="Save changes"
|
|
126
|
+
confirming={save.loading}
|
|
127
|
+
onCancel={() => setConfirm(false)}
|
|
128
|
+
onConfirm={() => void save.mutate({ name: edit.name.trim(), goal: edit.goal, brief: edit.brief })}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface DetailProps {
|
|
135
|
+
id: string;
|
|
136
|
+
props: CampaignsScreenProps;
|
|
137
|
+
tab?: string;
|
|
138
|
+
onTabChange: (tab: string) => void;
|
|
139
|
+
docId?: string;
|
|
140
|
+
onDocChange: (id: string | null) => void;
|
|
141
|
+
onClose: () => void;
|
|
142
|
+
onExpand?: () => void;
|
|
143
|
+
onCollapse?: () => void;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function Detail({ id, props, tab, onTabChange, docId, onDocChange, onClose, onExpand, onCollapse }: DetailProps) {
|
|
147
|
+
const base = useG8Query((signal) => g8Get(`/campaigns/${id}`, undefined, { signal }), [id]);
|
|
148
|
+
const full = useG8Query((signal) => g8Get(`/campaigns/${id}/full`, undefined, { signal }), [id]);
|
|
149
|
+
const c = { id, ...objectOf(full.data), ...objectOf(base.data) } as CampaignRow;
|
|
150
|
+
const refetch = () => { void base.refetch(); void full.refetch(); };
|
|
151
|
+
if ((base.error as { status?: number } | null)?.status === 404)
|
|
152
|
+
return (
|
|
153
|
+
<div className="space-y-2 p-4 text-sm">
|
|
154
|
+
<p>This campaign no longer exists.</p>
|
|
155
|
+
<Button size="sm" variant="outline" onClick={onClose}>Close</Button>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
if (base.error) return <ApiError error={base.error} title="Could not load the campaign" onRetry={() => void base.refetch()} />;
|
|
159
|
+
return (
|
|
160
|
+
<DetailPanel
|
|
161
|
+
title={c.name ?? `Campaign ${id}`}
|
|
162
|
+
subtitle={c.goal ?? undefined}
|
|
163
|
+
badges={c.status ? <StatusPill tone={campaignTone(c.status)} dot>{c.status}</StatusPill> : null}
|
|
164
|
+
actions={props.renderDetailActions?.(c)}
|
|
165
|
+
onClose={onClose}
|
|
166
|
+
onExpand={onExpand}
|
|
167
|
+
onCollapse={onCollapse}
|
|
168
|
+
activeTab={tab}
|
|
169
|
+
onTabChange={onTabChange}
|
|
170
|
+
tabs={[
|
|
171
|
+
{
|
|
172
|
+
id: "details",
|
|
173
|
+
label: "Details",
|
|
174
|
+
// Until the campaign itself has loaded, `c` is only `{ id }`: "Edit details" would seed the form with empty
|
|
175
|
+
// name, goal and brief, and saving would blank the real values. Show a skeleton until the record is here.
|
|
176
|
+
content: base.data === undefined ? (
|
|
177
|
+
<div className="space-y-2" aria-busy="true" data-testid="campaign-details-loading"><Skeleton className="h-24 w-full" /><Skeleton className="h-8 w-32" /></div>
|
|
178
|
+
) : (
|
|
179
|
+
<Details campaign={c} full={full.data} onSaved={refetch} />
|
|
180
|
+
),
|
|
181
|
+
},
|
|
182
|
+
{ id: "metrics", label: "Metrics", content: <CampaignMetrics campaignId={id} /> },
|
|
183
|
+
{ id: "documents", label: "Documents", content: <CampaignAssets campaignId={id} documentId={docId ?? null} onDocumentChange={onDocChange} /> },
|
|
184
|
+
{ id: "audience", label: "Audience", content: <CampaignAudienceStep campaignId={id} onAttached={refetch} /> },
|
|
185
|
+
{ id: "sequence", label: "Sequence", content: <CampaignSequenceStep campaignId={id} /> },
|
|
186
|
+
{ id: "launch", label: "Launch", content: <CampaignLaunchStep campaignId={id} onLaunched={refetch} /> },
|
|
187
|
+
...(props.extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(c) })),
|
|
188
|
+
]}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function CampaignsScreen(props: CampaignsScreenProps) {
|
|
194
|
+
const [tab, setTab] = useUrlState("tab", "campaigns");
|
|
195
|
+
const [selectedId, setSelectedId] = useUrlState("id", "");
|
|
196
|
+
const [detailTab, setDetailTab] = useUrlState("dtab", "");
|
|
197
|
+
const [docId, setDocId] = useUrlState("doc", "");
|
|
198
|
+
const [view, setView] = useUrlState("view", "");
|
|
199
|
+
const [creating, setCreating] = useState(false);
|
|
200
|
+
const [version, setVersion] = useState(0);
|
|
201
|
+
const bump = () => setVersion((v) => v + 1);
|
|
202
|
+
const go = (nextTab: string, id: string) => { setTab(nextTab === "campaigns" ? null : nextTab); setSelectedId(id || null); setDetailTab(null); setDocId(null); setView(null); };
|
|
203
|
+
const close = () => { setSelectedId(null); setDetailTab(null); setDocId(null); setView(null); };
|
|
204
|
+
const create = (
|
|
205
|
+
<Button onClick={() => setCreating(true)}>
|
|
206
|
+
<Plus className="mr-1 h-4 w-4" aria-hidden /> New campaign
|
|
207
|
+
</Button>
|
|
208
|
+
);
|
|
209
|
+
const active = TABS.some((t) => t.id === tab) ? tab : "campaigns";
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<ScreenLayout
|
|
213
|
+
className={props.className}
|
|
214
|
+
title="Campaigns"
|
|
215
|
+
description="Plan, write and launch AI-assisted outreach campaigns."
|
|
216
|
+
icon={<Megaphone className="h-5 w-5" aria-hidden />}
|
|
217
|
+
actions={<>{props.toolbarActions}{create}</>}
|
|
218
|
+
tabs={TABS}
|
|
219
|
+
activeTab={active}
|
|
220
|
+
onTabChange={(id) => go(id, "")}
|
|
221
|
+
>
|
|
222
|
+
{active === "profile" ? (
|
|
223
|
+
<SplitView
|
|
224
|
+
expanded={view === "full"} onExpandedChange={(e) => setView(e ? "full" : null)}
|
|
225
|
+
detailOpen={Boolean(selectedId)}
|
|
226
|
+
onCloseDetail={close}
|
|
227
|
+
list={<IcpEditor websiteUrl={props.websiteUrl} refreshKey={version} onOpen={(i) => go("profile", i.id)} />}
|
|
228
|
+
detail={selectedId ? <IcpDetail key={selectedId} icpId={selectedId} onExpand={view === "full" ? undefined : () => setView("full")} onCollapse={view === "full" ? () => setView(null) : undefined} onClose={close} onChanged={bump} /> : null}
|
|
229
|
+
/>
|
|
230
|
+
) : active === "personas" ? (
|
|
231
|
+
<SplitView
|
|
232
|
+
expanded={view === "full"} onExpandedChange={(e) => setView(e ? "full" : null)}
|
|
233
|
+
detailOpen={Boolean(selectedId)}
|
|
234
|
+
onCloseDetail={close}
|
|
235
|
+
list={<PersonaEditor websiteUrl={props.websiteUrl} refreshKey={version} onOpen={(p) => go("personas", p.id)} />}
|
|
236
|
+
detail={selectedId ? <PersonaDetail key={selectedId} personaId={selectedId} onExpand={view === "full" ? undefined : () => setView("full")} onCollapse={view === "full" ? () => setView(null) : undefined} onClose={close} onChanged={bump} onOpenCampaign={(c) => go("campaigns", c.id)} /> : null}
|
|
237
|
+
/>
|
|
238
|
+
) : (
|
|
239
|
+
<SplitView
|
|
240
|
+
expanded={view === "full"} onExpandedChange={(e) => setView(e ? "full" : null)}
|
|
241
|
+
detailOpen={Boolean(selectedId)}
|
|
242
|
+
onCloseDetail={close}
|
|
243
|
+
detailWidth="xl"
|
|
244
|
+
list={<CampaignList linkParam="id" key={version} onOpen={(c) => go("campaigns", c.id)} emptyAction={create} />}
|
|
245
|
+
detail={
|
|
246
|
+
selectedId ? (
|
|
247
|
+
<Detail
|
|
248
|
+
key={selectedId}
|
|
249
|
+
id={selectedId}
|
|
250
|
+
props={props}
|
|
251
|
+
onExpand={view === "full" ? undefined : () => setView("full")} onCollapse={view === "full" ? () => setView(null) : undefined}
|
|
252
|
+
tab={detailTab || undefined}
|
|
253
|
+
onTabChange={(t) => setDetailTab(t === "details" ? null : t)}
|
|
254
|
+
docId={docId || undefined}
|
|
255
|
+
onDocChange={(d) => setDocId(d)}
|
|
256
|
+
onClose={close}
|
|
257
|
+
/>
|
|
258
|
+
) : null
|
|
259
|
+
}
|
|
260
|
+
/>
|
|
261
|
+
)}
|
|
262
|
+
<FormSheet width="xl" open={creating} onOpenChange={(o) => { setCreating(o); if (!o) bump(); }} title="New campaign" description="From an idea to a launched campaign in five steps.">
|
|
263
|
+
{creating ? <CampaignWizard websiteUrl={props.websiteUrl} onLaunched={bump} /> : null}
|
|
264
|
+
</FormSheet>
|
|
265
|
+
</ScreenLayout>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IcpDetail: one ideal customer profile, complete. Every field (firmographics, buying signals, score,
|
|
5
|
+
* status) and every action: edit, pin or unpin, archive.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. listIcps GET /icps (read; the API has no single-ICP read, so the row is found in the list)
|
|
9
|
+
* 2. updateIcp PATCH /icps/{icp_id} (write)
|
|
10
|
+
* 3. pinIcp / unpinIcp POST /icps/{icp_id}/pin|unpin (write)
|
|
11
|
+
* 4. archiveIcp POST /icps/{icp_id}/archive (destructive: confirmed)
|
|
12
|
+
*
|
|
13
|
+
* Related records: the API links no campaign to an ICP, so none are listed.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import type { ReactNode } from "react";
|
|
18
|
+
import { Button, Input, Skeleton, Textarea } from "@graph8/sdk/ui";
|
|
19
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
20
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
21
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
22
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
23
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
24
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
25
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
26
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
27
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
28
|
+
import { g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
29
|
+
import { fieldItems, icpFormOf, icpPatch, listOf, text } from "../lib/campaigns-utils";
|
|
30
|
+
import { FormField } from "./form-field";
|
|
31
|
+
|
|
32
|
+
export interface IcpDetailProps {
|
|
33
|
+
icpId: string;
|
|
34
|
+
onClose?: () => void;
|
|
35
|
+
/** Full-page mode: show an "Open full page" (onExpand) or "Back to split view" (onCollapse) button. */
|
|
36
|
+
onExpand?: () => void;
|
|
37
|
+
onCollapse?: () => void;
|
|
38
|
+
/** Called after an edit, pin or archive so a list can refresh. */
|
|
39
|
+
onChanged?: () => void;
|
|
40
|
+
renderActions?: (icp: Record<string, unknown>) => ReactNode;
|
|
41
|
+
className?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Form { name: string; description: string; industries: string; size: string; regions: string; signals: string }
|
|
45
|
+
|
|
46
|
+
/** The API returns `pinned` (`is_pinned` is accepted too, for older payloads). */
|
|
47
|
+
function isPinned(r: Record<string, unknown> | undefined): boolean {
|
|
48
|
+
return Boolean(r && (r.pinned ?? r.is_pinned));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function IcpDetail({ icpId, onClose, onExpand, onCollapse, onChanged, renderActions, className }: IcpDetailProps) {
|
|
52
|
+
const [form, setForm] = useState<Form | null>(null);
|
|
53
|
+
const [archiving, setArchiving] = useState(false);
|
|
54
|
+
const q = useG8Query((signal) => g8Get("/icps", { limit: 200 }, { signal }), [icpId]);
|
|
55
|
+
const icp = listOf<Record<string, any>>(q.data).find((r) => String(r.id) === icpId);
|
|
56
|
+
|
|
57
|
+
const save = useG8Mutation(async (f: Form) => {
|
|
58
|
+
const patch = icp ? icpPatch(icp, f) : {};
|
|
59
|
+
if (Object.keys(patch).length > 0) await g8Patch(`/icps/${icpId}`, patch); // nothing changed: no request
|
|
60
|
+
setForm(null);
|
|
61
|
+
await q.refetch();
|
|
62
|
+
onChanged?.();
|
|
63
|
+
});
|
|
64
|
+
const pin = useG8Mutation(async () => {
|
|
65
|
+
await g8Post(`/icps/${icpId}/${isPinned(icp) ? "unpin" : "pin"}`, {});
|
|
66
|
+
await q.refetch();
|
|
67
|
+
onChanged?.();
|
|
68
|
+
});
|
|
69
|
+
const archive = useG8Mutation(async () => {
|
|
70
|
+
await g8Post(`/icps/${icpId}/archive`, {});
|
|
71
|
+
setArchiving(false);
|
|
72
|
+
onChanged?.();
|
|
73
|
+
onClose?.();
|
|
74
|
+
}, { onError: () => setArchiving(false) });
|
|
75
|
+
|
|
76
|
+
if (q.error) return <ApiError error={q.error} title="Could not load the ICP" onRetry={() => void q.refetch()} className={className} />;
|
|
77
|
+
if (q.loading && !q.data) return <Skeleton className={`h-48 w-full ${className ?? ""}`} />;
|
|
78
|
+
if (!icp) return <p className={`p-4 text-sm ${className ?? ""}`}>This ICP no longer exists or was archived.</p>;
|
|
79
|
+
|
|
80
|
+
const f = (icp.firmographics ?? {}) as Record<string, unknown>;
|
|
81
|
+
const arr = (v: unknown) => (Array.isArray(v) ? v.join(", ") : text(v));
|
|
82
|
+
const startEdit = () => {
|
|
83
|
+
save.reset();
|
|
84
|
+
setForm(icpFormOf(icp));
|
|
85
|
+
};
|
|
86
|
+
const set = (k: keyof Form) => (e: { target: { value: string } }) => setForm((cur) => (cur ? { ...cur, [k]: e.target.value } : cur));
|
|
87
|
+
return (
|
|
88
|
+
<DetailPanel
|
|
89
|
+
className={className}
|
|
90
|
+
title={icp.name ?? `ICP ${icpId}`}
|
|
91
|
+
subtitle={icp.description ?? undefined}
|
|
92
|
+
badges={<><StatusPill tone={icp.status && icp.status !== "active" ? "neutral" : "positive"} dot>{icp.status ?? "active"}</StatusPill>{isPinned(icp) ? <StatusPill tone="info">pinned</StatusPill> : null}</>}
|
|
93
|
+
actions={
|
|
94
|
+
<>
|
|
95
|
+
<Button size="sm" variant="outline" onClick={startEdit}>Edit ICP</Button>
|
|
96
|
+
<Button size="sm" variant="outline" loading={pin.loading} onClick={() => void pin.mutate(undefined)}>{isPinned(icp) ? "Unpin ICP" : "Pin ICP"}</Button>
|
|
97
|
+
<Button size="sm" variant="outline" onClick={() => setArchiving(true)}>Archive ICP</Button>
|
|
98
|
+
{renderActions?.(icp)}
|
|
99
|
+
<FormSheet
|
|
100
|
+
open={form !== null}
|
|
101
|
+
onOpenChange={(o) => !o && setForm(null)}
|
|
102
|
+
title="Edit ICP"
|
|
103
|
+
footer={<><Button variant="outline" onClick={() => setForm(null)}>Cancel</Button><Button loading={save.loading} disabled={!form?.name.trim()} onClick={() => form && void save.mutate(form)}>Save ICP</Button></>}
|
|
104
|
+
>
|
|
105
|
+
{form ? (
|
|
106
|
+
<>
|
|
107
|
+
<FormField label="Name" htmlFor="icpd-name"><Input id="icpd-name" value={form.name} onChange={set("name")} /></FormField>
|
|
108
|
+
<FormField label="Description" htmlFor="icpd-desc"><Textarea id="icpd-desc" value={form.description} onChange={set("description")} /></FormField>
|
|
109
|
+
<FormField label="Industries" htmlFor="icpd-ind" hint="Comma separated."><Input id="icpd-ind" value={form.industries} onChange={set("industries")} /></FormField>
|
|
110
|
+
<FormField label="Company size" htmlFor="icpd-size"><Input id="icpd-size" value={form.size} onChange={set("size")} /></FormField>
|
|
111
|
+
<FormField label="Regions" htmlFor="icpd-reg" hint="Comma separated."><Input id="icpd-reg" value={form.regions} onChange={set("regions")} /></FormField>
|
|
112
|
+
<FormField label="Buying signals" htmlFor="icpd-sig" hint="One per line."><Textarea id="icpd-sig" value={form.signals} onChange={set("signals")} /></FormField>
|
|
113
|
+
{save.error ? <ApiError error={save.error} title="Could not save the ICP" /> : null}
|
|
114
|
+
</>
|
|
115
|
+
) : null}
|
|
116
|
+
</FormSheet>
|
|
117
|
+
<ConfirmSpendDialog
|
|
118
|
+
open={archiving}
|
|
119
|
+
tier="destructive"
|
|
120
|
+
title={`Archive ${icp.name ?? "this ICP"}`}
|
|
121
|
+
description="It drops out of the default list. Campaigns already created from it are unaffected."
|
|
122
|
+
affected={1}
|
|
123
|
+
confirmLabel="Archive ICP"
|
|
124
|
+
confirming={archive.loading}
|
|
125
|
+
onCancel={() => setArchiving(false)}
|
|
126
|
+
onConfirm={() => void archive.mutate(undefined)}
|
|
127
|
+
/>
|
|
128
|
+
</>
|
|
129
|
+
}
|
|
130
|
+
onClose={onClose}
|
|
131
|
+
onExpand={onExpand}
|
|
132
|
+
onCollapse={onCollapse}
|
|
133
|
+
tabs={[
|
|
134
|
+
{
|
|
135
|
+
id: "details",
|
|
136
|
+
label: "Details",
|
|
137
|
+
content: (
|
|
138
|
+
<div className="space-y-4">
|
|
139
|
+
{[pin.error, archive.error].map((e, i) => (e ? <ApiError key={i} error={e} /> : null))}
|
|
140
|
+
<KeyValueList columns={2} items={[...fieldItems({ industries: f.industries, company_size: f.company_size, regions: f.regions }), ...fieldItems(Object.fromEntries(Object.entries(icp).filter(([k]) => !["firmographics", "buying_signals", "name", "description"].includes(k))))]} />
|
|
141
|
+
<SectionCard title="Buying signals" description="What tells you an account fits.">
|
|
142
|
+
{((icp.buying_signals ?? []) as unknown[]).length === 0 ? (
|
|
143
|
+
<p className="text-sm text-muted-foreground">No buying signals yet.</p>
|
|
144
|
+
) : (
|
|
145
|
+
<ul className="list-disc space-y-1 pl-5 text-sm">{((icp.buying_signals ?? []) as unknown[]).map((s, i) => <li key={i}>{text(s)}</li>)}</ul>
|
|
146
|
+
)}
|
|
147
|
+
</SectionCard>
|
|
148
|
+
</div>
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
]}
|
|
152
|
+
/>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -28,13 +28,17 @@ import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
|
28
28
|
import { useJobPoll } from "@/g8/hooks/use-job-poll";
|
|
29
29
|
import { g8Get, g8Patch, g8Post, g8Put } from "@/g8/lib/g8-client";
|
|
30
30
|
import { useCompanyProfile } from "../hooks/use-company-profile";
|
|
31
|
-
import { CREDIT_ESTIMATES, commaList, lines, listOf, objectOf, settled, statusClass, text } from "../lib/campaigns-utils";
|
|
31
|
+
import { CREDIT_ESTIMATES, commaList, icpFormOf, icpPatch, lines, listOf, objectOf, settled, statusClass, text } from "../lib/campaigns-utils";
|
|
32
32
|
import { FormField } from "./form-field";
|
|
33
33
|
|
|
34
34
|
export interface IcpEditorProps {
|
|
35
35
|
/** Overrides the website from the saved profile (needed the first time, before a profile exists). */
|
|
36
36
|
websiteUrl?: string;
|
|
37
37
|
onChange?: () => void;
|
|
38
|
+
/** Called when an ICP's name is clicked (a screen opens its detail). Without it the name is plain text. */
|
|
39
|
+
onOpen?: (icp: { id: string; name?: string | null }) => void;
|
|
40
|
+
/** Change it to reload the list (the detail edited or archived an ICP). */
|
|
41
|
+
refreshKey?: number;
|
|
38
42
|
className?: string;
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -130,19 +134,12 @@ function ProfileCard({ websiteUrl: override, onChange }: IcpEditorProps) {
|
|
|
130
134
|
interface IcpForm { name: string; description: string; industries: string; size: string; regions: string; signals: string }
|
|
131
135
|
const emptyForm: IcpForm = { name: "", description: "", industries: "", size: "", regions: "", signals: "" };
|
|
132
136
|
|
|
133
|
-
|
|
134
|
-
const f = (icp.firmographics ?? {}) as Record<string, unknown>;
|
|
135
|
-
const arr = (v: unknown) => (Array.isArray(v) ? v.join(", ") : text(v));
|
|
136
|
-
return {
|
|
137
|
-
name: icp.name ?? "", description: icp.description ?? "", industries: arr(f.industries), size: text(f.company_size), regions: arr(f.regions),
|
|
138
|
-
signals: (icp.buying_signals ?? []).map((s) => text(s)).filter(Boolean).join("\n"),
|
|
139
|
-
};
|
|
140
|
-
}
|
|
137
|
+
const formOf = (icp: IcpRow): IcpForm => icpFormOf(icp as Record<string, any>);
|
|
141
138
|
|
|
142
139
|
export function IcpEditor(props: IcpEditorProps) {
|
|
143
|
-
const { onChange, className } = props;
|
|
140
|
+
const { onChange, onOpen, className } = props;
|
|
144
141
|
const profile = useCompanyProfile(props.websiteUrl);
|
|
145
|
-
const icps = useG8Query((signal) => g8Get("/icps", { limit: 100 }, { signal }), []);
|
|
142
|
+
const icps = useG8Query((signal) => g8Get("/icps", { limit: 100 }, { signal }), [props.refreshKey]);
|
|
146
143
|
const [editing, setEditing] = useState<IcpRow | "new" | null>(null);
|
|
147
144
|
const [form, setForm] = useState<IcpForm>(emptyForm);
|
|
148
145
|
const [archiving, setArchiving] = useState<IcpRow | null>(null);
|
|
@@ -151,9 +148,12 @@ export function IcpEditor(props: IcpEditorProps) {
|
|
|
151
148
|
const done = () => { void icps.refetch(); onChange?.(); };
|
|
152
149
|
const saveIcp = useG8Mutation(async () => {
|
|
153
150
|
const existing = editing && editing !== "new" ? editing : null;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
151
|
+
if (existing) {
|
|
152
|
+
const patch = icpPatch(existing as Record<string, any>, form);
|
|
153
|
+
return Object.keys(patch).length > 0 ? g8Patch(`/icps/${existing.id}`, patch) : undefined; // nothing changed: no request
|
|
154
|
+
}
|
|
155
|
+
const firmographics = { industries: commaList(form.industries), company_size: form.size, regions: commaList(form.regions) };
|
|
156
|
+
return g8Post("/icps", { name: form.name.trim(), description: form.description, firmographics, buying_signals: lines(form.signals), website_url: profile.websiteUrl });
|
|
157
157
|
}, { onSuccess: () => { setEditing(null); done(); } });
|
|
158
158
|
const archive = useG8Mutation(async (icp: IcpRow) => {
|
|
159
159
|
setBusyId(icp.id);
|
|
@@ -181,7 +181,7 @@ export function IcpEditor(props: IcpEditorProps) {
|
|
|
181
181
|
{icps.loading && !icps.data ? <TableRow><TableCell colSpan={4}><Skeleton className="h-6 w-full" /></TableCell></TableRow> : null}
|
|
182
182
|
{rows.map((icp) => (
|
|
183
183
|
<TableRow key={icp.id}>
|
|
184
|
-
<TableCell className="font-medium">{icp.name}</TableCell>
|
|
184
|
+
<TableCell className="font-medium">{onOpen ? <button type="button" className="text-left hover:underline" aria-label={`Open ${icp.name}`} onClick={() => onOpen(icp)}>{icp.name}</button> : icp.name}</TableCell>
|
|
185
185
|
<TableCell className="max-w-md truncate text-muted-foreground">{icp.description}</TableCell>
|
|
186
186
|
<TableCell><span className={`rounded px-2 py-0.5 text-xs ${statusClass(icp.status)}`}>{icp.status ?? "active"}</span></TableCell>
|
|
187
187
|
<TableCell className="space-x-2 text-right">
|