@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,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ContactLookupLink: "Open contact" for something that only knows a person's email (an inbox thread, a meeting
|
|
5
|
+
* attendee, a visitor). It looks the email up in your CRM and, when exactly one contact has it, shows a button that
|
|
6
|
+
* calls `onOpen(contactId)` (route to your contacts screen: `router.push("/contacts?id=" + id)`). When nobody has that
|
|
7
|
+
* email it renders nothing, so a thread from a stranger gets no dead button.
|
|
8
|
+
*
|
|
9
|
+
* SDK operations, in order:
|
|
10
|
+
* 1. listContacts GET /contacts?email=<email>&limit=2 (read)
|
|
11
|
+
* Nothing here is billable, external or destructive.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Button } from "@graph8/sdk/ui";
|
|
15
|
+
import { UserRound } from "lucide-react";
|
|
16
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
17
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
18
|
+
|
|
19
|
+
export interface ContactLookupLinkProps {
|
|
20
|
+
/** The person's email. Nothing is looked up (and nothing rendered) without one. */
|
|
21
|
+
email?: string | null;
|
|
22
|
+
/** Called with the CRM contact id when the button is clicked. */
|
|
23
|
+
onOpen: (contactId: number) => void;
|
|
24
|
+
label?: string;
|
|
25
|
+
variant?: "default" | "outline" | "secondary" | "ghost";
|
|
26
|
+
size?: "default" | "sm";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ContactLookupLink({ email, onOpen, label = "Open contact", variant = "outline", size = "sm" }: ContactLookupLinkProps) {
|
|
30
|
+
const address = email?.trim().toLowerCase() ?? "";
|
|
31
|
+
const q = useG8Query(
|
|
32
|
+
async (signal) => ((await g8Get<Array<{ id: number; work_email?: string | null }>>("/contacts", { email: address, limit: 2, page: 1 }, { signal })).data ?? []),
|
|
33
|
+
[address],
|
|
34
|
+
{ enabled: address.includes("@"), key: ["contacts", "by-email", address] },
|
|
35
|
+
);
|
|
36
|
+
const match = (q.data ?? []).find((c) => !c.work_email || c.work_email.toLowerCase() === address);
|
|
37
|
+
if (!address || !match) return null;
|
|
38
|
+
return (
|
|
39
|
+
<Button size={size} variant={variant} onClick={() => onOpen(match.id)}>
|
|
40
|
+
<UserRound className="mr-1 size-4" aria-hidden />
|
|
41
|
+
{label}
|
|
42
|
+
</Button>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -40,10 +40,12 @@ import { RecordLink } from "@/g8/components/screen/record-link";
|
|
|
40
40
|
import { plural } from "@/g8/lib/plural";
|
|
41
41
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
42
42
|
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
43
|
+
import { useUrlFilters } from "@/g8/hooks/use-url-filters";
|
|
44
|
+
import { ListSelect } from "./list-select";
|
|
45
|
+
import { MoreFilters, TextFilter } from "@/g8/components/screen/filter-controls";
|
|
43
46
|
import { g8Delete, g8Get } from "@/g8/lib/g8-client";
|
|
44
|
-
import { useDebouncedValue } from "../hooks/use-debounced";
|
|
45
47
|
import { usePageQuery } from "../hooks/use-page-query";
|
|
46
|
-
import { contactName, errorStatus, isMasked, location } from "../lib/crm-types";
|
|
48
|
+
import { contactName, contactSubtitle, errorStatus, isMasked, location } from "../lib/crm-types";
|
|
47
49
|
import type { ContactRecord, ScreenActionContext } from "../lib/crm-types";
|
|
48
50
|
import { Pager, SortHead, formatDay, sortRows, useBulkDelete, useRowSelection, useSort } from "../lib/screen-parts";
|
|
49
51
|
import { ActivityTimeline } from "./activity-timeline";
|
|
@@ -72,8 +74,10 @@ export interface ContactsScreenProps {
|
|
|
72
74
|
className?: string;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
+
/** Every filter the contacts API takes (name, email, job title, seniority, company name and id, country, state, city, department, industry), plus `list`. */
|
|
78
|
+
const FILTER_KEYS = ["name", "job_title", "seniority_level", "country", "email", "company_name", "company_id", "state", "city", "job_department", "industry", "list"] as const;
|
|
79
|
+
type FilterKey = (typeof FILTER_KEYS)[number];
|
|
80
|
+
const MORE_KEYS: readonly FilterKey[] = ["email", "company_name", "company_id", "state", "city", "job_department", "industry", "list"];
|
|
77
81
|
const SENIORITY = ["C-Suite", "VP", "Director", "Manager"];
|
|
78
82
|
|
|
79
83
|
function sortValue(row: ContactRecord, key: string): string | null | undefined {
|
|
@@ -101,23 +105,27 @@ export function ContactsScreen(props: ContactsScreenProps) {
|
|
|
101
105
|
const parsedId = idParam ? Number(idParam) : null;
|
|
102
106
|
/** A malformed deep link (`?id=abc`) opens nothing instead of fetching /contacts/NaN. */
|
|
103
107
|
const openId = parsedId != null && Number.isFinite(parsedId) ? parsedId : null;
|
|
104
|
-
|
|
105
|
-
const
|
|
108
|
+
// The filters live in the URL (`c_email=...`), so a filtered view survives reload, Back and sharing.
|
|
109
|
+
const urlFilters = useUrlFilters(FILTER_KEYS, { prefix: "c_" });
|
|
110
|
+
const { draft, filters } = urlFilters;
|
|
111
|
+
const apiFilters = Object.fromEntries(Object.entries(filters).filter(([k, v]) => v && k !== "list"));
|
|
112
|
+
const effectiveListId = listId ?? (filters.list ? Number(filters.list) : undefined);
|
|
106
113
|
const [sheet, setSheet] = useState<"new" | "import" | "dupes" | null>(null);
|
|
107
114
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
108
115
|
const [note, setNote] = useState<string | null>(null);
|
|
109
116
|
const { sort, toggle: toggleSort } = useSort();
|
|
110
117
|
|
|
111
118
|
const list = usePageQuery<ContactRecord>(
|
|
112
|
-
(page, signal) => g8Get<ContactRecord[]>("/contacts", { page, limit: pageSize, list_id:
|
|
113
|
-
[
|
|
119
|
+
(page, signal) => g8Get<ContactRecord[]>("/contacts", { page, limit: pageSize, list_id: effectiveListId, ...apiFilters }, { signal }),
|
|
120
|
+
[effectiveListId, filters],
|
|
114
121
|
pageSize,
|
|
115
122
|
["contacts", "list", "screen"],
|
|
123
|
+
urlFilters.ready,
|
|
116
124
|
);
|
|
117
125
|
const dupes = useG8Query((signal) => g8Get<{ pending?: number; count?: number }>("/duplicates/counts", { entity_type: "contact" }, { signal }), [], { key: ["duplicates", "counts", "contact"] });
|
|
118
126
|
const selection = useRowSelection<ContactRecord>(`${listId}|${JSON.stringify(filters)}`);
|
|
119
127
|
const rows = useMemo(() => sortRows(list.rows, sort, sortValue), [list.rows, sort]);
|
|
120
|
-
const hasFilters =
|
|
128
|
+
const hasFilters = urlFilters.active;
|
|
121
129
|
const pending = dupes.data?.data.pending ?? dupes.data?.data.count;
|
|
122
130
|
const lockedOnPage = list.rows.filter((r) => isMasked(r.work_email)).length;
|
|
123
131
|
|
|
@@ -131,7 +139,7 @@ export function ContactsScreen(props: ContactsScreenProps) {
|
|
|
131
139
|
});
|
|
132
140
|
const allOnPage = rows.length > 0 && rows.every((r) => selection.selected.has(r.id));
|
|
133
141
|
const reload = () => { void list.refetch(); void dupes.refetch(); };
|
|
134
|
-
const set = (key:
|
|
142
|
+
const set = (key: FilterKey, v: string) => urlFilters.set(key, v);
|
|
135
143
|
|
|
136
144
|
useEffect(() => { setNote(null); }, [filters, listId]);
|
|
137
145
|
|
|
@@ -173,7 +181,17 @@ export function ContactsScreen(props: ContactsScreenProps) {
|
|
|
173
181
|
{SENIORITY.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
174
182
|
</select>
|
|
175
183
|
<input aria-label="Filter by country" placeholder="Country" className="h-9 w-32 rounded-md border bg-background px-3 text-sm" value={draft.country} onChange={(e) => set("country", e.target.value)} />
|
|
176
|
-
|
|
184
|
+
<MoreFilters activeCount={MORE_KEYS.filter((k) => draft[k]).length}>
|
|
185
|
+
<TextFilter label="email" type="email" value={draft.email} onChange={(v) => set("email", v)} className="w-44" />
|
|
186
|
+
<TextFilter label="company name" placeholder="Company" value={draft.company_name} onChange={(v) => set("company_name", v)} />
|
|
187
|
+
<TextFilter label="company ID" placeholder="Company ID" type="number" value={draft.company_id} onChange={(v) => set("company_id", v)} className="w-28" />
|
|
188
|
+
<TextFilter label="industry" value={draft.industry} onChange={(v) => set("industry", v)} />
|
|
189
|
+
<TextFilter label="department" placeholder="Department" value={draft.job_department} onChange={(v) => set("job_department", v)} />
|
|
190
|
+
<TextFilter label="state" value={draft.state} onChange={(v) => set("state", v)} className="w-28" />
|
|
191
|
+
<TextFilter label="city" value={draft.city} onChange={(v) => set("city", v)} className="w-32" />
|
|
192
|
+
{listId == null ? <ListSelect label="Filter by list" emptyOption="Any list" value={draft.list} valueLabel={`List ${draft.list}`} pageSize={100} onChange={(l) => set("list", l ? String(l.id) : "")} /> : null}
|
|
193
|
+
</MoreFilters>
|
|
194
|
+
{hasFilters ? <Button variant="ghost" size="sm" onClick={urlFilters.clear}>Clear filters</Button> : null}
|
|
177
195
|
</>
|
|
178
196
|
}
|
|
179
197
|
/>
|
|
@@ -229,7 +247,7 @@ export function ContactsScreen(props: ContactsScreenProps) {
|
|
|
229
247
|
<EmptyState
|
|
230
248
|
title={hasFilters ? "No contacts match these filters" : "No contacts yet"}
|
|
231
249
|
description={hasFilters ? "Try fewer filters." : "Add your first contact or import a file."}
|
|
232
|
-
action={hasFilters ? <Button variant="outline" onClick={
|
|
250
|
+
action={hasFilters ? <Button variant="outline" onClick={urlFilters.clear}>Clear filters</Button> : <Button onClick={() => setSheet("new")}>New contact</Button>}
|
|
233
251
|
/>
|
|
234
252
|
) : null}
|
|
235
253
|
<Pager page={list.page} pageCount={list.pageCount} total={list.total} noun="contacts" hasNext={list.hasNext} loading={list.loading} onPage={list.setPage} />
|
|
@@ -326,7 +344,7 @@ function ContactPanel({ full, setFull, contactId, seed, onClose, onChanged, onDe
|
|
|
326
344
|
<>
|
|
327
345
|
<DetailPanel
|
|
328
346
|
title={name}
|
|
329
|
-
subtitle={
|
|
347
|
+
subtitle={contactSubtitle(contact)}
|
|
330
348
|
avatar={<EntityAvatar name={name} kind="person" size="lg" />}
|
|
331
349
|
badges={isMasked(contact.work_email) ? <StatusPill tone="warning" dot>Locked email</StatusPill> : undefined}
|
|
332
350
|
onClose={onClose}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { useState } from "react";
|
|
22
22
|
import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from "@graph8/sdk/ui";
|
|
23
23
|
import { ApiError } from "@/g8/components/api-error";
|
|
24
|
+
import { FirstNotice } from "@/g8/components/paging-notices";
|
|
24
25
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
25
26
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
26
27
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
@@ -53,6 +54,9 @@ function side(pair: Pair, s: "a" | "b", entity: "contact" | "company") {
|
|
|
53
54
|
return { id: pair[`${s}_id`] as number, name: name || String([pair[`${s}_email`], pair[`${s}_domain`]].find((v) => v && !isMasked(v)) ?? `#${pair[`${s}_id`]}`), lines: lines.filter(Boolean).map((l) => (isMasked(l) ? "Locked" : String(l))) };
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/** The recent-merges API has no paging: a full read is announced under the list. */
|
|
58
|
+
const RECENT_LIMIT = 25;
|
|
59
|
+
|
|
56
60
|
export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps) {
|
|
57
61
|
const brand = useBrand();
|
|
58
62
|
const plural = entity === "contact" ? "contacts" : "companies";
|
|
@@ -67,7 +71,7 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
|
|
|
67
71
|
const res = await g8Get<{ items: Pair[]; next_cursor?: string | null }>("/duplicates", { entity_type: entity, status: "pending", limit: 25, cursor }, { signal });
|
|
68
72
|
return { data: res.data.items ?? [], pagination: { next_cursor: res.data.next_cursor ?? null } };
|
|
69
73
|
}, [entity]);
|
|
70
|
-
const recent = useG8Query((signal) => g8Get<MergeRow[]>("/duplicates/recent-merges", { entity_type: entity, limit:
|
|
74
|
+
const recent = useG8Query((signal) => g8Get<MergeRow[]>("/duplicates/recent-merges", { entity_type: entity, limit: RECENT_LIMIT }, { signal }), [entity]);
|
|
71
75
|
const [busy, setBusy] = useState<string | null>(null);
|
|
72
76
|
const dropPick = (id: string | undefined) => id && setPicked((p) => { const n = new Set(p); n.delete(id); return n; });
|
|
73
77
|
const refreshAll = () => { void pairs.reload(); void counts.refetch(); void recent.refetch(); onChanged?.(); };
|
|
@@ -145,6 +149,7 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
|
|
|
145
149
|
{m.can_undo ? <Button size="sm" variant="outline" loading={undo.loading && busy === m.id} onClick={() => void undo.mutate(m.id)}>Undo</Button> : null}
|
|
146
150
|
</div>
|
|
147
151
|
))}
|
|
152
|
+
<FirstNotice shown={(recent.data?.data ?? []).length} limit={RECENT_LIMIT} noun="merges" />
|
|
148
153
|
</TabsContent>
|
|
149
154
|
</Tabs>
|
|
150
155
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { useEffect, useState } from "react";
|
|
21
21
|
import { Button, Input, Label, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
22
22
|
import { ApiError } from "@/g8/components/api-error";
|
|
23
|
+
import { ListSelect } from "./list-select";
|
|
23
24
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
24
25
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
25
26
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
@@ -38,7 +39,6 @@ export interface ImportWizardProps {
|
|
|
38
39
|
export interface ImportResult { imported?: number | null; updated?: number | null; skipped?: number | null; failed?: number | null; list_id?: number | null }
|
|
39
40
|
interface Upload { datafile_id: string; row_count?: number | null; columns: string[] }
|
|
40
41
|
interface Preview { rows: Array<Record<string, unknown>>; total: number }
|
|
41
|
-
interface ListOption { id: number; title: string; type?: string | null }
|
|
42
42
|
|
|
43
43
|
export const IMPORT_TARGETS: Array<{ value: string; label: string }> = [
|
|
44
44
|
{ value: "CONTACT_FIRST_NAME", label: "First name" }, { value: "CONTACT_LAST_NAME", label: "Last name" },
|
|
@@ -80,7 +80,6 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
|
|
|
80
80
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
81
81
|
const [result, setResult] = useState<ImportResult | null>(null);
|
|
82
82
|
|
|
83
|
-
const lists = useG8Query((signal) => g8Get<ListOption[]>("/lists", { limit: 200 }, { signal }), [], { enabled: upload != null && listId == null });
|
|
84
83
|
const send = useG8Mutation((file: File) => { const body = new FormData(); body.append("file", file); return g8Post<Upload>("/imports", body); }, {
|
|
85
84
|
onSuccess: (r) => { setUpload(r.data); setMapping(guessMapping(r.data.columns ?? [])); },
|
|
86
85
|
});
|
|
@@ -107,7 +106,7 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
|
|
|
107
106
|
if (result) {
|
|
108
107
|
return (
|
|
109
108
|
<div className={`flex flex-col gap-3 ${className ?? ""}`} role="status">
|
|
110
|
-
<h3 className="text-base font-
|
|
109
|
+
<h3 className="text-base font-medium">Import finished</h3>
|
|
111
110
|
<p className="text-sm">{result.imported ?? 0} new, {result.updated ?? 0} updated, {result.skipped ?? 0} skipped, {result.failed ?? 0} failed.</p>
|
|
112
111
|
<Button variant="outline" onClick={() => { setResult(null); setUpload(null); setMapping({}); setDestination(listId); setNewListTitle(""); setPolicy("autofill"); setEnrich(false); run.reset(); preview.reset(); }}>Import another file</Button>
|
|
113
112
|
</div>
|
|
@@ -161,10 +160,7 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
|
|
|
161
160
|
<h3 className="text-sm font-medium">3. Destination and rules</h3>
|
|
162
161
|
{listId == null ? (
|
|
163
162
|
<div className="flex flex-wrap items-center gap-2">
|
|
164
|
-
<
|
|
165
|
-
<option value="">New list...</option>
|
|
166
|
-
{(lists.data?.data ?? []).filter((l) => !l.type || l.type === "contacts").map((l) => <option key={l.id} value={l.id}>{l.title}</option>)}
|
|
167
|
-
</select>
|
|
163
|
+
<ListSelect label="Destination list" emptyOption="New list..." type="contacts" pageSize={200} value={destination} valueLabel={newListTitle.trim() || undefined} enabled={upload != null && listId == null} selectClassName="h-9 rounded-md border bg-background px-2 text-sm" onChange={(l) => setDestination(l ? l.id : undefined)} />
|
|
168
164
|
{destination == null ? <Input aria-label="New list name" placeholder="New list name" className="w-56" value={newListTitle} onChange={(e) => setNewListTitle(e.target.value)} /> : null}
|
|
169
165
|
</div>
|
|
170
166
|
) : null}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ListSelect: the audience-list dropdown with the list read wired in. A native select that is searchable (server
|
|
5
|
+
* search once there is more than one page) and paged ("Load more lists"), so the 201st list is reachable. It is the
|
|
6
|
+
* foundation's ListSelect plus `GET /lists` (the foundation holds no API scope); every block that picks a list
|
|
7
|
+
* (forms, lists, newsletter, nurture, sequencer, dialer) imports this one.
|
|
8
|
+
*
|
|
9
|
+
* SDK operations, in order:
|
|
10
|
+
* 1. GET /lists?search&page&limit (lists.listLists) read - through lib/read-lists.ts
|
|
11
|
+
*/
|
|
12
|
+
import { ListSelect as BaseListSelect } from "@/g8/components/list-select";
|
|
13
|
+
import type { ListSelectProps } from "@/g8/components/list-select";
|
|
14
|
+
import { readLists } from "../lib/read-lists";
|
|
15
|
+
|
|
16
|
+
export type { ListSelectOption } from "@/g8/components/list-select";
|
|
17
|
+
|
|
18
|
+
export function ListSelect(props: Omit<ListSelectProps, "read">) {
|
|
19
|
+
return <BaseListSelect {...props} read={readLists} />;
|
|
20
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RecordEngagementTab: how much a contact or company has engaged: emails, calls, meetings, LinkedIn, website visits and
|
|
5
|
+
* downloads, the last touch, and the pages visited in the last 90 days. Use it as an `extraDetailTabs` entry of
|
|
6
|
+
* ContactsScreen / CompaniesScreen:
|
|
7
|
+
* { id: "engagement", label: "Engagement", render: (c) => <RecordEngagementTab kind="contacts" id={c.id} /> }
|
|
8
|
+
*
|
|
9
|
+
* SDK operations, in order:
|
|
10
|
+
* 1. getContactEngagementSummary GET /contacts/{id}/engagement-summary (read) when kind="contacts"
|
|
11
|
+
* getCompanyEngagementSummary GET /companies/{id}/engagement-summary (read) when kind="companies"
|
|
12
|
+
* 2. getContactSitesVisited GET /contacts/{id}/sites-visited?days=90 (read) when kind="contacts"
|
|
13
|
+
* getCompanySitesVisited GET /companies/{id}/sites-visited?days=90 (read) when kind="companies"
|
|
14
|
+
* Nothing here is billable, external or destructive. The visits list is shown ten at a time with "Show more", so the
|
|
15
|
+
* count in the heading always matches what can be reached.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
20
|
+
import { Activity } from "lucide-react";
|
|
21
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
22
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
23
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
24
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
25
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
26
|
+
|
|
27
|
+
/** The counts both summaries share (the contact one adds LinkedIn and the last touch). */
|
|
28
|
+
interface Summary { emails?: number | null; calls?: number | null; meetings?: number | null; linkedin?: number | null; website_visits?: number | null; downloads?: number | null; contact_count?: number | null; last_contact?: string | null; last_contact_checked?: boolean | null }
|
|
29
|
+
interface Visit { url?: string | null; domain?: string | null; title?: string | null; event_date?: string | null; page_type?: string | null }
|
|
30
|
+
interface Visits { visits?: Visit[] | null; total_visits?: number | null }
|
|
31
|
+
|
|
32
|
+
const STEP = 10;
|
|
33
|
+
const DAYS = 90;
|
|
34
|
+
|
|
35
|
+
function dataOf<T>(env: unknown): T {
|
|
36
|
+
const d = (env as { data?: unknown } | null)?.data;
|
|
37
|
+
return (d && typeof d === "object" ? d : {}) as T;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function when(iso: string | null | undefined): string {
|
|
41
|
+
const d = iso ? new Date(iso) : null;
|
|
42
|
+
return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const count = (n: number | null | undefined): string => (typeof n === "number" ? n.toLocaleString() : "-");
|
|
46
|
+
|
|
47
|
+
export interface RecordEngagementTabProps {
|
|
48
|
+
kind: "contacts" | "companies";
|
|
49
|
+
id: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function RecordEngagementTab({ kind, id }: RecordEngagementTabProps) {
|
|
53
|
+
const [shown, setShown] = useState(STEP);
|
|
54
|
+
const summary = useG8Query(async (signal) => dataOf<Summary>(await g8Get(`/${kind}/${id}/engagement-summary`, undefined, { signal })), [kind, id]);
|
|
55
|
+
const visits = useG8Query(async (signal) => dataOf<Visits>(await g8Get(`/${kind}/${id}/sites-visited`, { days: DAYS }, { signal })), [kind, id]);
|
|
56
|
+
|
|
57
|
+
if (summary.error) return <ApiError error={summary.error} title="Could not load engagement" onRetry={() => void summary.refetch()} />;
|
|
58
|
+
if (!summary.data) return <Skeleton className="h-32 w-full" aria-label="Loading engagement" />;
|
|
59
|
+
const s = summary.data;
|
|
60
|
+
const items = [
|
|
61
|
+
{ label: "Emails", value: count(s.emails) },
|
|
62
|
+
{ label: "Calls", value: count(s.calls) },
|
|
63
|
+
{ label: "Meetings", value: count(s.meetings) },
|
|
64
|
+
...(kind === "contacts" ? [{ label: "LinkedIn", value: count(s.linkedin) }] : [{ label: "People", value: count(s.contact_count) }]),
|
|
65
|
+
{ label: "Website visits", value: count(s.website_visits) },
|
|
66
|
+
{ label: "Downloads", value: count(s.downloads) },
|
|
67
|
+
...(kind === "contacts" ? [{ label: "Last touch", value: when(s.last_contact) || (s.last_contact_checked === false ? "Not checked" : "No touch yet") }] : []),
|
|
68
|
+
];
|
|
69
|
+
const rows = visits.data?.visits ?? [];
|
|
70
|
+
const total = rows.length;
|
|
71
|
+
const reported = visits.data?.total_visits ?? total;
|
|
72
|
+
return (
|
|
73
|
+
<div className="space-y-4">
|
|
74
|
+
<KeyValueList columns={2} items={items} />
|
|
75
|
+
<section aria-label="Pages visited" className="space-y-2">
|
|
76
|
+
<h3 className="text-sm font-medium">Pages visited, last {DAYS} days</h3>
|
|
77
|
+
{visits.error ? <ApiError error={visits.error} title="Could not load pages visited" onRetry={() => void visits.refetch()} /> : null}
|
|
78
|
+
{!visits.error && !visits.data ? <Skeleton className="h-16 w-full" aria-label="Loading pages visited" /> : null}
|
|
79
|
+
{visits.data && total === 0 ? <EmptyState compact icon={<Activity className="size-8" />} title="No pages visited" description={`No website visits in the last ${DAYS} days.`} /> : null}
|
|
80
|
+
{total > 0 ? (
|
|
81
|
+
<>
|
|
82
|
+
<ul className="divide-y rounded-md border">
|
|
83
|
+
{rows.slice(0, shown).map((v, i) => (
|
|
84
|
+
<li key={`${v.url}-${i}`} className="flex items-center justify-between gap-3 p-3 text-sm">
|
|
85
|
+
<span className="min-w-0">
|
|
86
|
+
<span className="block truncate font-medium">{v.title || v.url || "Page"}</span>
|
|
87
|
+
<span className="block truncate text-xs text-muted-foreground">{[v.domain, v.page_type].filter(Boolean).join(" · ")}</span>
|
|
88
|
+
</span>
|
|
89
|
+
<span className="shrink-0 text-xs text-muted-foreground">{when(v.event_date)}</span>
|
|
90
|
+
</li>
|
|
91
|
+
))}
|
|
92
|
+
</ul>
|
|
93
|
+
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
94
|
+
<span role="status">Showing {Math.min(shown, total).toLocaleString()} of {total.toLocaleString()} visits</span>
|
|
95
|
+
{shown < total ? <Button size="sm" variant="outline" onClick={() => setShown((n) => n + STEP)}>Show more</Button> : null}
|
|
96
|
+
</div>
|
|
97
|
+
{reported > total ? <p role="note" className="text-xs text-muted-foreground">The API returned {total.toLocaleString()} of {reported.toLocaleString()} visits in this window. Narrow it in the visitors screen to see the rest.</p> : null}
|
|
98
|
+
</>
|
|
99
|
+
) : null}
|
|
100
|
+
</section>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -178,7 +178,7 @@ export function RecordForm(props: RecordFormProps) {
|
|
|
178
178
|
|
|
179
179
|
{validation ? <p role="alert" className="text-sm text-destructive">{validation}</p> : null}
|
|
180
180
|
{duplicate ? (
|
|
181
|
-
<div role="alert" className="rounded-md border border-
|
|
181
|
+
<div role="alert" className="rounded-md border border-warning-foreground/30 bg-warning p-3 text-sm">
|
|
182
182
|
A contact with this email or LinkedIn URL already exists{existingId ? ` (id ${existingId})` : ""}.
|
|
183
183
|
{props.onDuplicate ? <Button type="button" variant="link" size="sm" onClick={() => props.onDuplicate?.(existingId)}>Open the existing contact</Button> : null}
|
|
184
184
|
</div>
|
|
@@ -37,8 +37,9 @@ import { RecordLink } from "@/g8/components/screen/record-link";
|
|
|
37
37
|
import { plural } from "@/g8/lib/plural";
|
|
38
38
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
39
39
|
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
40
|
+
import { useUrlFilters } from "@/g8/hooks/use-url-filters";
|
|
41
|
+
import { MoreFilters, TextFilter } from "@/g8/components/screen/filter-controls";
|
|
40
42
|
import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
41
|
-
import { useDebouncedValue } from "../hooks/use-debounced";
|
|
42
43
|
import { usePageQuery } from "../hooks/use-page-query";
|
|
43
44
|
import { errorStatus } from "../lib/crm-types";
|
|
44
45
|
import { Pager, formatDay, useBulkDelete, useRowSelection } from "../lib/screen-parts";
|
|
@@ -54,6 +55,8 @@ export interface WorkspaceTask extends TaskItem {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export interface TasksScreenProps {
|
|
58
|
+
/** The signed-in person's user id. With it the toolbar gets a "My tasks" toggle (assignee filter); without it the toggle is hidden. */
|
|
59
|
+
currentUserId?: string;
|
|
57
60
|
pageSize?: number;
|
|
58
61
|
/** Which tab opens first. Default "open". */
|
|
59
62
|
initialStatus?: "open" | "completed" | "all";
|
|
@@ -85,6 +88,10 @@ function PriorityPill({ priority }: { priority?: number | null }) {
|
|
|
85
88
|
return <StatusPill tone={priority === 1 ? "negative" : priority === 2 ? "warning" : "neutral"}>{PRIORITY[priority] ?? "Low"}</StatusPill>;
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
/** Every filter the tasks API takes besides the status tab: search, priority, type, linked record type, assignee, creator, source meeting; `mine` = assigned to the signed-in person. */
|
|
92
|
+
const FILTER_KEYS = ["search", "priority", "task_type", "entity_type", "assignee_id", "created_by", "source_meeting_id", "mine"] as const;
|
|
93
|
+
type FilterKey = (typeof FILTER_KEYS)[number];
|
|
94
|
+
|
|
88
95
|
export function TasksScreen(props: TasksScreenProps) {
|
|
89
96
|
const { pageSize = 25 } = props;
|
|
90
97
|
const [idParam, setIdParamRaw] = useUrlState("id");
|
|
@@ -94,28 +101,35 @@ export function TasksScreen(props: TasksScreenProps) {
|
|
|
94
101
|
const setIdParam = (v: string | null): void => { if (v == null) setView(null); setIdParamRaw(v); };
|
|
95
102
|
const openId = idParam || null;
|
|
96
103
|
const [tab, setTab] = useState<Tab>(props.initialStatus ?? "open");
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
104
|
+
// Filters live in the URL (`t_priority=1`, `t_mine=1`), so a filtered view survives reload, Back and sharing.
|
|
105
|
+
const urlFilters = useUrlFilters(FILTER_KEYS, { prefix: "t_" });
|
|
106
|
+
const { draft, filters } = urlFilters;
|
|
107
|
+
const search = draft.search;
|
|
108
|
+
const priority = draft.priority;
|
|
109
|
+
const taskType = draft.task_type;
|
|
110
|
+
const entityType = props.defaultRecord?.entity_type ?? draft.entity_type;
|
|
111
|
+
const q = filters.search;
|
|
112
|
+
// "My tasks" needs the signed-in person's user id: the app passes it as `currentUserId` (the pickers block's
|
|
113
|
+
// useCurrentMember gives it), so this screen holds no team-members scope of its own.
|
|
114
|
+
const assigneeId = draft.mine && props.currentUserId ? props.currentUserId : filters.assignee_id || undefined;
|
|
102
115
|
const [sheet, setSheet] = useState<"new" | "edit" | null>(null);
|
|
103
116
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
104
117
|
const [note, setNote] = useState<string | null>(null);
|
|
105
118
|
|
|
106
|
-
const scope = { status: tab === "all" ? undefined : tab, search: q || undefined, priority: priority || undefined, task_type:
|
|
119
|
+
const scope = { status: tab === "all" ? undefined : tab, search: q || undefined, priority: filters.priority || undefined, task_type: filters.task_type || undefined, entity_type: (props.defaultRecord?.entity_type ?? filters.entity_type) || undefined, assignee_id: assigneeId, created_by: filters.created_by || undefined, source_meeting_id: filters.source_meeting_id || undefined, entity_id: props.defaultRecord?.entity_id };
|
|
107
120
|
const list = usePageQuery<WorkspaceTask>(
|
|
108
121
|
(page, signal) => g8Get<WorkspaceTask[]>("/tasks", { limit: pageSize, offset: (page - 1) * pageSize, ...scope }, { signal }),
|
|
109
|
-
[scope.status, scope.search, scope.priority, scope.task_type, scope.entity_type, scope.entity_id],
|
|
122
|
+
[scope.status, scope.search, scope.priority, scope.task_type, scope.entity_type, scope.entity_id, scope.assignee_id, scope.created_by, scope.source_meeting_id],
|
|
110
123
|
pageSize,
|
|
111
124
|
["tasks", "list", "screen"],
|
|
125
|
+
urlFilters.ready,
|
|
112
126
|
);
|
|
113
127
|
const recordScope = { entity_type: props.defaultRecord?.entity_type, entity_id: props.defaultRecord?.entity_id };
|
|
114
128
|
const openCount = useG8Query((signal) => g8Get<WorkspaceTask[]>("/tasks", { status: "open", limit: 1, ...recordScope }, { signal }), [recordScope.entity_type, recordScope.entity_id], { key: ["tasks", "count", "open", recordScope] });
|
|
115
129
|
const doneCount = useG8Query((signal) => g8Get<WorkspaceTask[]>("/tasks", { status: "completed", limit: 1, ...recordScope }, { signal }), [recordScope.entity_type, recordScope.entity_id], { key: ["tasks", "count", "completed", recordScope] });
|
|
116
130
|
const selection = useRowSelection<WorkspaceTask>(JSON.stringify(scope));
|
|
117
131
|
const reload = () => { void list.refetch(); void openCount.refetch(); void doneCount.refetch(); };
|
|
118
|
-
const hasFilters = Boolean(search || priority || taskType ||
|
|
132
|
+
const hasFilters = Boolean(search || priority || taskType || draft.entity_type || draft.assignee_id || draft.created_by || draft.source_meeting_id || draft.mine);
|
|
119
133
|
const overdue = list.rows.filter(isOverdue).length;
|
|
120
134
|
|
|
121
135
|
const toggle = useG8Mutation((t: WorkspaceTask) => g8Patch(`/tasks/${t.id}`, { status: t.status === "completed" ? "open" : "completed" }), { invalidates: ["tasks"], onSuccess: reload });
|
|
@@ -130,7 +144,7 @@ export function TasksScreen(props: TasksScreenProps) {
|
|
|
130
144
|
if (openId && gone.includes(openId)) setIdParam(null);
|
|
131
145
|
reload();
|
|
132
146
|
});
|
|
133
|
-
useEffect(() => { setNote(null); }, [tab,
|
|
147
|
+
useEffect(() => { setNote(null); }, [tab, filters]);
|
|
134
148
|
const allOnPage = list.rows.length > 0 && list.rows.every((r) => selection.selected.has(r.id));
|
|
135
149
|
|
|
136
150
|
return (
|
|
@@ -162,21 +176,27 @@ export function TasksScreen(props: TasksScreenProps) {
|
|
|
162
176
|
list={
|
|
163
177
|
<div className="flex flex-col gap-3">
|
|
164
178
|
<Toolbar
|
|
165
|
-
search={{ value: search, onChange:
|
|
179
|
+
search={{ value: search, onChange: (v) => urlFilters.set("search", v), debounceMs: 0, placeholder: "Search tasks by title" }}
|
|
166
180
|
filters={
|
|
167
181
|
<>
|
|
168
|
-
<select aria-label="Filter by priority" className="h-9 rounded-md border bg-background px-2 text-sm" value={priority} onChange={(e) =>
|
|
182
|
+
<select aria-label="Filter by priority" className="h-9 rounded-md border bg-background px-2 text-sm" value={priority} onChange={(e) => urlFilters.set("priority", e.target.value)}>
|
|
169
183
|
<option value="">Any priority</option><option value="1">High</option><option value="2">Medium</option><option value="3">Low</option>
|
|
170
184
|
</select>
|
|
171
|
-
<select aria-label="Filter by type" className="h-9 rounded-md border bg-background px-2 text-sm" value={taskType} onChange={(e) =>
|
|
185
|
+
<select aria-label="Filter by type" className="h-9 rounded-md border bg-background px-2 text-sm" value={taskType} onChange={(e) => urlFilters.set("task_type", e.target.value)}>
|
|
172
186
|
<option value="">Any type</option>{TASK_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
|
173
187
|
</select>
|
|
174
188
|
{props.defaultRecord ? null : (
|
|
175
|
-
<select aria-label="Filter by linked record" className="h-9 rounded-md border bg-background px-2 text-sm" value={entityType} onChange={(e) =>
|
|
189
|
+
<select aria-label="Filter by linked record" className="h-9 rounded-md border bg-background px-2 text-sm" value={entityType} onChange={(e) => urlFilters.set("entity_type", e.target.value)}>
|
|
176
190
|
<option value="">Any record</option>{ENTITY_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
|
177
191
|
</select>
|
|
178
192
|
)}
|
|
179
|
-
{
|
|
193
|
+
{props.currentUserId ? <Button variant={draft.mine ? "default" : "outline"} size="sm" aria-pressed={Boolean(draft.mine)} onClick={() => urlFilters.set("mine", draft.mine ? "" : "1")}>My tasks</Button> : null}
|
|
194
|
+
<MoreFilters activeCount={["assignee_id", "created_by", "source_meeting_id"].filter((k) => draft[k as FilterKey]).length}>
|
|
195
|
+
<TextFilter label="assignee ID" placeholder="Assignee user ID" value={draft.assignee_id} onChange={(v) => urlFilters.set("assignee_id", v)} className="w-48" />
|
|
196
|
+
<TextFilter label="creator ID" placeholder="Created by user ID" value={draft.created_by} onChange={(v) => urlFilters.set("created_by", v)} className="w-48" />
|
|
197
|
+
<TextFilter label="meeting ID" placeholder="Source meeting ID" value={draft.source_meeting_id} onChange={(v) => urlFilters.set("source_meeting_id", v)} className="w-48" />
|
|
198
|
+
</MoreFilters>
|
|
199
|
+
{hasFilters ? <Button variant="ghost" size="sm" onClick={urlFilters.clear}>Clear filters</Button> : null}
|
|
180
200
|
</>
|
|
181
201
|
}
|
|
182
202
|
/>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useAllLists: every list of the workspace, for a screen that must look one up by id (its size, its name). The lists API
|
|
5
|
+
* pages (`page`, `limit`), so this reads page after page (100 each, up to 20 pages) instead of one capped `limit=200`
|
|
6
|
+
* call; `truncated` says the page cap was reached. To CHOOSE a list use ListSelect (searchable, paged).
|
|
7
|
+
*
|
|
8
|
+
* SDK operations: GET /lists?page&limit (lists.listLists) read - through lib/read-lists.ts
|
|
9
|
+
*/
|
|
10
|
+
import { useAllPages } from "@/g8/hooks/use-all-pages";
|
|
11
|
+
import type { ListSelectOption } from "@/g8/components/list-select";
|
|
12
|
+
import { readLists } from "../lib/read-lists";
|
|
13
|
+
|
|
14
|
+
const PAGE = 100;
|
|
15
|
+
|
|
16
|
+
export function useAllLists(deps: readonly unknown[] = [], enabled = true) {
|
|
17
|
+
return useAllPages<ListSelectOption>((page, signal) => readLists("", page, PAGE, signal), deps, { enabled });
|
|
18
|
+
}
|
|
@@ -34,12 +34,14 @@ export function usePageQuery<T>(
|
|
|
34
34
|
deps: readonly unknown[],
|
|
35
35
|
pageSize = 25,
|
|
36
36
|
cacheKey?: readonly unknown[],
|
|
37
|
+
/** Hold the read (for example until URL filters are known). Default true. */
|
|
38
|
+
enabled = true,
|
|
37
39
|
): UsePageQueryResult<T> {
|
|
38
40
|
const key = JSON.stringify(deps);
|
|
39
41
|
const [state, setState] = useState({ key, page: 1 });
|
|
40
42
|
const page = state.key === key ? state.page : 1;
|
|
41
43
|
const queryKey: G8QueryKey | undefined = cacheKey ? [...cacheKey, { page, pageSize, deps: key }] : undefined;
|
|
42
|
-
const query = useG8Query((signal) => fetchPage(page, signal), [key, page], { key: queryKey });
|
|
44
|
+
const query = useG8Query((signal) => fetchPage(page, signal), [key, page], { key: queryKey, enabled });
|
|
43
45
|
|
|
44
46
|
const info = query.data?.pagination as Partial<PageInfo> | undefined;
|
|
45
47
|
const rows = (query.data?.data ?? []) as T[];
|
|
@@ -31,6 +31,8 @@ export interface ContactRecord {
|
|
|
31
31
|
last_name?: string | null;
|
|
32
32
|
full_name?: string | null;
|
|
33
33
|
work_email?: string | null;
|
|
34
|
+
/** Personal addresses, comma separated. */
|
|
35
|
+
personal_emails?: string | null;
|
|
34
36
|
direct_phone?: string | null;
|
|
35
37
|
mobile_phone?: string | null;
|
|
36
38
|
job_title?: string | null;
|
|
@@ -107,6 +109,18 @@ export function contactName(c: Pick<ContactRecord, "first_name" | "last_name" |
|
|
|
107
109
|
return joined || c.full_name || (c.work_email && !isMasked(c.work_email) ? c.work_email : "") || (c.id != null ? `#${c.id}` : "-");
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The line under a contact's name: "VP of Sales at Acme". The company is the company's own name (`company.name`, else the
|
|
114
|
+
* list row's `company_name`); it never falls back to anything about the person. "at ..." is left out when there is no
|
|
115
|
+
* title or no company. A company that happens to share the person's name (a freelancer) is shown as the API sent it.
|
|
116
|
+
*/
|
|
117
|
+
export function contactSubtitle(c: Pick<ContactRecord, "job_title" | "company_name" | "company">): string {
|
|
118
|
+
const company = [c.company?.name, c.company_name].map((n) => (n ?? "").trim()).find((n) => n !== "");
|
|
119
|
+
const title = (c.job_title ?? "").trim();
|
|
120
|
+
if (title && company) return `${title} at ${company}`;
|
|
121
|
+
return title || company || "No title or company";
|
|
122
|
+
}
|
|
123
|
+
|
|
110
124
|
/** A company's display name: name, then domain, then `#id`, then a dash. Never "Unnamed". */
|
|
111
125
|
export function companyName(c: { name?: string | null; domain?: string | null; id?: number | string }): string {
|
|
112
126
|
return c.name || c.domain || (c.id != null ? `#${c.id}` : "-");
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* readLists: one page of audience lists, for ListSelect and useAllLists.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations: GET /lists?search&page&limit (lists.listLists) read
|
|
7
|
+
*/
|
|
8
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
9
|
+
import { hasNextOf } from "@/g8/lib/paging";
|
|
10
|
+
import type { ListSelectOption, ListsReader } from "@/g8/components/list-select";
|
|
11
|
+
|
|
12
|
+
export const readLists: ListsReader = async (q, page, pageSize, signal) => {
|
|
13
|
+
const env = await g8Get<ListSelectOption[]>("/lists", { search: q || undefined, page, limit: pageSize }, { signal });
|
|
14
|
+
const rows = (env.data ?? []) as ListSelectOption[];
|
|
15
|
+
return { rows, hasNext: hasNextOf(env, pageSize, rows.length) };
|
|
16
|
+
};
|