@graph8/sdk 0.370.0 → 0.373.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 +19 -0
- package/blocks/_foundation/README.md +14 -0
- package/blocks/_foundation/block.json +44 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
- package/blocks/_foundation/components/empty-state.tsx +5 -2
- package/blocks/_foundation/components/list-select.tsx +112 -0
- package/blocks/_foundation/components/paging-notices.tsx +55 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +2 -1
- package/blocks/_foundation/components/screen/filter-controls.tsx +92 -0
- package/blocks/_foundation/components/screen/index.ts +1 -0
- package/blocks/_foundation/components/screen/split-view.tsx +1 -1
- package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
- package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
- package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
- package/blocks/_foundation/lib/paging.ts +57 -0
- package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
- package/blocks/agents/README.md +81 -4
- package/blocks/agents/block.json +311 -2
- package/blocks/agents/components/agent-activity.tsx +112 -0
- package/blocks/agents/components/agent-approvals.tsx +135 -0
- package/blocks/agents/components/agent-builder.tsx +17 -3
- package/blocks/agents/components/agent-conversations.tsx +143 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -2
- package/blocks/agents/components/agent-operators.tsx +164 -0
- package/blocks/agents/components/agent-run-detail.tsx +20 -3
- package/blocks/agents/components/agent-runs.tsx +3 -3
- package/blocks/agents/components/agent-skills-panel.tsx +7 -7
- package/blocks/agents/components/agent-test-history.tsx +121 -0
- package/blocks/agents/components/agents-screen.tsx +53 -11
- package/blocks/agents/components/assistant-sessions.tsx +150 -0
- package/blocks/agents/components/duplicate-agent-button.tsx +42 -0
- package/blocks/agents/components/from-template-button.tsx +65 -0
- package/blocks/agents/components/knowledge-browser.tsx +107 -0
- package/blocks/agents/components/knowledge-detail.tsx +156 -0
- package/blocks/agents/components/knowledge-form.tsx +69 -0
- package/blocks/agents/components/name-dialog.tsx +32 -0
- package/blocks/agents/components/receptionist-button.tsx +46 -0
- package/blocks/agents/components/save-as-template-button.tsx +32 -0
- package/blocks/agents/components/skill-editor.tsx +147 -0
- package/blocks/agents/components/skill-library.tsx +111 -0
- package/blocks/agents/lib/agent-types.ts +7 -1
- package/blocks/agents/lib/facts.tsx +13 -0
- package/blocks/agents/lib/knowledge-utils.ts +49 -0
- package/blocks/agents/lib/load-more.tsx +141 -0
- package/blocks/agents/lib/skill-utils.ts +91 -0
- package/blocks/agents/recipes/create-agent-and-attach-skill.ts +1 -1
- package/blocks/analytics/components/analytics-screen.tsx +16 -3
- package/blocks/analytics/components/kpi-tiles.tsx +1 -1
- package/blocks/analytics/components/report-builder.tsx +15 -2
- package/blocks/appointments/README.md +6 -0
- package/blocks/appointments/block.json +27 -0
- package/blocks/appointments/components/booking-page.tsx +2 -2
- package/blocks/appointments/components/meeting-contact-links.tsx +49 -0
- package/blocks/appointments/components/meeting-detail.tsx +2 -2
- package/blocks/appointments/components/meeting-list.tsx +29 -13
- package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
- package/blocks/campaigns/components/campaign-assets.tsx +1 -1
- package/blocks/campaigns/components/campaign-launch-step.tsx +16 -11
- package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
- package/blocks/campaigns/components/campaign-wizard.tsx +10 -5
- package/blocks/campaigns/components/icp-detail.tsx +11 -2
- package/blocks/campaigns/components/icp-editor.tsx +8 -3
- package/blocks/campaigns/components/persona-detail.tsx +11 -2
- package/blocks/campaigns/components/persona-editor.tsx +6 -1
- package/blocks/campaigns/lib/campaigns-utils.ts +4 -4
- package/blocks/coach/components/call-scorecard.tsx +7 -7
- package/blocks/company-intelligence/README.md +132 -0
- package/blocks/company-intelligence/block.json +318 -0
- package/blocks/company-intelligence/components/company-intelligence-screen.tsx +114 -0
- package/blocks/company-intelligence/components/company-intelligence-tab.tsx +88 -0
- package/blocks/company-intelligence/components/company-profile.tsx +68 -0
- package/blocks/company-intelligence/components/intelligence-sources.tsx +63 -0
- package/blocks/company-intelligence/components/report-detail.tsx +189 -0
- package/blocks/company-intelligence/components/research-reports.tsx +136 -0
- package/blocks/company-intelligence/components/research-run.tsx +161 -0
- package/blocks/company-intelligence/components/source-detail.tsx +117 -0
- package/blocks/company-intelligence/components/source-view.tsx +274 -0
- package/blocks/company-intelligence/components/wiki-document-detail.tsx +116 -0
- package/blocks/company-intelligence/components/wiki-documents.tsx +66 -0
- package/blocks/company-intelligence/hooks/use-intelligence.ts +173 -0
- package/blocks/company-intelligence/lib/intelligence-types.ts +230 -0
- package/blocks/company-intelligence/lib/safe-markdown.tsx +150 -0
- package/blocks/company-intelligence/recipes/read-competitors-and-news.ts +33 -0
- package/blocks/company-intelligence/recipes/research-your-website.ts +43 -0
- package/blocks/company-intelligence/recipes/write-the-research-reports.ts +54 -0
- package/blocks/copilot/components/copilot-memory.tsx +6 -2
- package/blocks/copilot/components/copilot-panel.tsx +1 -1
- package/blocks/copilot/components/copilot-team-chat.tsx +6 -1
- package/blocks/crm/README.md +6 -0
- package/blocks/crm/block.json +53 -4
- package/blocks/crm/components/companies-screen.tsx +23 -9
- package/blocks/crm/components/company-detail.tsx +2 -2
- package/blocks/crm/components/contact-detail.tsx +4 -4
- package/blocks/crm/components/contact-lookup-link.tsx +44 -0
- package/blocks/crm/components/contacts-screen.tsx +31 -13
- package/blocks/crm/components/dedupe-review.tsx +6 -1
- package/blocks/crm/components/import-wizard.tsx +3 -7
- package/blocks/crm/components/list-select.tsx +20 -0
- package/blocks/crm/components/record-engagement-tab.tsx +103 -0
- package/blocks/crm/components/record-form.tsx +1 -1
- package/blocks/crm/components/tasks-screen.tsx +35 -15
- package/blocks/crm/hooks/use-all-lists.ts +18 -0
- package/blocks/crm/hooks/use-page-query.ts +3 -1
- package/blocks/crm/lib/crm-types.ts +14 -0
- package/blocks/crm/lib/read-lists.ts +16 -0
- package/blocks/deals/README.md +24 -6
- package/blocks/deals/block.json +151 -2
- package/blocks/deals/components/close-deal-dialog.tsx +169 -0
- package/blocks/deals/components/deal-contacts-editor.tsx +134 -0
- package/blocks/deals/components/deal-detail.tsx +2 -2
- package/blocks/deals/components/deal-filters.tsx +65 -0
- package/blocks/deals/components/deal-line-items.tsx +120 -0
- package/blocks/deals/components/deal-notes-tab.tsx +97 -0
- package/blocks/deals/components/deal-related.tsx +30 -9
- package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
- package/blocks/deals/components/deals-screen.tsx +57 -22
- package/blocks/deals/components/forecast-view.tsx +4 -4
- package/blocks/deals/components/pipeline-board.tsx +8 -6
- package/blocks/deals/components/pipeline-editor.tsx +132 -0
- package/blocks/deals/components/record-deals-tab.tsx +62 -0
- package/blocks/deals/components/stage-editor.tsx +1 -1
- package/blocks/deals/hooks/use-pipeline-deals.ts +17 -4
- package/blocks/deals/lib/deal-extras.ts +156 -0
- package/blocks/dialer/README.md +14 -5
- package/blocks/dialer/block.json +17 -2
- package/blocks/dialer/components/call-contact-button.tsx +26 -9
- package/blocks/dialer/components/call-log-screen.tsx +4 -1
- package/blocks/dialer/components/call-log.tsx +16 -4
- package/blocks/dialer/components/call-queue.tsx +3 -3
- package/blocks/dialer/components/dialer-screen.tsx +69 -27
- package/blocks/dialer/components/disposition-panel.tsx +1 -1
- package/blocks/dialer/components/record-calls-tab.tsx +101 -0
- package/blocks/dialer/components/session-start-form.tsx +58 -11
- package/blocks/dialer/components/softphone.tsx +1 -1
- package/blocks/enrichment/README.md +16 -6
- package/blocks/enrichment/block.json +19 -7
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +76 -27
- package/blocks/enrichment/components/email-verify-badge.tsx +3 -3
- package/blocks/enrichment/components/enrich-button.tsx +1 -1
- package/blocks/enrichment/components/enrichment-screen.tsx +70 -17
- package/blocks/enrichment/components/waterfall-config.tsx +27 -7
- package/blocks/enrichment/lib/enrichment-types.ts +39 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
- package/blocks/forms/README.md +8 -0
- package/blocks/forms/block.json +107 -0
- package/blocks/forms/components/form-activity.tsx +71 -0
- package/blocks/forms/components/form-backfill.tsx +108 -0
- package/blocks/forms/components/form-builder.tsx +1 -1
- package/blocks/forms/components/form-embed.tsx +1 -1
- package/blocks/forms/components/form-groups.tsx +151 -0
- package/blocks/forms/components/form-mapping-editor.tsx +6 -9
- package/blocks/forms/components/form-route-history.tsx +72 -0
- package/blocks/forms/components/form-saved-views.tsx +81 -0
- package/blocks/forms/components/forms-screen.tsx +51 -17
- package/blocks/forms/components/more-rows.tsx +30 -0
- package/blocks/forms/components/submissions-export.tsx +67 -0
- package/blocks/forms/components/submissions-table.tsx +15 -6
- package/blocks/forms/lib/forms-extra.ts +67 -0
- package/blocks/inbox/README.md +5 -0
- package/blocks/inbox/block.json +15 -0
- package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
- package/blocks/inbox/components/thread-badge.tsx +2 -2
- package/blocks/inbox/components/thread-view.tsx +1 -1
- package/blocks/integrations/README.md +54 -1
- package/blocks/integrations/block.json +189 -15
- package/blocks/integrations/components/ad-accounts.tsx +82 -0
- package/blocks/integrations/components/connection-health.tsx +59 -0
- package/blocks/integrations/components/crm-detail.tsx +20 -1
- package/blocks/integrations/components/crm-records.tsx +103 -0
- package/blocks/integrations/components/field-mappings.tsx +61 -0
- package/blocks/integrations/components/integration-list.tsx +4 -4
- package/blocks/integrations/components/integrations-screen.tsx +20 -3
- package/blocks/integrations/components/migration-panel.tsx +133 -0
- package/blocks/integrations/components/sugarcrm-connect.tsx +95 -0
- package/blocks/integrations/components/sync-status.tsx +6 -6
- package/blocks/integrations/components/sync-triggers.tsx +134 -0
- package/blocks/integrations/components/webhook-detail.tsx +26 -7
- package/blocks/integrations/components/webhook-manager.tsx +30 -17
- package/blocks/integrations/components/workspace-tools.tsx +127 -0
- package/blocks/integrations/lib/crm-utils.ts +39 -0
- package/blocks/integrations/lib/facts.tsx +13 -0
- package/blocks/integrations/lib/integrations-utils.ts +5 -0
- package/blocks/integrations/lib/load-more.tsx +141 -0
- package/blocks/intent/components/account-intent-score.tsx +5 -5
- package/blocks/intent/components/intent-feed.tsx +8 -8
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -8
- package/blocks/intent/lib/intent-utils.ts +4 -4
- package/blocks/lifecycle/components/account-related.tsx +8 -1
- package/blocks/lifecycle/components/churn-list.tsx +5 -2
- package/blocks/lifecycle/components/customer-table.tsx +1 -1
- package/blocks/lists/README.md +7 -1
- package/blocks/lists/block.json +31 -3
- package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
- package/blocks/lists/components/add-to-list-button.tsx +17 -7
- package/blocks/lists/components/list-picker.tsx +13 -3
- package/blocks/lists/components/list-table.tsx +1 -1
- package/blocks/lists/components/lists-screen.tsx +4 -2
- package/blocks/lists/components/record-lists-tab.tsx +99 -0
- package/blocks/lists/components/suppression-manager.tsx +27 -6
- package/blocks/lists/components/suppressions-screen.tsx +21 -12
- package/blocks/mailboxes/block.json +4 -0
- package/blocks/mailboxes/components/connect-mailbox.tsx +9 -8
- package/blocks/mailboxes/components/domain-detail.tsx +10 -4
- package/blocks/mailboxes/components/domain-purchase.tsx +14 -11
- package/blocks/mailboxes/components/mailbox-detail.tsx +3 -2
- package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
- package/blocks/mailboxes/components/mailboxes-screen.tsx +8 -3
- package/blocks/mailboxes/components/warmup-health.tsx +20 -20
- package/blocks/mailboxes/lib/mailboxes-utils.ts +4 -4
- package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
- package/blocks/newsletter/README.md +12 -0
- package/blocks/newsletter/block.json +117 -0
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -2
- package/blocks/newsletter/components/issue-versions.tsx +93 -0
- package/blocks/newsletter/components/more-rows.tsx +30 -0
- package/blocks/newsletter/components/newsletter-editor.tsx +1 -1
- package/blocks/newsletter/components/newsletter-list-syncs.tsx +121 -0
- package/blocks/newsletter/components/newsletter-screen.tsx +36 -13
- package/blocks/newsletter/components/newsletter-settings.tsx +211 -0
- package/blocks/newsletter/components/newsletter-stats.tsx +2 -2
- package/blocks/newsletter/components/newsletter-templates.tsx +133 -0
- package/blocks/newsletter/components/subscriber-table.tsx +5 -7
- package/blocks/newsletter/components/subscriber-timeline.tsx +65 -0
- package/blocks/newsletter/lib/newsletter-extra.ts +60 -0
- package/blocks/nurture/components/nurture-builder.tsx +10 -2
- package/blocks/nurture/components/nurture-enroll.tsx +6 -7
- package/blocks/nurture/components/nurture-form.tsx +5 -8
- package/blocks/nurture/components/nurture-screen.tsx +16 -5
- package/blocks/nurture/components/nurture-stats.tsx +5 -4
- package/blocks/objects/README.md +1 -1
- package/blocks/objects/block.json +13 -5
- package/blocks/objects/components/attribute-field-input.tsx +14 -1
- package/blocks/objects/components/object-record-detail.tsx +8 -1
- package/blocks/objects/components/object-schema-editor.tsx +1 -1
- package/blocks/objects/components/record-roles-panel.tsx +15 -5
- package/blocks/pages/README.md +14 -0
- package/blocks/pages/block.json +113 -1
- package/blocks/pages/components/more-rows.tsx +30 -0
- package/blocks/pages/components/page-ai-tools.tsx +196 -0
- package/blocks/pages/components/page-analytics.tsx +176 -0
- package/blocks/pages/components/page-clone.tsx +99 -0
- package/blocks/pages/components/page-domains.tsx +167 -0
- package/blocks/pages/components/page-editor.tsx +1 -1
- package/blocks/pages/components/page-generation-status.tsx +48 -0
- package/blocks/pages/components/page-tracking.tsx +137 -0
- package/blocks/pages/components/pages-screen.tsx +57 -16
- package/blocks/pages/components/seo-report.tsx +6 -1
- package/blocks/pages/lib/studio.ts +7 -0
- package/blocks/phone-numbers/README.md +74 -0
- package/blocks/phone-numbers/block.json +262 -0
- package/blocks/phone-numbers/components/a2p-registration.tsx +230 -0
- package/blocks/phone-numbers/components/assign-number.tsx +104 -0
- package/blocks/phone-numbers/components/number-search.tsx +239 -0
- package/blocks/phone-numbers/components/number-settings.tsx +134 -0
- package/blocks/phone-numbers/components/number-usage.tsx +97 -0
- package/blocks/phone-numbers/components/phone-number-detail.tsx +84 -0
- package/blocks/phone-numbers/components/phone-number-list.tsx +110 -0
- package/blocks/phone-numbers/components/phone-numbers-screen.tsx +201 -0
- package/blocks/phone-numbers/components/register-owned-number.tsx +98 -0
- package/blocks/phone-numbers/components/release-number.tsx +126 -0
- package/blocks/phone-numbers/hooks/use-phone-numbers.ts +58 -0
- package/blocks/phone-numbers/lib/phone-numbers-utils.ts +202 -0
- package/blocks/phone-numbers/recipes/01-search-and-buy-number.ts +49 -0
- package/blocks/phone-numbers/recipes/02-assign-number-to-rep.ts +25 -0
- package/blocks/phone-numbers/recipes/03-audit-numbers.ts +36 -0
- package/blocks/phone-numbers/recipes/04-register-text-messaging.ts +39 -0
- package/blocks/pickers/README.md +23 -0
- package/blocks/pickers/block.json +103 -0
- package/blocks/pickers/components/company-picker.tsx +90 -0
- package/blocks/pickers/components/contact-picker.tsx +131 -0
- package/blocks/pickers/components/member-picker.tsx +99 -0
- package/blocks/pickers/components/search-picker.tsx +98 -0
- package/blocks/pickers/hooks/use-current-member.ts +104 -0
- package/blocks/pickers/hooks/use-picker-pages.ts +73 -0
- package/blocks/pickers/lib/use-debounced.ts +13 -0
- package/blocks/quotes/README.md +13 -0
- package/blocks/quotes/block.json +48 -1
- package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
- package/blocks/quotes/components/quote-builder.tsx +1 -1
- package/blocks/quotes/components/quote-lifecycle-actions.tsx +146 -0
- package/blocks/quotes/components/quote-list.tsx +5 -3
- package/blocks/quotes/components/quote-related.tsx +3 -2
- package/blocks/quotes/components/quote-viewer.tsx +7 -2
- package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
- package/blocks/radar/components/competitor-battle-card.tsx +1 -1
- package/blocks/radar/components/radar-overview.tsx +4 -4
- package/blocks/radar/components/radar-screen.tsx +2 -2
- package/blocks/sequencer/README.md +6 -0
- package/blocks/sequencer/block.json +28 -1
- package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +2 -2
- package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
- package/blocks/sequencer/components/sequence-builder.tsx +19 -20
- package/blocks/sequencer/components/sequence-detail.tsx +4 -2
- package/blocks/sequencer/components/sequence-enrollments.tsx +4 -2
- package/blocks/sequencer/components/sequence-list.tsx +1 -1
- package/blocks/sequencer/components/sequence-stats.tsx +8 -8
- package/blocks/sequencer/components/sequences-screen.tsx +3 -0
- package/blocks/sequencer/components/step-editor.tsx +11 -11
- package/blocks/sequencer/lib/sequencer-utils.ts +5 -5
- package/blocks/social-ads/README.md +8 -0
- package/blocks/social-ads/block.json +137 -4
- package/blocks/social-ads/components/audience-sync-detail.tsx +9 -3
- package/blocks/social-ads/components/audience-sync.tsx +6 -2
- package/blocks/social-ads/components/campaign-ads.tsx +166 -0
- package/blocks/social-ads/components/campaign-detail.tsx +6 -1
- package/blocks/social-ads/components/campaign-editor.tsx +134 -0
- package/blocks/social-ads/components/creative-concepts.tsx +74 -0
- package/blocks/social-ads/components/creative-detail.tsx +6 -1
- package/blocks/social-ads/components/creative-gallery.tsx +20 -2
- package/blocks/social-ads/components/creative-revisions.tsx +57 -0
- package/blocks/social-ads/components/creative-tools.tsx +176 -0
- package/blocks/social-ads/components/post-composer.tsx +4 -2
- package/blocks/social-ads/components/post-detail.tsx +3 -0
- package/blocks/social-ads/components/social-ideas.tsx +64 -0
- package/blocks/social-ads/components/social-performance.tsx +80 -0
- package/blocks/social-ads/components/social-screen.tsx +33 -6
- package/blocks/social-ads/lib/ads-extra.ts +67 -0
- package/blocks/surveys/README.md +91 -0
- package/blocks/surveys/block.json +299 -0
- package/blocks/surveys/components/account-nps.tsx +69 -0
- package/blocks/surveys/components/contact-survey-history.tsx +52 -0
- package/blocks/surveys/components/org-summary.tsx +82 -0
- package/blocks/surveys/components/sending-domains.tsx +113 -0
- package/blocks/surveys/components/survey-form.tsx +126 -0
- package/blocks/surveys/components/survey-responses.tsx +145 -0
- package/blocks/surveys/components/survey-results.tsx +72 -0
- package/blocks/surveys/components/survey-send-history.tsx +58 -0
- package/blocks/surveys/components/survey-send.tsx +162 -0
- package/blocks/surveys/components/survey-triggers.tsx +166 -0
- package/blocks/surveys/components/surveys-screen.tsx +234 -0
- package/blocks/surveys/lib/surveys-types.ts +228 -0
- package/blocks/surveys/recipes/create-an-nps-survey.ts +22 -0
- package/blocks/surveys/recipes/send-a-survey-and-follow-it.ts +27 -0
- package/blocks/surveys/recipes/survey-score-report.ts +27 -0
- package/blocks/visitors/components/snippet-install.tsx +18 -9
- package/blocks/visitors/components/traffic-chart.tsx +4 -4
- package/blocks/visitors/components/visitor-company-card.tsx +24 -14
- package/blocks/visitors/components/visitor-feed.tsx +5 -5
- package/blocks/webchat/components/chat-widget-config.tsx +1 -1
- package/blocks/webchat/components/conversation-list.tsx +1 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +1 -1
- package/blocks/workbench/components/run-progress.tsx +1 -1
- package/blocks/workbench/components/workbench-grid.tsx +1 -1
- package/blocks/workbench/components/workbench-screen.tsx +15 -5
- package/blocks/workbench/components/workbench-table-list.tsx +14 -2
- package/blocks/workflows/components/workflow-editor.tsx +1 -1
- package/dist/cli.js +536 -392
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/brand.d.ts +11 -2
- package/dist/types/blocks/create.d.ts +7 -3
- package/dist/types/blocks/doctor.d.ts +1 -0
- package/dist/types/blocks/flows.d.ts +7 -1
- package/dist/types/blocks/orgBrand.d.ts +34 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/version.d.ts +2 -2
- package/docs/api/desk.d.ts +2 -2
- package/docs/api/desk.md +3 -3
- package/package.json +1 -1
- package/stacks/6sense/pages/companies.tsx.tmpl +21 -0
- package/stacks/6sense/pages/contacts.tsx.tmpl +19 -0
- package/stacks/6sense/pages/tasks.tsx.tmpl +19 -0
- package/stacks/6sense.json +17 -4
- package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
- package/stacks/apollo/pages/companies.tsx.tmpl +19 -36
- package/stacks/apollo/pages/contacts.tsx.tmpl +29 -95
- package/stacks/apollo/pages/deals.tsx.tmpl +17 -0
- package/stacks/apollo/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/apollo/pages/lists.tsx.tmpl +12 -0
- package/stacks/apollo/pages/meetings.tsx.tmpl +13 -0
- package/stacks/apollo/pages/sequences.tsx.tmpl +12 -0
- package/stacks/apollo/pages/tasks.tsx.tmpl +20 -0
- package/stacks/apollo.json +36 -8
- package/stacks/calendly/pages/companies.tsx.tmpl +19 -0
- package/stacks/calendly/pages/contacts.tsx.tmpl +21 -0
- package/stacks/calendly/pages/meetings.tsx.tmpl +13 -0
- package/stacks/calendly/pages/tasks.tsx.tmpl +19 -0
- package/stacks/calendly.json +8 -4
- package/stacks/clay/pages/companies.tsx.tmpl +17 -1
- package/stacks/clay/pages/contacts.tsx.tmpl +12 -0
- package/stacks/clay/pages/lists.tsx.tmpl +12 -0
- package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
- package/stacks/clay.json +16 -3
- package/stacks/gong/pages/calls.tsx.tmpl +23 -0
- package/stacks/gong/pages/companies.tsx.tmpl +22 -0
- package/stacks/gong/pages/contacts.tsx.tmpl +32 -0
- package/stacks/gong/pages/lists.tsx.tmpl +12 -0
- package/stacks/gong/pages/meetings.tsx.tmpl +13 -0
- package/stacks/gong/pages/tasks.tsx.tmpl +19 -0
- package/stacks/gong.json +23 -6
- package/stacks/hootsuite/pages/companies.tsx.tmpl +22 -0
- package/stacks/hootsuite/pages/contacts.tsx.tmpl +28 -0
- package/stacks/hootsuite/pages/lists.tsx.tmpl +12 -0
- package/stacks/hootsuite/pages/tasks.tsx.tmpl +19 -0
- package/stacks/hootsuite.json +9 -4
- package/stacks/hubspot/pages/companies.tsx.tmpl +14 -35
- package/stacks/hubspot/pages/contacts.tsx.tmpl +29 -95
- package/stacks/hubspot/pages/deals.tsx.tmpl +10 -1
- package/stacks/hubspot/pages/inbox.tsx.tmpl +12 -1
- package/stacks/hubspot/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/hubspot/pages/lists.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/meetings.tsx.tmpl +13 -0
- package/stacks/hubspot/pages/sequences.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
- package/stacks/hubspot.json +21 -5
- package/stacks/intercom/pages/companies.tsx.tmpl +19 -0
- package/stacks/intercom/pages/contacts.tsx.tmpl +21 -0
- package/stacks/intercom/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/intercom/pages/tasks.tsx.tmpl +19 -0
- package/stacks/intercom.json +8 -4
- package/stacks/mailchimp/pages/companies.tsx.tmpl +22 -0
- package/stacks/mailchimp/pages/contacts.tsx.tmpl +28 -0
- package/stacks/mailchimp/pages/lists.tsx.tmpl +12 -0
- package/stacks/mailchimp/pages/tasks.tsx.tmpl +19 -0
- package/stacks/mailchimp.json +20 -5
- package/stacks/outreach/pages/calls.tsx.tmpl +23 -0
- package/stacks/outreach/pages/companies.tsx.tmpl +22 -0
- package/stacks/outreach/pages/contacts.tsx.tmpl +27 -66
- package/stacks/outreach/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/outreach/pages/lists.tsx.tmpl +12 -0
- package/stacks/outreach/pages/meetings.tsx.tmpl +13 -0
- package/stacks/outreach/pages/sequences.tsx.tmpl +12 -0
- package/stacks/outreach/pages/tasks.tsx.tmpl +19 -0
- package/stacks/outreach.json +25 -7
- package/stacks/pipedrive/pages/companies.tsx.tmpl +11 -35
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +15 -35
- package/stacks/pipedrive/pages/deals.tsx.tmpl +10 -1
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +12 -1
- package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
- package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
- package/stacks/pipedrive.json +4 -2
- package/stacks/salesforce/pages/companies.tsx.tmpl +14 -35
- package/stacks/salesforce/pages/contacts.tsx.tmpl +20 -35
- package/stacks/salesforce/pages/deals.tsx.tmpl +10 -1
- package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
- package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
- package/stacks/salesforce.json +7 -3
- package/stacks/zoominfo/pages/companies.tsx.tmpl +17 -1
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +12 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +12 -0
- package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +16 -3
- package/styles/styles.css +1 -1
- package/templates/next-app/app/globals.css.tmpl +349 -64
- package/templates/next-app/components/app-shell.tsx.tmpl +6 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WorkspaceTools: Slack, Google Workspace and Roam, each as a card with its live status. They connect through a sign-in screen that only runs
|
|
5
|
+
* in the graph8 app, so a tool that is not connected shows a "Connect in graph8" link, never a dead button. Connected tools show what the
|
|
6
|
+
* status reports. Google Workspace also has "Test connection" (a live check) and a Drive browser (folders one level at a time with their
|
|
7
|
+
* files; "Load more" follows Google's page token) for confirming which folders graph8 can read.
|
|
8
|
+
* All reads. Nothing here changes a connection.
|
|
9
|
+
*
|
|
10
|
+
* SDK operations, in order:
|
|
11
|
+
* 1. getSlackStatus / getGoogleWorkspaceStatus / getRoamStatus GET /integrations/slack/status, /integrations/google-workspace, /integrations/roam/status (read)
|
|
12
|
+
* 2. testGoogleWorkspaceConnection POST /integrations/google-workspace/test (read)
|
|
13
|
+
* 3. listGoogleDriveFolders GET /integrations/google-workspace/folders?parent_id&view&page_size&page_token (read)
|
|
14
|
+
* 4. listGoogleDriveFolderFiles GET /integrations/google-workspace/folders/{folder_id}/files?page_size&page_token (read)
|
|
15
|
+
*/
|
|
16
|
+
import { useEffect, useState } from "react";
|
|
17
|
+
import { Folder } from "lucide-react";
|
|
18
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
19
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
20
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
21
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
22
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
23
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
24
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
25
|
+
import { brandUrl, useBrand } from "@/g8/hooks/use-brand";
|
|
26
|
+
import { FactList } from "../lib/facts";
|
|
27
|
+
import { GRAPH8_APP_INTEGRATIONS_PATH, looksConnected } from "../lib/integrations-utils";
|
|
28
|
+
|
|
29
|
+
export interface WorkspaceToolsProps {
|
|
30
|
+
/** Where "Connect" sends people. Default: graph8 Settings > Integrations. */
|
|
31
|
+
connectUrl?: string;
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface DriveFolder { id: string; name: string }
|
|
36
|
+
interface DriveFile { id: string; name: string; mimeType?: string; size?: string | number | null }
|
|
37
|
+
const TOOLS = [
|
|
38
|
+
{ key: "slack", label: "Slack", path: "/integrations/slack/status", note: "Send alerts and notifications to Slack channels." },
|
|
39
|
+
{ key: "google", label: "Google Workspace", path: "/integrations/google-workspace", note: "Read Drive folders and use your Workspace account." },
|
|
40
|
+
{ key: "roam", label: "Roam", path: "/integrations/roam/status", note: "Post to Roam groups and users." },
|
|
41
|
+
] as const;
|
|
42
|
+
const enc = encodeURIComponent;
|
|
43
|
+
type Query = { data: unknown; error: unknown; loading: boolean; refetch: () => Promise<unknown> };
|
|
44
|
+
|
|
45
|
+
/** One paged Drive list. Folders and files are separate queries, so each keeps its own page token (a token only works for the query that issued it). */
|
|
46
|
+
function usePagedDrive<T>(path: string, key: string, field: "folders" | "files", extraQuery: Record<string, string | number>, enabled: boolean) {
|
|
47
|
+
const [acc, setAcc] = useState<T[]>([]);
|
|
48
|
+
const [token, setToken] = useState<string | undefined>();
|
|
49
|
+
const [next, setNext] = useState<string | undefined>();
|
|
50
|
+
useEffect(() => { setAcc([]); setToken(undefined); setNext(undefined); }, [key]);
|
|
51
|
+
const q = useG8Query((signal) => g8Get(path, { ...extraQuery, page_size: 50, page_token: token }, { signal }), [key, token], { enabled });
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const d = (q.data as { data?: Record<string, unknown> } | undefined)?.data;
|
|
54
|
+
if (!d || !Array.isArray(d[field])) return;
|
|
55
|
+
setAcc((prev) => (token ? [...prev, ...(d[field] as T[])] : (d[field] as T[])));
|
|
56
|
+
setNext((d.next_page_token as string | null | undefined) ?? undefined);
|
|
57
|
+
}, [q.data]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
58
|
+
return { items: acc, next, loading: q.loading, error: q.error, refetch: q.refetch, more: () => next && setToken(next) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function DriveBrowser() {
|
|
62
|
+
const [trail, setTrail] = useState<DriveFolder[]>([]);
|
|
63
|
+
const parent = trail[trail.length - 1]?.id ?? "root";
|
|
64
|
+
const folders = usePagedDrive<DriveFolder>("/integrations/google-workspace/folders", parent, "folders", { parent_id: parent, view: "my_drive" }, true);
|
|
65
|
+
const files = usePagedDrive<DriveFile>(`/integrations/google-workspace/folders/${enc(parent)}/files`, parent, "files", {}, parent !== "root");
|
|
66
|
+
const go = (t: DriveFolder[]) => setTrail(t);
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex flex-col gap-2">
|
|
69
|
+
<nav aria-label="Folder path" className="flex flex-wrap items-center gap-1 text-sm">
|
|
70
|
+
<button type="button" className="underline" onClick={() => go([])}>My Drive</button>
|
|
71
|
+
{trail.map((f, i) => <span key={f.id}> / <button type="button" className="underline" onClick={() => go(trail.slice(0, i + 1))}>{f.name}</button></span>)}
|
|
72
|
+
</nav>
|
|
73
|
+
{folders.error ? <ApiError error={folders.error} title="Could not list the folders" onRetry={() => void folders.refetch()} /> : null}
|
|
74
|
+
{(folders.loading && folders.items.length === 0) ? <Skeleton className="h-10 w-full" /> : null}
|
|
75
|
+
<ul className="divide-y rounded-md border text-sm" aria-label="Folders">
|
|
76
|
+
{folders.items.map((f) => <li key={f.id}><button type="button" className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent" onClick={() => go([...trail, f])}><Folder className="size-4" aria-hidden />{f.name}</button></li>)}
|
|
77
|
+
</ul>
|
|
78
|
+
{folders.next ? <Button size="sm" variant="outline" className="self-start" loading={folders.loading} onClick={folders.more}>Load more folders</Button> : null}
|
|
79
|
+
{parent !== "root" ? (
|
|
80
|
+
<>
|
|
81
|
+
{files.error ? <ApiError compact error={files.error} title="Could not list the files" onRetry={() => void files.refetch()} /> : null}
|
|
82
|
+
<ul className="divide-y rounded-md border text-sm" aria-label="Files">
|
|
83
|
+
{files.items.map((f) => <li key={f.id} className="flex justify-between px-3 py-1.5"><span>{f.name}</span><span className="text-xs text-muted-foreground">{f.mimeType?.split(".").pop()}</span></li>)}
|
|
84
|
+
</ul>
|
|
85
|
+
{files.next ? <Button size="sm" variant="outline" className="self-start" loading={files.loading} onClick={files.more}>Load more files</Button> : null}
|
|
86
|
+
</>
|
|
87
|
+
) : null}
|
|
88
|
+
{!folders.loading && !folders.error && folders.items.length === 0 && (parent === "root" || (!files.loading && files.items.length === 0)) ? <p className="text-sm text-muted-foreground">This folder is empty.</p> : null}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ToolCard({ tool, connectUrl }: { tool: (typeof TOOLS)[number]; connectUrl: string }) {
|
|
94
|
+
const brand = useBrand();
|
|
95
|
+
const [drive, setDrive] = useState(false);
|
|
96
|
+
const status = useG8Query((signal) => g8Get(tool.path, undefined, { signal }), [tool.path]);
|
|
97
|
+
const test = useG8Mutation(() => g8Post("/integrations/google-workspace/test", {}));
|
|
98
|
+
const connected = looksConnected(status.data);
|
|
99
|
+
const verdict = (test.data as { data?: { success?: boolean; ok?: boolean; message?: string; error?: string } } | undefined)?.data;
|
|
100
|
+
const body = ((status.data as { data?: Record<string, unknown> } | undefined)?.data ?? {}) as Record<string, unknown>;
|
|
101
|
+
const facts = Object.fromEntries(Object.entries(body).filter(([k]) => !["connected", "is_connected"].includes(k)));
|
|
102
|
+
return (
|
|
103
|
+
<SectionCard title={tool.label} description={tool.note} actions={status.data ? <StatusPill tone={connected ? "positive" : "neutral"} dot>{connected ? "Connected" : "Not connected"}</StatusPill> : undefined}>
|
|
104
|
+
{status.error ? <ApiError error={status.error} title={`Could not check ${tool.label}`} onRetry={() => void status.refetch()} /> : null}
|
|
105
|
+
{status.loading && !status.data ? <Skeleton className="h-10 w-full" /> : null}
|
|
106
|
+
{status.data && !connected ? <a className="text-sm underline" href={connectUrl} target="_blank" rel="noreferrer">Connect {tool.label} in {brand.linkLabel}</a> : null}
|
|
107
|
+
{connected ? <FactList data={facts} empty="Connected." /> : null}
|
|
108
|
+
{connected && tool.key === "google" ? (
|
|
109
|
+
<div className="mt-3 flex flex-col gap-2">
|
|
110
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
111
|
+
<Button size="sm" variant="outline" loading={test.loading} onClick={() => void test.mutate(undefined)}>Test connection</Button>
|
|
112
|
+
<Button size="sm" variant="outline" aria-expanded={drive} onClick={() => setDrive((v) => !v)}>{drive ? "Hide Drive" : "Browse Drive"}</Button>
|
|
113
|
+
{test.data !== undefined ? <span role="status" className="text-sm" data-testid="google-test">{verdict?.success === false || verdict?.ok === false ? `The test failed${verdict.message || verdict.error ? `: ${verdict.message ?? verdict.error}` : ""}` : "Google answered: the connection works."}</span> : null}
|
|
114
|
+
</div>
|
|
115
|
+
{test.error ? <ApiError error={test.error} title="The test could not run" /> : null}
|
|
116
|
+
{drive ? <DriveBrowser /> : null}
|
|
117
|
+
</div>
|
|
118
|
+
) : null}
|
|
119
|
+
</SectionCard>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function WorkspaceTools({ connectUrl: connectUrlProp, className }: WorkspaceToolsProps) {
|
|
124
|
+
const brand = useBrand();
|
|
125
|
+
const connectUrl = connectUrlProp ?? brandUrl(brand, GRAPH8_APP_INTEGRATIONS_PATH);
|
|
126
|
+
return <div className={`grid gap-4 md:grid-cols-2 ${className ?? ""}`}>{TOOLS.map((t) => <ToolCard key={t.key} tool={t} connectUrl={connectUrl} />)}</div>;
|
|
127
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/** Helpers for reading records that come back in a CRM's own shape (HubSpot properties, Salesforce fields, Pipedrive values). */
|
|
4
|
+
|
|
5
|
+
export type CrmRecord = Record<string, unknown> & { id?: string | number; properties?: Record<string, unknown> };
|
|
6
|
+
|
|
7
|
+
const pick = (r: CrmRecord, keys: string[]): string => {
|
|
8
|
+
const sources: Array<Record<string, unknown>> = [r, (r.properties ?? {}) as Record<string, unknown>, ((r as { attributes?: Record<string, unknown> }).attributes ?? {}) as Record<string, unknown>];
|
|
9
|
+
for (const src of sources) for (const k of keys) { const v = src[k]; if (typeof v === "string" && v.trim()) return v; if (typeof v === "number") return String(v); }
|
|
10
|
+
return "";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** A readable name for a CRM record of any kind. */
|
|
14
|
+
export function crmRecordTitle(r: CrmRecord): string {
|
|
15
|
+
const first = pick(r, ["firstname", "first_name", "FirstName"]);
|
|
16
|
+
const last = pick(r, ["lastname", "last_name", "LastName"]);
|
|
17
|
+
return pick(r, ["name", "full_name", "Name", "dealname", "title", "subject"]) || [first, last].filter(Boolean).join(" ") || pick(r, ["email", "Email", "domain", "company"]) || String(r.id ?? "Record");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function crmRecordSubtitle(r: CrmRecord): string {
|
|
21
|
+
return [pick(r, ["email", "Email"]), pick(r, ["company", "Company", "domain"]), pick(r, ["stage", "dealstage", "StageName", "status", "Status"])].filter(Boolean).join(" - ");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The record's own scalar fields (properties flattened in), for a detail view. Objects and empty values are left out. */
|
|
25
|
+
export function flattenRecord(r: CrmRecord): Record<string, string> {
|
|
26
|
+
const out: Record<string, string> = {};
|
|
27
|
+
const add = (src: Record<string, unknown>) => { for (const [k, v] of Object.entries(src)) if ((typeof v === "string" && v) || typeof v === "number" || typeof v === "boolean") out[k] = String(v); };
|
|
28
|
+
add(r);
|
|
29
|
+
add((r.properties ?? {}) as Record<string, unknown>);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const CRM_ENTITIES = [
|
|
34
|
+
{ id: "contacts", label: "Contacts" },
|
|
35
|
+
{ id: "companies", label: "Companies" },
|
|
36
|
+
{ id: "deals", label: "Deals" },
|
|
37
|
+
{ id: "leads", label: "Leads" },
|
|
38
|
+
] as const;
|
|
39
|
+
export type CrmEntity = (typeof CRM_ENTITIES)[number]["id"];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/** Renders an open-shaped API object (health, scorecard, status) as labelled facts without guessing its fields. */
|
|
4
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
5
|
+
|
|
6
|
+
const label = (k: string): string => { const t = k.replace(/_/g, " "); return t.charAt(0).toUpperCase() + t.slice(1); };
|
|
7
|
+
const show = (v: unknown): string => (v == null ? "" : typeof v === "string" ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : JSON.stringify(v));
|
|
8
|
+
|
|
9
|
+
export function FactList({ data, empty = "Nothing reported yet." }: { data: unknown; empty?: string }) {
|
|
10
|
+
const entries = Object.entries((data && typeof data === "object" && !Array.isArray(data) ? data : {}) as Record<string, unknown>).filter(([, v]) => v != null && v !== "");
|
|
11
|
+
if (entries.length === 0) return <p className="text-sm text-muted-foreground">{empty}</p>;
|
|
12
|
+
return <KeyValueList columns={2} items={entries.map(([k, v]) => ({ label: label(k), value: show(v), wide: typeof v === "object", mono: typeof v === "object" }))} />;
|
|
13
|
+
}
|
|
@@ -7,6 +7,11 @@ import { DEFAULT_BRAND } from "@/g8/hooks/use-brand";
|
|
|
7
7
|
export const CRM_PROVIDERS = ["hubspot", "salesforce", "pipedrive", "zoho", "sugarcrm"] as const;
|
|
8
8
|
export type CrmProvider = (typeof CRM_PROVIDERS)[number];
|
|
9
9
|
|
|
10
|
+
/** The connection type the API expects (`CrmConnectionType`): Zoho is `zoho-crm`, the others are their own id. */
|
|
11
|
+
export function crmType(provider: string): string {
|
|
12
|
+
return provider === "zoho" ? "zoho-crm" : provider;
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
export const PROVIDER_LABELS: Record<CrmProvider, string> = {
|
|
11
16
|
hubspot: "HubSpot",
|
|
12
17
|
salesforce: "Salesforce",
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Paging helpers for the agents block, in the same shape as the foundation's paging primitives (a list never stops silently at a
|
|
5
|
+
* cap: it shows "Load more" while the last page was full).
|
|
6
|
+
*
|
|
7
|
+
* useLoadMoreList offset + limit APIs (the next page starts at items.length).
|
|
8
|
+
* useGrowingList limit-only APIs (a bigger limit refetches the list from the start).
|
|
9
|
+
* LoadMore the footer button plus the error of a failed "more" request.
|
|
10
|
+
*/
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
12
|
+
import { Button } from "@graph8/sdk/ui";
|
|
13
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
14
|
+
|
|
15
|
+
export interface LoadMoreList<T> {
|
|
16
|
+
items: T[];
|
|
17
|
+
/** The first page is loading (or reloading after the deps changed). */
|
|
18
|
+
loading: boolean;
|
|
19
|
+
loadingMore: boolean;
|
|
20
|
+
error: unknown;
|
|
21
|
+
moreError: unknown;
|
|
22
|
+
hasMore: boolean;
|
|
23
|
+
/** A limit-only API stopped at its own maximum: there are more rows the API will not return. */
|
|
24
|
+
capped?: boolean;
|
|
25
|
+
loadMore: () => void;
|
|
26
|
+
reload: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Options {
|
|
30
|
+
pageSize?: number;
|
|
31
|
+
enabled?: boolean;
|
|
32
|
+
/** useGrowingList: the largest `limit` the API accepts. */
|
|
33
|
+
max?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Offset paging. `fetchPage(offset, limit)` returns that page's rows; a full page means there may be more. */
|
|
37
|
+
export function useLoadMoreList<T>(
|
|
38
|
+
fetchPage: (offset: number, limit: number, signal: AbortSignal) => Promise<T[]>,
|
|
39
|
+
deps: readonly unknown[],
|
|
40
|
+
{ pageSize = 50, enabled = true }: Options = {},
|
|
41
|
+
): LoadMoreList<T> {
|
|
42
|
+
const [items, setItems] = useState<T[]>([]);
|
|
43
|
+
const [loading, setLoading] = useState(enabled);
|
|
44
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
45
|
+
const [error, setError] = useState<unknown>(null);
|
|
46
|
+
const [moreError, setMoreError] = useState<unknown>(null);
|
|
47
|
+
const [hasMore, setHasMore] = useState(false);
|
|
48
|
+
const [tick, setTick] = useState(0);
|
|
49
|
+
const fetchRef = useRef(fetchPage);
|
|
50
|
+
fetchRef.current = fetchPage;
|
|
51
|
+
const gen = useRef(0);
|
|
52
|
+
const count = useRef(0);
|
|
53
|
+
const sig = JSON.stringify([...deps, enabled, pageSize]);
|
|
54
|
+
const lastSig = useRef(sig);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!enabled) { setLoading(false); return; }
|
|
58
|
+
const mine = ++gen.current;
|
|
59
|
+
const ctl = new AbortController();
|
|
60
|
+
// A reload() keeps showing the rows it has until the fresh first page lands; a changed filter starts from nothing.
|
|
61
|
+
const fresh = lastSig.current !== sig;
|
|
62
|
+
lastSig.current = sig;
|
|
63
|
+
setLoading(true); setError(null); setMoreError(null);
|
|
64
|
+
if (fresh) { setItems([]); setHasMore(false); count.current = 0; }
|
|
65
|
+
// A reload() refetches as many pages as were showing, so a row opened from page 2 does not vanish; a changed filter starts at page 1.
|
|
66
|
+
const want = fresh ? pageSize : Math.max(count.current, pageSize);
|
|
67
|
+
const load = async (): Promise<T[]> => {
|
|
68
|
+
const all: T[] = [];
|
|
69
|
+
let last = pageSize;
|
|
70
|
+
while (all.length < want && last >= pageSize) {
|
|
71
|
+
const page = await fetchRef.current(all.length, pageSize, ctl.signal);
|
|
72
|
+
all.push(...page);
|
|
73
|
+
last = page.length;
|
|
74
|
+
}
|
|
75
|
+
return all;
|
|
76
|
+
};
|
|
77
|
+
load().then((rows) => {
|
|
78
|
+
if (gen.current !== mine) return;
|
|
79
|
+
setItems(rows); count.current = rows.length; setHasMore(rows.length > 0 && rows.length % pageSize === 0); setLoading(false);
|
|
80
|
+
}, (e) => { if (gen.current === mine) { setError(e); setLoading(false); } });
|
|
81
|
+
return () => ctl.abort();
|
|
82
|
+
}, [...deps, enabled, pageSize, tick]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
83
|
+
|
|
84
|
+
const loadMore = useCallback(() => {
|
|
85
|
+
const mine = gen.current;
|
|
86
|
+
setLoadingMore(true); setMoreError(null);
|
|
87
|
+
fetchRef.current(count.current, pageSize, new AbortController().signal).then((rows) => {
|
|
88
|
+
if (gen.current !== mine) return;
|
|
89
|
+
count.current += rows.length;
|
|
90
|
+
setItems((prev) => [...prev, ...rows]); setHasMore(rows.length >= pageSize); setLoadingMore(false);
|
|
91
|
+
}, (e) => { if (gen.current === mine) { setMoreError(e); setLoadingMore(false); } });
|
|
92
|
+
}, [pageSize]);
|
|
93
|
+
|
|
94
|
+
return { items, loading, loadingMore, error, moreError, hasMore, loadMore, reload: () => setTick((t) => t + 1) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Limit-only APIs: asks for `step` rows, and "Load more" asks again for `step` more from the start. */
|
|
98
|
+
export function useGrowingList<T>(
|
|
99
|
+
fetchRows: (limit: number, signal: AbortSignal) => Promise<T[]>,
|
|
100
|
+
deps: readonly unknown[],
|
|
101
|
+
{ pageSize = 50, enabled = true, max = Infinity }: Options = {},
|
|
102
|
+
): LoadMoreList<T> {
|
|
103
|
+
const [limit, setLimit] = useState(pageSize);
|
|
104
|
+
const [rows, setRows] = useState<T[]>([]);
|
|
105
|
+
const [loading, setLoading] = useState(enabled);
|
|
106
|
+
const [error, setError] = useState<unknown>(null);
|
|
107
|
+
const [tick, setTick] = useState(0);
|
|
108
|
+
const [growing, setGrowing] = useState(false);
|
|
109
|
+
const fetchRef = useRef(fetchRows);
|
|
110
|
+
fetchRef.current = fetchRows;
|
|
111
|
+
const depKey = JSON.stringify(deps);
|
|
112
|
+
const lastDeps = useRef(depKey);
|
|
113
|
+
|
|
114
|
+
useEffect(() => { if (lastDeps.current !== depKey) { lastDeps.current = depKey; setLimit(pageSize); } }, [depKey, pageSize]);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!enabled) { setLoading(false); return; }
|
|
117
|
+
let live = true;
|
|
118
|
+
const ctl = new AbortController();
|
|
119
|
+
setLoading(true); setError(null);
|
|
120
|
+
fetchRef.current(limit, ctl.signal).then((r) => { if (live) { setRows(r); setLoading(false); setGrowing(false); } }, (e) => { if (live) { setError(e); setLoading(false); setGrowing(false); } });
|
|
121
|
+
return () => { live = false; ctl.abort(); };
|
|
122
|
+
}, [limit, depKey, enabled, tick]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
items: rows, loading: loading && !growing, loadingMore: loading && growing, error: growing ? null : error, moreError: growing ? error : null,
|
|
126
|
+
hasMore: !loading && rows.length >= limit && limit < max, capped: !loading && rows.length >= limit && limit >= max,
|
|
127
|
+
loadMore: () => { setGrowing(true); setLimit((l) => Math.min(max, l + pageSize)); }, reload: () => setTick((t) => t + 1),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The footer of a paged list. Renders nothing when everything is shown. */
|
|
132
|
+
export function LoadMore<T>({ list, noun = "rows" }: { list: LoadMoreList<T>; noun?: string }) {
|
|
133
|
+
if (!list.hasMore && !list.moreError && !list.capped) return null;
|
|
134
|
+
return (
|
|
135
|
+
<div className="flex flex-col items-center gap-2 py-2">
|
|
136
|
+
{list.moreError ? <ApiError compact error={list.moreError} title={`Could not load more ${noun}`} onRetry={list.loadMore} /> : null}
|
|
137
|
+
{list.capped ? <p className="text-xs text-muted-foreground">Showing the latest {list.items.length} {noun}. The API returns no more than this.</p> : null}
|
|
138
|
+
{list.hasMore && !list.moreError ? <Button size="sm" variant="outline" loading={list.loadingMore} onClick={list.loadMore}>Load more {noun}</Button> : null}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -36,7 +36,7 @@ function Coverage({ account }: { account: AbmAccount }) {
|
|
|
36
36
|
<li
|
|
37
37
|
key={category}
|
|
38
38
|
title={`${cov.contact_count ?? 0} contacts, ${cov.signal_count ?? 0} signals`}
|
|
39
|
-
className="rounded border border-
|
|
39
|
+
className="rounded border border-border px-1.5 py-0.5 text-xs text-foreground"
|
|
40
40
|
>
|
|
41
41
|
{category}: {cov.barometer ?? "none"}
|
|
42
42
|
</li>
|
|
@@ -52,9 +52,9 @@ function Badge({ account, compact }: { account: AbmAccount; compact?: boolean })
|
|
|
52
52
|
<div className="flex items-center gap-2">
|
|
53
53
|
<span className={`rounded px-2 py-0.5 text-xs font-medium ${strengthClass(account.account_strength)}`}>{label}</span>
|
|
54
54
|
{account.is_high_intent ? (
|
|
55
|
-
<span className="rounded bg-
|
|
55
|
+
<span className="rounded bg-error px-2 py-0.5 text-xs font-medium text-error-foreground">High intent</span>
|
|
56
56
|
) : null}
|
|
57
|
-
<span className="text-xs text-
|
|
57
|
+
<span className="text-xs text-muted-foreground">{account.total_signal_count ?? 0} signals</span>
|
|
58
58
|
</div>
|
|
59
59
|
{compact ? null : <Coverage account={account} />}
|
|
60
60
|
</div>
|
|
@@ -69,7 +69,7 @@ export function AccountIntentScore({ companyIds, days = 30, compact, className }
|
|
|
69
69
|
const status = (error as { status?: number }).status;
|
|
70
70
|
const text = status === 403 ? "Intent scoring is not enabled for this workspace." : "Could not load intent.";
|
|
71
71
|
return (
|
|
72
|
-
<span role="alert" className={`text-xs text-
|
|
72
|
+
<span role="alert" className={`text-xs text-error-foreground ${className ?? ""}`}>
|
|
73
73
|
{text}
|
|
74
74
|
</span>
|
|
75
75
|
);
|
|
@@ -81,7 +81,7 @@ export function AccountIntentScore({ companyIds, days = 30, compact, className }
|
|
|
81
81
|
return account ? (
|
|
82
82
|
<Badge key={id} account={{ ...account, company_id: id }} compact={compact} />
|
|
83
83
|
) : (
|
|
84
|
-
<span key={id} className="block text-xs text-
|
|
84
|
+
<span key={id} className="block text-xs text-muted-foreground">
|
|
85
85
|
No signal
|
|
86
86
|
</span>
|
|
87
87
|
);
|
|
@@ -59,7 +59,7 @@ function UrlLookup() {
|
|
|
59
59
|
{ enabled: submitted != null },
|
|
60
60
|
);
|
|
61
61
|
return (
|
|
62
|
-
<section aria-label="URL lookup" className="rounded border border-
|
|
62
|
+
<section aria-label="URL lookup" className="rounded border border-border p-3">
|
|
63
63
|
<form
|
|
64
64
|
className="flex gap-2"
|
|
65
65
|
onSubmit={(e) => {
|
|
@@ -76,13 +76,13 @@ function UrlLookup() {
|
|
|
76
76
|
{result.error ? <ApiError error={result.error} onRetry={() => void result.refetch()} /> : null}
|
|
77
77
|
{submitted && !result.loading && !result.error ? (
|
|
78
78
|
(result.data ?? []).length === 0 ? (
|
|
79
|
-
<p className="mt-3 text-sm text-
|
|
79
|
+
<p className="mt-3 text-sm text-muted-foreground">No identified companies visited this URL in the last 30 days.</p>
|
|
80
80
|
) : (
|
|
81
81
|
<ul className="mt-3 divide-y text-sm">
|
|
82
82
|
{(result.data ?? []).map((c) => (
|
|
83
83
|
<li key={`${c.company_id}-${c.domain}`} className="flex justify-between py-1.5">
|
|
84
84
|
<span>{c.name ?? c.domain}</span>
|
|
85
|
-
<span className="text-
|
|
85
|
+
<span className="text-muted-foreground">
|
|
86
86
|
{c.visit_count} visits, {c.contacts_seen} people
|
|
87
87
|
</span>
|
|
88
88
|
</li>
|
|
@@ -132,7 +132,7 @@ export function IntentFeed({ days = 30, keywordIds, pageSize = 25, refreshSecond
|
|
|
132
132
|
</div>
|
|
133
133
|
|
|
134
134
|
{feed.error && status === 403 ? (
|
|
135
|
-
<p role="alert" className="rounded border border-
|
|
135
|
+
<p role="alert" className="rounded border border-warning-foreground/25 bg-warning p-3 text-sm text-warning-foreground">
|
|
136
136
|
Account intent is not enabled for this workspace. Ask {brand.linkLabel} to switch on ABM intent.
|
|
137
137
|
</p>
|
|
138
138
|
) : feed.error ? (
|
|
@@ -167,16 +167,16 @@ export function IntentFeed({ days = 30, keywordIds, pageSize = 25, refreshSecond
|
|
|
167
167
|
>
|
|
168
168
|
<TableCell>
|
|
169
169
|
<div className="font-medium">{a.company_name ?? a.company_domain ?? "Unknown company"}</div>
|
|
170
|
-
<div className="text-xs text-
|
|
170
|
+
<div className="text-xs text-muted-foreground">{a.company_domain}</div>
|
|
171
171
|
</TableCell>
|
|
172
172
|
<TableCell>
|
|
173
173
|
<span className={`rounded px-2 py-0.5 text-xs ${strengthClass(a.account_strength)}`}>{a.account_strength ?? "-"}</span>
|
|
174
|
-
{a.is_high_intent ? <span className="ml-1 text-xs font-medium text-
|
|
174
|
+
{a.is_high_intent ? <span className="ml-1 text-xs font-medium text-error-foreground">High intent</span> : null}
|
|
175
175
|
</TableCell>
|
|
176
176
|
<TableCell>
|
|
177
177
|
{a.total_signal_count ?? 0}
|
|
178
178
|
{typeof a.trend_pct === "number" ? (
|
|
179
|
-
<span className="ml-1 text-xs text-
|
|
179
|
+
<span className="ml-1 text-xs text-muted-foreground">
|
|
180
180
|
({a.trend_pct > 0 ? "+" : ""}
|
|
181
181
|
{Math.round(a.trend_pct)}%)
|
|
182
182
|
</span>
|
|
@@ -195,7 +195,7 @@ export function IntentFeed({ days = 30, keywordIds, pageSize = 25, refreshSecond
|
|
|
195
195
|
</Table>
|
|
196
196
|
)}
|
|
197
197
|
|
|
198
|
-
<div className="flex items-center justify-between text-sm text-
|
|
198
|
+
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
199
199
|
<span>{total != null ? `${total.toLocaleString()} accounts` : ""}</span>
|
|
200
200
|
<div className="flex gap-2">
|
|
201
201
|
<Button variant="outline" size="sm" disabled={page <= 1 || feed.loading} onClick={() => setPage((p) => p - 1)}>
|
|
@@ -185,8 +185,8 @@ export function IntentKeywordManager({ confirmListing = true, pageSize = 50, onS
|
|
|
185
185
|
return (
|
|
186
186
|
<div className={`space-y-4 ${className ?? ""}`}>
|
|
187
187
|
<div className="grid gap-3 md:grid-cols-2">
|
|
188
|
-
<section className="space-y-2 rounded border border-
|
|
189
|
-
<h3 className="text-sm font-
|
|
188
|
+
<section className="space-y-2 rounded border border-border p-3">
|
|
189
|
+
<h3 className="text-sm font-medium">Track keywords</h3>
|
|
190
190
|
<Textarea
|
|
191
191
|
aria-label="Keywords"
|
|
192
192
|
rows={3}
|
|
@@ -198,8 +198,8 @@ export function IntentKeywordManager({ confirmListing = true, pageSize = 50, onS
|
|
|
198
198
|
Track keywords
|
|
199
199
|
</Button>
|
|
200
200
|
</section>
|
|
201
|
-
<section className="space-y-2 rounded border border-
|
|
202
|
-
<h3 className="text-sm font-
|
|
201
|
+
<section className="space-y-2 rounded border border-border p-3">
|
|
202
|
+
<h3 className="text-sm font-medium">Track a competitor domain</h3>
|
|
203
203
|
<Input
|
|
204
204
|
aria-label="Competitor domain"
|
|
205
205
|
placeholder="competitor.com"
|
|
@@ -228,7 +228,7 @@ export function IntentKeywordManager({ confirmListing = true, pageSize = 50, onS
|
|
|
228
228
|
</Button>
|
|
229
229
|
</div>
|
|
230
230
|
{domainResult ? (
|
|
231
|
-
<p className="text-xs text-
|
|
231
|
+
<p className="text-xs text-muted-foreground" role="status">
|
|
232
232
|
{domainResult.dry_run ? "Preview: " : "Saved: "}
|
|
233
233
|
{domainResult.pages_seeded ?? 0} pages, {domainResult.contacts_seeded ?? 0} people, {domainResult.companies_seeded ?? 0}{" "}
|
|
234
234
|
companies. {domainResult.message}
|
|
@@ -301,10 +301,10 @@ export function IntentKeywordManager({ confirmListing = true, pageSize = 50, onS
|
|
|
301
301
|
)}
|
|
302
302
|
|
|
303
303
|
{companies ? (
|
|
304
|
-
<section className="rounded border border-
|
|
305
|
-
<h3 className="text-sm font-
|
|
304
|
+
<section className="rounded border border-border p-3" aria-label="Keyword companies">
|
|
305
|
+
<h3 className="text-sm font-medium">Companies interested in "{companies.keyword}"</h3>
|
|
306
306
|
{companies.rows.length === 0 ? (
|
|
307
|
-
<p className="mt-2 text-sm text-
|
|
307
|
+
<p className="mt-2 text-sm text-muted-foreground">No resolved companies yet.</p>
|
|
308
308
|
) : (
|
|
309
309
|
<ul className="mt-2 grid gap-1 text-sm md:grid-cols-2">
|
|
310
310
|
{companies.rows.map((c, i) => (
|
|
@@ -88,8 +88,8 @@ export function formatDate(value: string | null | undefined): string {
|
|
|
88
88
|
/** Tailwind classes for an account-strength label. Unknown labels stay neutral. */
|
|
89
89
|
export function strengthClass(strength: string | null | undefined): string {
|
|
90
90
|
const s = (strength ?? "").toLowerCase();
|
|
91
|
-
if (s.includes("strong") || s === "high") return "bg-
|
|
92
|
-
if (s.includes("moderate") || s === "medium") return "bg-
|
|
93
|
-
if (s.includes("weak") || s === "low") return "bg-
|
|
94
|
-
return "bg-
|
|
91
|
+
if (s.includes("strong") || s === "high") return "bg-positive text-positive-foreground";
|
|
92
|
+
if (s.includes("moderate") || s === "medium") return "bg-warning text-warning-foreground";
|
|
93
|
+
if (s.includes("weak") || s === "low") return "bg-muted text-foreground";
|
|
94
|
+
return "bg-muted text-foreground";
|
|
95
95
|
}
|
|
@@ -13,6 +13,7 @@ import { useState } from "react";
|
|
|
13
13
|
import { Radar } from "lucide-react";
|
|
14
14
|
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
15
15
|
import { ApiError } from "@/g8/components/api-error";
|
|
16
|
+
import { FirstNotice } from "@/g8/components/paging-notices";
|
|
16
17
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
17
18
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
18
19
|
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
@@ -42,8 +43,11 @@ export function AccountBriefTab({ companyId, name }: { companyId: number; name?:
|
|
|
42
43
|
);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/** The signals API has no paging: a full read is announced under the list. */
|
|
47
|
+
const SIGNAL_LIMIT = 20;
|
|
48
|
+
|
|
45
49
|
export function AccountSignalsTab({ companyId }: { companyId: number }) {
|
|
46
|
-
const q = useG8Query((signal) => g8Get(`/accounts/${companyId}/intent-signals`, { limit:
|
|
50
|
+
const q = useG8Query((signal) => g8Get(`/accounts/${companyId}/intent-signals`, { limit: SIGNAL_LIMIT }, { signal }), [companyId]);
|
|
47
51
|
if (q.error) return <ApiError error={q.error} title="Could not load signals" onRetry={() => void q.refetch()} />;
|
|
48
52
|
if (q.loading && !q.data) return <Skeleton className="h-24 w-full" aria-label="Loading signals" />;
|
|
49
53
|
const payload = body(q.data);
|
|
@@ -51,6 +55,7 @@ export function AccountSignalsTab({ companyId }: { companyId: number }) {
|
|
|
51
55
|
const signals = raw.map(asRec);
|
|
52
56
|
if (signals.length === 0) return <EmptyState icon={<Radar className="size-8" />} title="Quiet" description="No recent buying or activity signals for this account." />;
|
|
53
57
|
return (
|
|
58
|
+
<>
|
|
54
59
|
<ul className="divide-y rounded-md border" aria-label="Signals">
|
|
55
60
|
{signals.map((s, i) => {
|
|
56
61
|
const when = text(s.detected_at ?? s.created_at ?? s.occurred_at);
|
|
@@ -62,5 +67,7 @@ export function AccountSignalsTab({ companyId }: { companyId: number }) {
|
|
|
62
67
|
);
|
|
63
68
|
})}
|
|
64
69
|
</ul>
|
|
70
|
+
<FirstNotice shown={signals.length} limit={SIGNAL_LIMIT} noun="signals" />
|
|
71
|
+
</>
|
|
65
72
|
);
|
|
66
73
|
}
|
|
@@ -35,15 +35,18 @@ export interface ChurnListProps {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** Loaded on click: one call per row up front would be 25 calls for a page the person may only skim. */
|
|
38
|
+
/** The signals API has no paging: a full read is shown as "20+ recent signals". */
|
|
39
|
+
const SIGNAL_LIMIT = 20;
|
|
40
|
+
|
|
38
41
|
function Intent({ companyId }: { companyId: number }) {
|
|
39
42
|
const [asked, setAsked] = useState(false);
|
|
40
|
-
const q = useG8Query((signal) => g8Get(`/accounts/${companyId}/intent-signals`, { limit:
|
|
43
|
+
const q = useG8Query((signal) => g8Get(`/accounts/${companyId}/intent-signals`, { limit: SIGNAL_LIMIT }, { signal }), [companyId], { enabled: asked });
|
|
41
44
|
if (!asked) return <Button size="sm" variant="ghost" aria-label={`Check activity for ${companyId}`} onClick={() => setAsked(true)}>Check</Button>;
|
|
42
45
|
if (q.loading) return <Skeleton className="h-4 w-16" />;
|
|
43
46
|
if (q.error) return <span className="text-muted-foreground text-xs">n/a</span>;
|
|
44
47
|
const payload = body(q.data);
|
|
45
48
|
const signals = Array.isArray(payload) ? payload : Array.isArray(asRec(payload).signals) ? (asRec(payload).signals as unknown[]) : [];
|
|
46
|
-
return <span className="text-xs">{signals.length ? `${signals.length} recent signals` : "Quiet"}</span>;
|
|
49
|
+
return <span className="text-xs">{signals.length ? `${signals.length}${signals.length >= SIGNAL_LIMIT ? "+" : ""} recent signals` : "Quiet"}</span>;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
export function ChurnList({ stageSlug = "churned", winBackSlug = "leads", onOpenCompany, pageSize = 25, className }: ChurnListProps) {
|
|
@@ -41,7 +41,7 @@ function Summary() {
|
|
|
41
41
|
return (
|
|
42
42
|
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4" data-testid="revenue-summary">
|
|
43
43
|
{tiles.map(([key, value]) => (
|
|
44
|
-
<Card key={key}><CardContent className="p-4"><div className="text-2xl font-
|
|
44
|
+
<Card key={key}><CardContent className="p-4"><div className="text-2xl font-medium">{value.toLocaleString()}</div><div className="text-muted-foreground text-xs capitalize">{key.replace(/_count$/, "").replace(/_/g, " ")}</div></CardContent></Card>
|
|
45
45
|
))}
|
|
46
46
|
</div>
|
|
47
47
|
);
|
package/blocks/lists/README.md
CHANGED
|
@@ -39,7 +39,7 @@ and `contacts:read/write`. The screens use the foundation screen kit (`component
|
|
|
39
39
|
shows `ready_count / warned / blocked` and retries with `conflict_resolution: "skip_all" | "add_all"`.
|
|
40
40
|
4. **Members**: `ContactTable listId={id}` (or `GET /contacts?list_id=`). Removing uses
|
|
41
41
|
`DELETE /lists/{list_id}/contacts { contact_ids }`; contacts are never deleted by it.
|
|
42
|
-
5. **Suppress**: `POST /contacts/suppressions/bulk-add { contact_ids, channel, reason }`; read back with
|
|
42
|
+
5. **Suppress**: pick people by name with the `ContactPicker` (pickers block) (no ids to type; pasting ids stays under "Paste ids instead"), then `POST /contacts/suppressions/bulk-add { contact_ids, channel, reason }`; read back with
|
|
43
43
|
`GET /contacts/suppressions`. The ledger has ids only (no names or emails): open the contact for details.
|
|
44
44
|
6. **Reinstate**: `POST /contacts/suppressions/reinstate { contact_ids }`. Contacts who unsubscribed themselves
|
|
45
45
|
can be refused; the answer lists them in `skipped`.
|
|
@@ -107,3 +107,9 @@ Ask the person: who may reinstate suppressed contacts (decide it in the proxy's
|
|
|
107
107
|
| checkContactSuppression | GET /contacts/{contact_id}/suppression | read | SuppressionManager |
|
|
108
108
|
| reinstateSuppressions | POST /contacts/suppressions/reinstate | write | SuppressionManager |
|
|
109
109
|
| refreshDynamicList, checkListIntegrity | POST /lists/{id}/refresh, /lists/{id}/integrity-check | write, read | recipes |
|
|
110
|
+
|
|
111
|
+
## Cross-block glue
|
|
112
|
+
|
|
113
|
+
- `AddCompanyPeopleToListButton` (`components/add-company-people-to-list-button.tsx`): "Add people to list" for companies. Lists hold contacts, so it reads the people at the companies (`GET /contacts?company_id=`, up to 500) and opens `AddToListButton` for them. `AddToListButton` gains optional `defaultOpen`, `hideTrigger` and `onClosed`; `ListsScreen` gains optional `onOpenContact` so list members open in your contacts screen.
|
|
114
|
+
|
|
115
|
+
- `RecordListsTab` (`components/record-lists-tab.tsx`): the lists one contact belongs to (`getContactLists`), with `AddToListButton` and a confirmed Remove (`removeContactsFromList`, destructive). `extraDetailTabs` entry for ContactsScreen: `{ id: "lists", label: "Lists", render: (c) => <RecordListsTab contactId={c.id} onOpenList={(id) => router.push(`/lists?id=${id}`)} /> }`.
|