@graph8/sdk 0.362.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 +33 -10
- package/blocks/_foundation/README.md +78 -1
- package/blocks/_foundation/block.json +123 -6
- package/blocks/_foundation/components/api-error.tsx +9 -7
- package/blocks/_foundation/components/brand-provider.tsx +23 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
- package/blocks/_foundation/components/confirmed-action.tsx +90 -0
- package/blocks/_foundation/components/connect-graph8.tsx +280 -0
- package/blocks/_foundation/components/empty-state.tsx +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 +2 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/split-view.tsx +1 -1
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +15 -97
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
- package/blocks/_foundation/hooks/use-brand.ts +52 -0
- package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
- package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
- package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
- package/blocks/_foundation/hooks/use-paginated.ts +120 -0
- package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
- package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
- package/blocks/_foundation/lib/g8-cache.ts +454 -0
- package/blocks/_foundation/lib/g8-client.ts +1 -1
- package/blocks/_foundation/lib/paging.ts +57 -0
- package/blocks/_foundation/lib/plural.ts +23 -0
- package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
- package/blocks/_foundation/server/connect-route.ts +15 -0
- package/blocks/_foundation/server/connect.ts +127 -0
- package/blocks/_foundation/server/local-key.ts +70 -0
- package/blocks/_foundation/server/proxy.ts +103 -3
- package/blocks/_foundation/server/route.ts +16 -4
- package/blocks/agents/README.md +93 -2
- package/blocks/agents/block.json +343 -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 +29 -10
- package/blocks/agents/components/agent-conversations.tsx +143 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +4 -3
- package/blocks/agents/components/agent-operators.tsx +164 -0
- package/blocks/agents/components/agent-run-detail.tsx +119 -0
- package/blocks/agents/components/agent-runs.tsx +19 -10
- 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 +226 -0
- 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/README.md +13 -0
- package/blocks/analytics/block.json +45 -1
- package/blocks/analytics/components/analytics-screen.tsx +222 -0
- package/blocks/analytics/components/kpi-tiles.tsx +1 -1
- package/blocks/analytics/components/leaderboard.tsx +14 -7
- package/blocks/analytics/components/report-builder.tsx +38 -11
- package/blocks/analytics/components/report-detail.tsx +84 -0
- package/blocks/analytics/components/trend-chart.tsx +62 -0
- package/blocks/appointments/README.md +6 -0
- package/blocks/appointments/block.json +33 -2
- package/blocks/appointments/components/availability-editor.tsx +17 -5
- package/blocks/appointments/components/booking-page.tsx +2 -2
- package/blocks/appointments/components/event-type-editor.tsx +2 -1
- package/blocks/appointments/components/event-type-related.tsx +4 -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 +38 -18
- package/blocks/appointments/components/meetings-screen.tsx +7 -4
- package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
- package/blocks/appointments/components/scheduling-screen.tsx +2 -1
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +17 -0
- package/blocks/campaigns/README.md +41 -11
- package/blocks/campaigns/block.json +105 -42
- package/blocks/campaigns/components/campaign-assets.tsx +17 -6
- package/blocks/campaigns/components/campaign-launch-step.tsx +114 -26
- package/blocks/campaigns/components/campaign-list.tsx +7 -3
- package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
- package/blocks/campaigns/components/campaign-wizard.tsx +52 -14
- package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
- package/blocks/campaigns/components/icp-detail.tsx +163 -0
- package/blocks/campaigns/components/icp-editor.tsx +22 -17
- package/blocks/campaigns/components/persona-detail.tsx +181 -0
- package/blocks/campaigns/components/persona-editor.tsx +19 -11
- package/blocks/campaigns/lib/campaigns-utils.ts +138 -5
- package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
- package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
- package/blocks/coach/README.md +10 -0
- package/blocks/coach/block.json +16 -1
- package/blocks/coach/components/call-scorecard.tsx +7 -7
- package/blocks/coach/components/coach-screen.tsx +222 -0
- 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/README.md +13 -0
- package/blocks/copilot/block.json +14 -1
- package/blocks/copilot/components/copilot-memory.tsx +6 -2
- package/blocks/copilot/components/copilot-panel.tsx +14 -5
- package/blocks/copilot/components/copilot-screen.tsx +148 -0
- package/blocks/copilot/components/copilot-team-chat.tsx +13 -6
- package/blocks/crm/README.md +34 -0
- package/blocks/crm/block.json +107 -2
- package/blocks/crm/components/companies-screen.tsx +359 -0
- package/blocks/crm/components/company-detail.tsx +10 -8
- package/blocks/crm/components/company-table.tsx +11 -4
- package/blocks/crm/components/contact-detail.tsx +10 -7
- package/blocks/crm/components/contact-lookup-link.tsx +44 -0
- package/blocks/crm/components/contact-table.tsx +10 -3
- package/blocks/crm/components/contacts-screen.tsx +377 -0
- package/blocks/crm/components/dedupe-review.tsx +13 -6
- package/blocks/crm/components/import-wizard.tsx +7 -9
- package/blocks/crm/components/list-select.tsx +20 -0
- package/blocks/crm/components/notes-panel.tsx +4 -4
- package/blocks/crm/components/record-engagement-tab.tsx +103 -0
- package/blocks/crm/components/record-form.tsx +6 -4
- package/blocks/crm/components/task-list.tsx +4 -4
- package/blocks/crm/components/tasks-screen.tsx +399 -0
- package/blocks/crm/hooks/use-all-lists.ts +18 -0
- package/blocks/crm/hooks/use-custom-fields.ts +6 -1
- package/blocks/crm/hooks/use-page-query.ts +9 -1
- package/blocks/crm/lib/crm-types.ts +26 -2
- package/blocks/crm/lib/read-lists.ts +16 -0
- package/blocks/crm/lib/screen-parts.tsx +105 -0
- package/blocks/deals/README.md +24 -6
- package/blocks/deals/block.json +160 -5
- 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 +25 -13
- 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 +31 -10
- package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
- package/blocks/deals/components/deals-screen.tsx +66 -26
- 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 +5 -5
- package/blocks/deals/hooks/use-pipeline-deals.ts +21 -4
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/deals/lib/deal-extras.ts +156 -0
- package/blocks/dialer/README.md +45 -4
- package/blocks/dialer/block.json +84 -3
- package/blocks/dialer/components/call-contact-button.tsx +269 -0
- package/blocks/dialer/components/call-log-screen.tsx +208 -0
- package/blocks/dialer/components/call-log.tsx +23 -7
- package/blocks/dialer/components/call-queue.tsx +4 -4
- package/blocks/dialer/components/dialer-screen.tsx +361 -0
- package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
- package/blocks/dialer/components/disposition-panel.tsx +1 -1
- package/blocks/dialer/components/live-calls-table.tsx +7 -3
- package/blocks/dialer/components/record-calls-tab.tsx +101 -0
- package/blocks/dialer/components/session-start-form.tsx +61 -12
- package/blocks/dialer/components/softphone.tsx +4 -2
- package/blocks/enrichment/README.md +29 -7
- package/blocks/enrichment/block.json +49 -9
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +83 -29
- package/blocks/enrichment/components/email-verify-badge.tsx +31 -11
- package/blocks/enrichment/components/enrich-button.tsx +32 -9
- package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +341 -0
- package/blocks/enrichment/components/waterfall-config.tsx +66 -17
- package/blocks/enrichment/lib/enrichment-types.ts +39 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
- package/blocks/forms/README.md +8 -0
- package/blocks/forms/block.json +139 -18
- 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 +4 -2
- package/blocks/forms/components/form-embed.tsx +4 -2
- 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 +56 -19
- 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 +17 -6
- package/blocks/forms/lib/forms-extra.ts +67 -0
- package/blocks/inbox/README.md +32 -3
- package/blocks/inbox/block.json +29 -2
- package/blocks/inbox/components/inbox-list.tsx +7 -3
- package/blocks/inbox/components/inbox-screen.tsx +294 -27
- package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
- package/blocks/inbox/components/reply-composer.tsx +65 -9
- package/blocks/inbox/components/tag-and-assign.tsx +4 -2
- package/blocks/inbox/components/thread-badge.tsx +2 -2
- package/blocks/inbox/components/thread-view.tsx +1 -1
- package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
- package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
- package/blocks/inbox/lib/inbox-types.ts +47 -1
- package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
- package/blocks/integrations/README.md +63 -1
- package/blocks/integrations/block.json +234 -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 +119 -0
- 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 +13 -8
- package/blocks/integrations/components/integrations-screen.tsx +152 -0
- 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 +11 -9
- package/blocks/integrations/components/sync-triggers.tsx +134 -0
- package/blocks/integrations/components/webhook-detail.tsx +179 -0
- package/blocks/integrations/components/webhook-manager.tsx +43 -25
- 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 +11 -1
- package/blocks/integrations/lib/load-more.tsx +141 -0
- package/blocks/intent/README.md +14 -0
- package/blocks/intent/block.json +27 -1
- package/blocks/intent/components/account-intent-score.tsx +5 -5
- package/blocks/intent/components/intent-feed.tsx +13 -10
- package/blocks/intent/components/intent-keyword-manager.tsx +16 -12
- package/blocks/intent/components/intent-screen.tsx +265 -0
- package/blocks/intent/components/keyword-detail.tsx +124 -0
- package/blocks/intent/lib/intent-utils.ts +4 -4
- package/blocks/lifecycle/block.json +13 -8
- 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/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +3 -1
- package/blocks/lifecycle/lib/lifecycle.ts +1 -1
- package/blocks/lists/README.md +20 -1
- package/blocks/lists/block.json +69 -3
- package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
- package/blocks/lists/components/add-to-list-button.tsx +20 -9
- package/blocks/lists/components/list-picker.tsx +17 -3
- package/blocks/lists/components/list-table.tsx +12 -6
- package/blocks/lists/components/lists-screen.tsx +346 -0
- package/blocks/lists/components/record-lists-tab.tsx +99 -0
- package/blocks/lists/components/suppression-manager.tsx +33 -8
- package/blocks/lists/components/suppressions-screen.tsx +264 -0
- package/blocks/mailboxes/README.md +17 -0
- package/blocks/mailboxes/block.json +69 -17
- package/blocks/mailboxes/components/connect-mailbox.tsx +32 -21
- package/blocks/mailboxes/components/domain-detail.tsx +148 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +20 -14
- package/blocks/mailboxes/components/mailbox-detail.tsx +157 -0
- package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
- package/blocks/mailboxes/components/mailboxes-screen.tsx +228 -0
- package/blocks/mailboxes/components/warmup-health.tsx +24 -22
- package/blocks/mailboxes/lib/mailboxes-utils.ts +26 -5
- package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
- package/blocks/newsletter/README.md +12 -0
- package/blocks/newsletter/block.json +157 -24
- package/blocks/newsletter/components/issue-scheduler.tsx +4 -3
- 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 +39 -14
- 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/block.json +28 -14
- package/blocks/nurture/components/nurture-builder.tsx +13 -3
- package/blocks/nurture/components/nurture-enroll.tsx +12 -10
- package/blocks/nurture/components/nurture-form.tsx +27 -10
- package/blocks/nurture/components/nurture-screen.tsx +26 -11
- package/blocks/nurture/components/nurture-stats.tsx +5 -4
- package/blocks/nurture/lib/nurture-types.ts +1 -1
- package/blocks/objects/README.md +18 -1
- package/blocks/objects/block.json +42 -21
- package/blocks/objects/components/attribute-field-input.tsx +14 -1
- package/blocks/objects/components/object-not-found.tsx +3 -1
- package/blocks/objects/components/object-record-detail.tsx +10 -2
- package/blocks/objects/components/object-schema-editor.tsx +4 -2
- package/blocks/objects/components/object-table.tsx +13 -3
- package/blocks/objects/components/objects-screen.tsx +147 -0
- package/blocks/objects/components/record-roles-panel.tsx +15 -5
- package/blocks/objects/lib/object-types.ts +1 -1
- package/blocks/pages/README.md +14 -0
- package/blocks/pages/block.json +140 -23
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +9 -4
- 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-list.tsx +2 -1
- 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 +65 -13
- package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
- package/blocks/quotes/components/product-picker.tsx +3 -1
- 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 +8 -4
- package/blocks/quotes/components/quote-viewer.tsx +7 -2
- package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
- package/blocks/radar/README.md +10 -0
- package/blocks/radar/block.json +41 -1
- package/blocks/radar/components/competitor-battle-card.tsx +1 -1
- package/blocks/radar/components/competitor-detail.tsx +117 -0
- package/blocks/radar/components/competitor-tracker.tsx +3 -1
- package/blocks/radar/components/radar-overview.tsx +4 -4
- package/blocks/radar/components/radar-report.tsx +4 -2
- package/blocks/radar/components/radar-screen.tsx +214 -0
- package/blocks/radar/components/report-detail.tsx +104 -0
- package/blocks/search/README.md +10 -0
- package/blocks/search/block.json +16 -1
- package/blocks/search/components/filter-builder.tsx +3 -3
- package/blocks/search/components/prospect-results.tsx +5 -3
- package/blocks/search/components/prospect-search-screen.tsx +235 -0
- package/blocks/search/components/saved-searches.tsx +4 -1
- package/blocks/search/hooks/use-filter-options.ts +20 -6
- package/blocks/search/lib/search-types.ts +2 -0
- package/blocks/sequencer/README.md +55 -9
- package/blocks/sequencer/block.json +126 -19
- package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +11 -5
- package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
- package/blocks/sequencer/components/sequence-builder.tsx +224 -60
- package/blocks/sequencer/components/sequence-detail.tsx +261 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +116 -0
- package/blocks/sequencer/components/sequence-list.tsx +34 -9
- package/blocks/sequencer/components/sequence-stats.tsx +11 -10
- package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
- package/blocks/sequencer/components/sequences-screen.tsx +162 -0
- package/blocks/sequencer/components/step-editor.tsx +14 -12
- package/blocks/sequencer/lib/sequencer-utils.ts +133 -10
- package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
- package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
- package/blocks/social-ads/README.md +19 -0
- package/blocks/social-ads/block.json +233 -1
- package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
- package/blocks/social-ads/components/ads-screen.tsx +152 -0
- package/blocks/social-ads/components/audience-sync-detail.tsx +194 -0
- package/blocks/social-ads/components/audience-sync.tsx +17 -9
- package/blocks/social-ads/components/campaign-ads.tsx +166 -0
- package/blocks/social-ads/components/campaign-detail.tsx +138 -0
- 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 +155 -0
- package/blocks/social-ads/components/creative-gallery.tsx +35 -7
- 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 +7 -3
- package/blocks/social-ads/components/post-detail.tsx +163 -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-post-list.tsx +11 -3
- package/blocks/social-ads/components/social-screen.tsx +187 -0
- 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/README.md +10 -0
- package/blocks/visitors/block.json +25 -1
- package/blocks/visitors/components/snippet-install.tsx +21 -10
- package/blocks/visitors/components/traffic-chart.tsx +4 -4
- package/blocks/visitors/components/visitor-company-card.tsx +26 -16
- package/blocks/visitors/components/visitor-detail.tsx +105 -0
- package/blocks/visitors/components/visitor-feed.tsx +16 -7
- package/blocks/visitors/components/visitors-screen.tsx +166 -0
- package/blocks/webchat/README.md +10 -0
- package/blocks/webchat/block.json +21 -7
- package/blocks/webchat/components/chat-widget-config.tsx +8 -5
- package/blocks/webchat/components/conversation-list.tsx +8 -4
- package/blocks/webchat/components/webchat-screen.tsx +245 -0
- package/blocks/workbench/README.md +11 -0
- package/blocks/workbench/block.json +48 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +5 -3
- package/blocks/workbench/components/run-progress.tsx +1 -1
- package/blocks/workbench/components/workbench-grid.tsx +10 -4
- package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
- package/blocks/workbench/components/workbench-screen.tsx +202 -0
- package/blocks/workbench/components/workbench-sources.tsx +194 -0
- package/blocks/workbench/components/workbench-table-list.tsx +14 -2
- package/blocks/workflows/README.md +13 -0
- package/blocks/workflows/block.json +28 -9
- package/blocks/workflows/components/run-history.tsx +31 -9
- package/blocks/workflows/components/trigger-config.tsx +3 -1
- package/blocks/workflows/components/workflow-editor.tsx +4 -2
- package/blocks/workflows/components/workflow-list.tsx +13 -6
- package/blocks/workflows/components/workflows-screen.tsx +193 -0
- package/dist/cli.js +3109 -1977
- package/dist/errors.d.mts +34 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +46 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.mjs +21 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.browser.js +11 -5
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +11 -5
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +164 -25
- package/dist/index.d.ts +164 -25
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +10 -4
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +10 -4
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +142 -5
- package/dist/react.d.ts +142 -5
- package/dist/react.js +10 -4
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +10 -4
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/add.d.ts +11 -4
- package/dist/types/blocks/brand.d.ts +122 -0
- package/dist/types/blocks/compose.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +27 -20
- package/dist/types/blocks/doctor.d.ts +6 -1
- package/dist/types/blocks/flows.d.ts +7 -1
- package/dist/types/blocks/navfile.d.ts +35 -0
- package/dist/types/blocks/orgBrand.d.ts +34 -0
- package/dist/types/blocks/paths.d.ts +4 -0
- package/dist/types/blocks/prefixes.d.ts +37 -0
- package/dist/types/blocks/registry.d.ts +17 -0
- package/dist/types/blocks/screens.d.ts +53 -0
- package/dist/types/cli.d.ts +5 -0
- package/dist/types/errors.d.ts +33 -0
- package/dist/types/http.d.ts +2 -18
- package/dist/types/version.d.ts +2 -2
- package/docs/agents.md +15 -4
- package/docs/api/README.md +2 -2
- package/docs/api/by-path.md +1 -0
- package/docs/api/desk.d.ts +98 -2
- package/docs/api/desk.md +123 -1
- package/docs/api/inbox.d.ts +10 -2
- package/docs/api/inbox.md +10 -2
- package/docs/api/seo.d.ts +19 -1
- package/docs/api/seo.md +24 -1
- package/package.json +16 -2
- package/stacks/6sense/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 +55 -17
- package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
- package/stacks/apollo/pages/companies.tsx.tmpl +33 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +51 -0
- 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 +147 -37
- 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 +64 -0
- package/stacks/clay/pages/companies.tsx.tmpl +31 -0
- package/stacks/clay/pages/contacts.tsx.tmpl +31 -0
- package/stacks/clay/pages/lists.tsx.tmpl +12 -0
- package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
- package/stacks/clay.json +73 -27
- 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 +98 -35
- 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 +104 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +26 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +45 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +20 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +103 -0
- 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/quotes.tsx.tmpl +11 -0
- package/stacks/hubspot/pages/sequences.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
- package/stacks/hubspot.json +181 -64
- 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 +52 -20
- 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 +68 -34
- 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 +46 -0
- 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 +106 -25
- package/stacks/pipedrive/pages/companies.tsx.tmpl +23 -0
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +27 -0
- package/stacks/pipedrive/pages/deals.tsx.tmpl +20 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +103 -0
- package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
- package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
- package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
- package/stacks/pipedrive.json +117 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +26 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +32 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +20 -0
- package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
- package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
- package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
- package/stacks/salesforce.json +85 -31
- package/stacks/zoominfo/pages/companies.tsx.tmpl +31 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +31 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +12 -0
- package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +115 -0
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +40 -9
- package/templates/next-app/app/globals.css.tmpl +349 -64
- package/templates/next-app/app/layout.tsx.tmpl +7 -2
- package/templates/next-app/components/app-shell.tsx.tmpl +104 -88
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ContactsScreen: the finished contacts workspace. A searchable, filterable, sortable, paginated grid on the left;
|
|
5
|
+
* the open contact (timeline, notes, tasks, fields) beside it. No props are required.
|
|
6
|
+
*
|
|
7
|
+
* Composes: ScreenLayout, StatStrip, Toolbar, SplitView, DetailPanel, FormSheet (screen kit) with RecordForm,
|
|
8
|
+
* ActivityTimeline, NotesPanel, TaskList, ImportWizard and DedupeReview (this block).
|
|
9
|
+
*
|
|
10
|
+
* Flows: search and filter (name, title, seniority, country; debounced, latest request wins) - sort the loaded
|
|
11
|
+
* page - paginate - open a contact (deep link ?id=, full page with &view=full) - create a contact (a 409 opens the existing one) - edit - delete one
|
|
12
|
+
* or many (confirm, "N of M" report, 404 = already gone) - import a CSV/Excel file - review and merge duplicates -
|
|
13
|
+
* add notes and tasks - hand a contact to other blocks through the slot props.
|
|
14
|
+
*
|
|
15
|
+
* SDK operations: none of its own. listContacts, getContact, getActivityFeed, duplicateCounts are `read`;
|
|
16
|
+
* the forms, notes and tasks are `write`; deleteContact (rows, detail and bulk) is `destructive` and always goes
|
|
17
|
+
* through ConfirmSpendDialog. Nothing here sends a message to a person.
|
|
18
|
+
*
|
|
19
|
+
* Locked (masked) emails and phones show as Locked: save the contact to a list or enrich it to reveal them.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useEffect, useMemo, useState } from "react";
|
|
23
|
+
import type { ReactNode } from "react";
|
|
24
|
+
import { Button, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
25
|
+
import { Building2, GitMerge, Lock, Plus, Trash2, Upload, Users } from "lucide-react";
|
|
26
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
27
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
28
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
29
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
30
|
+
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
31
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
32
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
33
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
34
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
35
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
36
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
37
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
38
|
+
import { rowActivationProps } from "@/g8/hooks/use-row-activation";
|
|
39
|
+
import { RecordLink } from "@/g8/components/screen/record-link";
|
|
40
|
+
import { plural } from "@/g8/lib/plural";
|
|
41
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
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";
|
|
46
|
+
import { g8Delete, g8Get } from "@/g8/lib/g8-client";
|
|
47
|
+
import { usePageQuery } from "../hooks/use-page-query";
|
|
48
|
+
import { contactName, contactSubtitle, errorStatus, isMasked, location } from "../lib/crm-types";
|
|
49
|
+
import type { ContactRecord, ScreenActionContext } from "../lib/crm-types";
|
|
50
|
+
import { Pager, SortHead, formatDay, sortRows, useBulkDelete, useRowSelection, useSort } from "../lib/screen-parts";
|
|
51
|
+
import { ActivityTimeline } from "./activity-timeline";
|
|
52
|
+
import { DedupeReview } from "./dedupe-review";
|
|
53
|
+
import { ImportWizard } from "./import-wizard";
|
|
54
|
+
import { NotesPanel } from "./notes-panel";
|
|
55
|
+
import { RecordForm } from "./record-form";
|
|
56
|
+
import { TaskList } from "./task-list";
|
|
57
|
+
|
|
58
|
+
export interface ContactsScreenProps {
|
|
59
|
+
/** Only members of this list. */
|
|
60
|
+
listId?: number;
|
|
61
|
+
pageSize?: number;
|
|
62
|
+
/** Buttons at the end of each row (enrich, add to list...). */
|
|
63
|
+
renderRowActions?: (row: ContactRecord, ctx: ScreenActionContext) => ReactNode;
|
|
64
|
+
/** Buttons in the open contact's header. */
|
|
65
|
+
renderDetailActions?: (contact: ContactRecord, ctx: ScreenActionContext) => ReactNode;
|
|
66
|
+
/** Extra tabs on the open contact. */
|
|
67
|
+
extraDetailTabs?: Array<{ id: string; label: string; render: (contact: ContactRecord) => ReactNode }>;
|
|
68
|
+
/** Extra buttons in the page header, before "New contact". */
|
|
69
|
+
toolbarActions?: ReactNode;
|
|
70
|
+
/** Extra bulk actions next to Delete (add to list, enrich, enroll...). */
|
|
71
|
+
renderBulkActions?: (selection: { ids: number[]; rows: ContactRecord[]; clear: () => void }) => ReactNode;
|
|
72
|
+
/** Called with a company id from the contact panel ("Open company"). Without it the button is hidden. */
|
|
73
|
+
onOpenCompany?: (companyId: number) => void;
|
|
74
|
+
className?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
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"];
|
|
81
|
+
const SENIORITY = ["C-Suite", "VP", "Director", "Manager"];
|
|
82
|
+
|
|
83
|
+
function sortValue(row: ContactRecord, key: string): string | null | undefined {
|
|
84
|
+
if (key === "name") return contactName(row);
|
|
85
|
+
if (key === "title") return row.job_title;
|
|
86
|
+
if (key === "company") return row.company_name;
|
|
87
|
+
if (key === "location") return location(row);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Locked() {
|
|
92
|
+
return <span className="inline-flex items-center gap-1 italic text-muted-foreground" title="Locked: save to a list or enrich to reveal"><Lock className="size-3" aria-hidden />Locked</span>;
|
|
93
|
+
}
|
|
94
|
+
function Cell({ value }: { value?: string | null }) {
|
|
95
|
+
return isMasked(value) ? <Locked /> : <>{value || <span className="text-muted-foreground">-</span>}</>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function ContactsScreen(props: ContactsScreenProps) {
|
|
99
|
+
const { listId, pageSize = 25 } = props;
|
|
100
|
+
const [idParam, setIdParamRaw] = useUrlState("id");
|
|
101
|
+
const [view, setView] = useUrlState("view");
|
|
102
|
+
const full = view === "full" && idParam != null && idParam !== "";
|
|
103
|
+
/** Changing or clearing the open record also leaves the full-page view. */
|
|
104
|
+
const setIdParam = (v: string | null): void => { if (v == null) setView(null); setIdParamRaw(v); };
|
|
105
|
+
const parsedId = idParam ? Number(idParam) : null;
|
|
106
|
+
/** A malformed deep link (`?id=abc`) opens nothing instead of fetching /contacts/NaN. */
|
|
107
|
+
const openId = parsedId != null && Number.isFinite(parsedId) ? parsedId : null;
|
|
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);
|
|
113
|
+
const [sheet, setSheet] = useState<"new" | "import" | "dupes" | null>(null);
|
|
114
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
115
|
+
const [note, setNote] = useState<string | null>(null);
|
|
116
|
+
const { sort, toggle: toggleSort } = useSort();
|
|
117
|
+
|
|
118
|
+
const list = usePageQuery<ContactRecord>(
|
|
119
|
+
(page, signal) => g8Get<ContactRecord[]>("/contacts", { page, limit: pageSize, list_id: effectiveListId, ...apiFilters }, { signal }),
|
|
120
|
+
[effectiveListId, filters],
|
|
121
|
+
pageSize,
|
|
122
|
+
["contacts", "list", "screen"],
|
|
123
|
+
urlFilters.ready,
|
|
124
|
+
);
|
|
125
|
+
const dupes = useG8Query((signal) => g8Get<{ pending?: number; count?: number }>("/duplicates/counts", { entity_type: "contact" }, { signal }), [], { key: ["duplicates", "counts", "contact"] });
|
|
126
|
+
const selection = useRowSelection<ContactRecord>(`${listId}|${JSON.stringify(filters)}`);
|
|
127
|
+
const rows = useMemo(() => sortRows(list.rows, sort, sortValue), [list.rows, sort]);
|
|
128
|
+
const hasFilters = urlFilters.active;
|
|
129
|
+
const pending = dupes.data?.data.pending ?? dupes.data?.data.count;
|
|
130
|
+
const lockedOnPage = list.rows.filter((r) => isMasked(r.work_email)).length;
|
|
131
|
+
|
|
132
|
+
const bulk = useBulkDelete("/contacts", (gone, total) => {
|
|
133
|
+
selection.drop(gone);
|
|
134
|
+
setConfirmDelete(false);
|
|
135
|
+
setNote(gone.length < total ? `Deleted ${gone.length} of ${total}. ${total - gone.length} could not be deleted; they are still selected.` : null);
|
|
136
|
+
if (openId != null && gone.includes(openId)) setIdParam(null);
|
|
137
|
+
void list.refetch();
|
|
138
|
+
void dupes.refetch();
|
|
139
|
+
});
|
|
140
|
+
const allOnPage = rows.length > 0 && rows.every((r) => selection.selected.has(r.id));
|
|
141
|
+
const reload = () => { void list.refetch(); void dupes.refetch(); };
|
|
142
|
+
const set = (key: FilterKey, v: string) => urlFilters.set(key, v);
|
|
143
|
+
|
|
144
|
+
useEffect(() => { setNote(null); }, [filters, listId]);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<ScreenLayout
|
|
148
|
+
className={props.className}
|
|
149
|
+
title="Contacts"
|
|
150
|
+
description="Everyone you sell to, with their history, notes and follow-ups."
|
|
151
|
+
icon={<Users className="size-5" aria-hidden />}
|
|
152
|
+
actions={
|
|
153
|
+
<>
|
|
154
|
+
{props.toolbarActions}
|
|
155
|
+
<Button variant="outline" size="sm" onClick={() => setSheet("dupes")}><GitMerge className="size-4" aria-hidden />Duplicates{pending ? ` (${pending})` : ""}</Button>
|
|
156
|
+
<Button variant="outline" size="sm" onClick={() => setSheet("import")}><Upload className="size-4" aria-hidden />Import</Button>
|
|
157
|
+
<Button size="sm" onClick={() => setSheet("new")}><Plus className="size-4" aria-hidden />New contact</Button>
|
|
158
|
+
</>
|
|
159
|
+
}
|
|
160
|
+
stats={[
|
|
161
|
+
{ label: "Contacts", value: list.loading && list.total === 0 ? "-" : list.total.toLocaleString(), hint: hasFilters ? "Matching the filters" : listId ? "In this list" : "In your workspace" },
|
|
162
|
+
{ label: "Possible duplicates", value: pending ?? "-", tone: pending ? "warning" : "neutral", hint: "Review and merge" },
|
|
163
|
+
{ label: "Locked on this page", value: lockedOnPage, hint: "Save to a list or enrich to reveal" },
|
|
164
|
+
]}
|
|
165
|
+
>
|
|
166
|
+
<SplitView
|
|
167
|
+
detailOpen={openId != null}
|
|
168
|
+
expanded={full}
|
|
169
|
+
onExpandedChange={(e) => setView(e ? "full" : null)}
|
|
170
|
+
onCloseDetail={() => setIdParam(null)}
|
|
171
|
+
detailWidth="lg"
|
|
172
|
+
list={
|
|
173
|
+
<div className="flex flex-col gap-3">
|
|
174
|
+
<Toolbar
|
|
175
|
+
search={{ value: draft.name, onChange: (v) => set("name", v), placeholder: "Search contacts by name" }}
|
|
176
|
+
filters={
|
|
177
|
+
<>
|
|
178
|
+
<input aria-label="Filter by job title" placeholder="Job title" className="h-9 w-36 rounded-md border bg-background px-3 text-sm" value={draft.job_title} onChange={(e) => set("job_title", e.target.value)} />
|
|
179
|
+
<select aria-label="Filter by seniority" className="h-9 rounded-md border bg-background px-2 text-sm" value={draft.seniority_level} onChange={(e) => set("seniority_level", e.target.value)}>
|
|
180
|
+
<option value="">Any seniority</option>
|
|
181
|
+
{SENIORITY.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
182
|
+
</select>
|
|
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)} />
|
|
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}
|
|
195
|
+
</>
|
|
196
|
+
}
|
|
197
|
+
/>
|
|
198
|
+
|
|
199
|
+
{selection.ids.length > 0 ? (
|
|
200
|
+
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm" role="status">
|
|
201
|
+
<span className="tabular-nums">{selection.ids.length} selected</span>
|
|
202
|
+
{props.renderBulkActions?.({ ids: selection.ids, rows: selection.rows, clear: selection.clear })}
|
|
203
|
+
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}><Trash2 className="size-4" aria-hidden />Delete {selection.ids.length}</Button>
|
|
204
|
+
<Button variant="ghost" size="sm" onClick={selection.clear}>Clear selection</Button>
|
|
205
|
+
</div>
|
|
206
|
+
) : null}
|
|
207
|
+
{list.error ? <ApiError error={list.error} title="Could not load contacts" onRetry={() => void list.refetch()} /> : null}
|
|
208
|
+
{note ? <p role="alert" className="text-sm text-destructive">{note}</p> : null}
|
|
209
|
+
|
|
210
|
+
<div className="overflow-hidden rounded-md border">
|
|
211
|
+
<Table>
|
|
212
|
+
<TableHeader>
|
|
213
|
+
<TableRow>
|
|
214
|
+
<TableHead className="w-8"><input type="checkbox" aria-label="Select page" checked={allOnPage} onChange={() => selection.togglePage(rows)} /></TableHead>
|
|
215
|
+
<SortHead label="Name" sortKey="name" sort={sort} onSort={toggleSort} />
|
|
216
|
+
<SortHead label="Title" sortKey="title" sort={sort} onSort={toggleSort} />
|
|
217
|
+
<SortHead label="Company" sortKey="company" sort={sort} onSort={toggleSort} />
|
|
218
|
+
<TableHead>Email</TableHead>
|
|
219
|
+
<SortHead label="Location" sortKey="location" sort={sort} onSort={toggleSort} />
|
|
220
|
+
{props.renderRowActions ? <TableHead className="w-8"><span className="sr-only">Actions</span></TableHead> : null}
|
|
221
|
+
</TableRow>
|
|
222
|
+
</TableHeader>
|
|
223
|
+
<TableBody>
|
|
224
|
+
{list.loading && rows.length === 0
|
|
225
|
+
? Array.from({ length: 6 }, (_, i) => <TableRow key={`sk${i}`}><TableCell colSpan={6 + (props.renderRowActions ? 1 : 0)}><Skeleton className="h-6 w-full" /></TableCell></TableRow>)
|
|
226
|
+
: rows.map((row) => {
|
|
227
|
+
const name = contactName(row);
|
|
228
|
+
return (
|
|
229
|
+
<TableRow key={row.id} data-testid={`contact-row-${row.id}`} data-state={row.id === openId ? "selected" : undefined} className="cursor-pointer" {...rowActivationProps(() => setIdParam(String(row.id)))}>
|
|
230
|
+
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
231
|
+
<input type="checkbox" aria-label={`Select ${name}`} checked={selection.selected.has(row.id)} onChange={() => selection.toggle(row)} />
|
|
232
|
+
</TableCell>
|
|
233
|
+
<TableCell>
|
|
234
|
+
<span className="flex items-center gap-2 font-medium"><EntityAvatar name={name} kind="person" size="sm" /><RecordLink param="id" id={row.id} onOpen={() => setIdParam(String(row.id))}>{name}</RecordLink></span>
|
|
235
|
+
</TableCell>
|
|
236
|
+
<TableCell><Cell value={row.job_title} /></TableCell>
|
|
237
|
+
<TableCell><Cell value={row.company_name} /></TableCell>
|
|
238
|
+
<TableCell><Cell value={row.work_email} /></TableCell>
|
|
239
|
+
<TableCell><Cell value={location(row)} /></TableCell>
|
|
240
|
+
{props.renderRowActions ? <TableCell onClick={(e) => e.stopPropagation()}>{props.renderRowActions(row, { reload })}</TableCell> : null}
|
|
241
|
+
</TableRow>
|
|
242
|
+
);
|
|
243
|
+
})}
|
|
244
|
+
</TableBody>
|
|
245
|
+
</Table>
|
|
246
|
+
{!list.loading && !list.error && rows.length === 0 ? (
|
|
247
|
+
<EmptyState
|
|
248
|
+
title={hasFilters ? "No contacts match these filters" : "No contacts yet"}
|
|
249
|
+
description={hasFilters ? "Try fewer filters." : "Add your first contact or import a file."}
|
|
250
|
+
action={hasFilters ? <Button variant="outline" onClick={urlFilters.clear}>Clear filters</Button> : <Button onClick={() => setSheet("new")}>New contact</Button>}
|
|
251
|
+
/>
|
|
252
|
+
) : null}
|
|
253
|
+
<Pager page={list.page} pageCount={list.pageCount} total={list.total} noun="contacts" hasNext={list.hasNext} loading={list.loading} onPage={list.setPage} />
|
|
254
|
+
</div>
|
|
255
|
+
{sort ? <p className="text-xs text-muted-foreground">Sorted within this page only.</p> : null}
|
|
256
|
+
</div>
|
|
257
|
+
}
|
|
258
|
+
detail={openId != null ? (
|
|
259
|
+
<ContactPanel key={openId} full={full} setFull={(v) => setView(v ? "full" : null)} contactId={openId} seed={list.rows.find((r) => r.id === openId)} onClose={() => setIdParam(null)} onChanged={reload} onDeleted={() => { setIdParam(null); reload(); }}
|
|
260
|
+
onOpenCompany={props.onOpenCompany} renderActions={props.renderDetailActions} extraTabs={props.extraDetailTabs} />
|
|
261
|
+
) : null}
|
|
262
|
+
/>
|
|
263
|
+
|
|
264
|
+
<FormSheet open={sheet === "new"} onOpenChange={(o) => setSheet(o ? "new" : null)} title="New contact" description="A name, an email or a LinkedIn URL is enough.">
|
|
265
|
+
<RecordForm entity="contact" listId={listId} onCancel={() => setSheet(null)}
|
|
266
|
+
onSaved={({ id }) => { setSheet(null); reload(); if (id != null) setIdParam(String(id)); }}
|
|
267
|
+
onDuplicate={(existing) => { setSheet(null); if (existing != null) setIdParam(String(existing)); }} />
|
|
268
|
+
</FormSheet>
|
|
269
|
+
<FormSheet open={sheet === "import"} onOpenChange={(o) => setSheet(o ? "import" : null)} title="Import contacts" description="Upload a CSV or Excel file, map the columns and choose where they go.">
|
|
270
|
+
<ImportWizard listId={listId} onImported={reload} />
|
|
271
|
+
</FormSheet>
|
|
272
|
+
<FormSheet open={sheet === "dupes"} onOpenChange={(o) => setSheet(o ? "dupes" : null)} title="Duplicate contacts" description="Merge pairs, or mark them as not duplicates.">
|
|
273
|
+
<DedupeReview entity="contact" onChanged={reload} />
|
|
274
|
+
</FormSheet>
|
|
275
|
+
|
|
276
|
+
<ConfirmSpendDialog
|
|
277
|
+
open={confirmDelete}
|
|
278
|
+
tier="destructive"
|
|
279
|
+
title={`Delete ${plural(selection.ids.length, "contact")}`}
|
|
280
|
+
description="They are removed from every list and sequence."
|
|
281
|
+
affected={selection.ids.length}
|
|
282
|
+
confirming={bulk.loading}
|
|
283
|
+
onConfirm={() => void bulk.mutate(selection.ids)}
|
|
284
|
+
onCancel={() => setConfirmDelete(false)}
|
|
285
|
+
/>
|
|
286
|
+
</ScreenLayout>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
interface ContactPanelProps {
|
|
291
|
+
full: boolean;
|
|
292
|
+
setFull: (full: boolean) => void;
|
|
293
|
+
contactId: number;
|
|
294
|
+
/** The list row, for fields the detail answer lacks (owner). */
|
|
295
|
+
seed?: ContactRecord;
|
|
296
|
+
onClose: () => void;
|
|
297
|
+
onChanged: () => void;
|
|
298
|
+
onDeleted: () => void;
|
|
299
|
+
onOpenCompany?: (companyId: number) => void;
|
|
300
|
+
renderActions?: ContactsScreenProps["renderDetailActions"];
|
|
301
|
+
extraTabs?: ContactsScreenProps["extraDetailTabs"];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function ContactPanel({ full, setFull, contactId, seed, onClose, onChanged, onDeleted, onOpenCompany, renderActions, extraTabs = [] }: ContactPanelProps) {
|
|
305
|
+
const [editing, setEditing] = useState(false);
|
|
306
|
+
const [confirm, setConfirm] = useState(false);
|
|
307
|
+
const [feedKey, setFeedKey] = useState(0);
|
|
308
|
+
const query = useG8Query((signal) => g8Get<ContactRecord>(`/contacts/${contactId}`, { include_custom_fields: true }, { signal }), [contactId], {
|
|
309
|
+
key: ["contacts", "detail", contactId],
|
|
310
|
+
// The row the contact was opened from paints the panel at once; the full record replaces it.
|
|
311
|
+
placeholderData: seed ? { data: seed } : undefined,
|
|
312
|
+
});
|
|
313
|
+
/** Edit, delete and slot actions wait for the full record: a list row must never seed the edit form. */
|
|
314
|
+
const partial = query.isPlaceholderData === true;
|
|
315
|
+
const remove = useG8Mutation(async () => {
|
|
316
|
+
try { await g8Delete(`/contacts/${contactId}`); } catch (e) { if (errorStatus(e) !== 404) throw e; }
|
|
317
|
+
}, { invalidates: [["contacts", "list"]], onSuccess: () => { setConfirm(false); onDeleted(); } });
|
|
318
|
+
const bump = () => setFeedKey((k) => k + 1);
|
|
319
|
+
|
|
320
|
+
if (query.loading && !query.data) return <div className="flex flex-col gap-3 p-4"><Skeleton className="h-14 w-full" /><Skeleton className="h-40 w-full" /></div>;
|
|
321
|
+
// A failed full record must not leave the list row on screen with every action disabled.
|
|
322
|
+
if (query.error && (!query.data || partial)) return <div className="p-4"><ApiError error={query.error} title="Could not load this contact" onRetry={() => void query.refetch()} /></div>;
|
|
323
|
+
const contact = query.data!.data;
|
|
324
|
+
const name = contactName(contact);
|
|
325
|
+
const val = (v?: string | null): ReactNode => (isMasked(v) ? <Locked /> : v || <span className="text-muted-foreground">-</span>);
|
|
326
|
+
|
|
327
|
+
const overview = (
|
|
328
|
+
<KeyValueList columns={2} items={[
|
|
329
|
+
{ label: "Work email", value: val(contact.work_email) },
|
|
330
|
+
{ label: "Direct phone", value: val(contact.direct_phone) },
|
|
331
|
+
{ label: "Mobile phone", value: val(contact.mobile_phone) },
|
|
332
|
+
{ label: "LinkedIn", value: val(contact.linkedin_url) },
|
|
333
|
+
{ label: "Seniority", value: val(contact.seniority_level) },
|
|
334
|
+
{ label: "Department", value: val(contact.job_department) },
|
|
335
|
+
{ label: "Location", value: val(location(contact)) },
|
|
336
|
+
{ label: "Owner", value: val(contact.owner_name ?? seed?.owner_name) },
|
|
337
|
+
{ label: "Added", value: val(formatDay(contact.created_at)) },
|
|
338
|
+
...Object.entries(contact.custom_fields ?? {}).map(([k, v]) => ({ label: k, value: val(v) })),
|
|
339
|
+
...(contact.about ? [{ label: "About", value: contact.about }] : []),
|
|
340
|
+
]} />
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<>
|
|
345
|
+
<DetailPanel
|
|
346
|
+
title={name}
|
|
347
|
+
subtitle={contactSubtitle(contact)}
|
|
348
|
+
avatar={<EntityAvatar name={name} kind="person" size="lg" />}
|
|
349
|
+
badges={isMasked(contact.work_email) ? <StatusPill tone="warning" dot>Locked email</StatusPill> : undefined}
|
|
350
|
+
onClose={onClose}
|
|
351
|
+
onExpand={full ? undefined : () => setFull(true)}
|
|
352
|
+
onCollapse={full ? () => setFull(false) : undefined}
|
|
353
|
+
actions={
|
|
354
|
+
<>
|
|
355
|
+
{partial ? null : renderActions?.(contact, { reload: () => { void query.refetch(); bump(); onChanged(); } })}
|
|
356
|
+
{contact.company?.id && onOpenCompany ? <Button variant="outline" size="sm" onClick={() => onOpenCompany(contact.company!.id as number)}><Building2 className="size-4" aria-hidden />Open company</Button> : null}
|
|
357
|
+
<Button variant="outline" size="sm" disabled={partial} title={partial ? "Loading the full record" : undefined} onClick={() => setEditing(true)}>Edit</Button>
|
|
358
|
+
<Button variant="destructive" size="sm" disabled={partial} onClick={() => setConfirm(true)}>Delete</Button>
|
|
359
|
+
</>
|
|
360
|
+
}
|
|
361
|
+
tabs={[
|
|
362
|
+
{ id: "overview", label: "Overview", content: overview },
|
|
363
|
+
{ id: "activity", label: "Activity", content: <ActivityTimeline entityType="contact" entityId={contactId} refreshKey={feedKey} /> },
|
|
364
|
+
{ id: "notes", label: "Notes", content: <NotesPanel entity="contact" entityId={contactId} onChanged={bump} /> },
|
|
365
|
+
{ id: "tasks", label: "Tasks", content: <TaskList entity="contact" entityId={contactId} onChanged={bump} /> },
|
|
366
|
+
...(partial ? [] : extraTabs).map((t) => ({ id: t.id, label: t.label, content: t.render(contact) })),
|
|
367
|
+
]}
|
|
368
|
+
/>
|
|
369
|
+
{remove.error ? <div className="p-4"><ApiError error={remove.error} title="Could not delete the contact" /></div> : null}
|
|
370
|
+
<FormSheet open={editing && !partial} onOpenChange={setEditing} title={`Edit ${name}`}>
|
|
371
|
+
<RecordForm entity="contact" record={contact} onCancel={() => setEditing(false)} onSaved={() => { setEditing(false); void query.refetch(); bump(); onChanged(); }} />
|
|
372
|
+
</FormSheet>
|
|
373
|
+
<ConfirmSpendDialog open={confirm} tier="destructive" title={`Delete ${name}`} description="The contact is removed from every list and sequence." affected={1}
|
|
374
|
+
confirming={remove.loading} onConfirm={() => void remove.mutate(undefined)} onCancel={() => setConfirm(false)} />
|
|
375
|
+
</>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
@@ -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";
|
|
@@ -29,6 +30,7 @@ import { useJobPoll } from "@/g8/hooks/use-job-poll";
|
|
|
29
30
|
import { usePaginated } from "@/g8/hooks/use-paginated";
|
|
30
31
|
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
31
32
|
import { isMasked } from "../lib/crm-types";
|
|
33
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
32
34
|
|
|
33
35
|
export interface DedupeReviewProps {
|
|
34
36
|
entity: "contact" | "company";
|
|
@@ -49,10 +51,14 @@ const CRITERIA = [
|
|
|
49
51
|
function side(pair: Pair, s: "a" | "b", entity: "contact" | "company") {
|
|
50
52
|
const name = entity === "contact" ? [pair[`${s}_first_name`], pair[`${s}_last_name`]].filter(Boolean).join(" ") : String(pair[`${s}_name`] ?? "");
|
|
51
53
|
const lines = entity === "contact" ? [pair[`${s}_email`], pair[`${s}_phone`], pair[`${s}_owner_name`]] : [pair[`${s}_domain`], pair[`${s}_industry`], pair[`${s}_owner_name`]];
|
|
52
|
-
return { id: pair[`${s}_id`] as number, name: name ||
|
|
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))) };
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/** The recent-merges API has no paging: a full read is announced under the list. */
|
|
58
|
+
const RECENT_LIMIT = 25;
|
|
59
|
+
|
|
55
60
|
export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps) {
|
|
61
|
+
const brand = useBrand();
|
|
56
62
|
const plural = entity === "contact" ? "contacts" : "companies";
|
|
57
63
|
const [merging, setMerging] = useState<{ pair: Pair; keep: "a" | "b" } | null>(null);
|
|
58
64
|
const [picked, setPicked] = useState<Set<string>>(new Set());
|
|
@@ -65,7 +71,7 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
|
|
|
65
71
|
const res = await g8Get<{ items: Pair[]; next_cursor?: string | null }>("/duplicates", { entity_type: entity, status: "pending", limit: 25, cursor }, { signal });
|
|
66
72
|
return { data: res.data.items ?? [], pagination: { next_cursor: res.data.next_cursor ?? null } };
|
|
67
73
|
}, [entity]);
|
|
68
|
-
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]);
|
|
69
75
|
const [busy, setBusy] = useState<string | null>(null);
|
|
70
76
|
const dropPick = (id: string | undefined) => id && setPicked((p) => { const n = new Set(p); n.delete(id); return n; });
|
|
71
77
|
const refreshAll = () => { void pairs.reload(); void counts.refetch(); void recent.refetch(); onChanged?.(); };
|
|
@@ -73,9 +79,9 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
|
|
|
73
79
|
const merge = useG8Mutation((v: { pair: Pair; keep: "a" | "b" }) => {
|
|
74
80
|
const keepId = v.pair[`${v.keep}_id`], dropId = v.pair[`${v.keep === "a" ? "b" : "a"}_id`];
|
|
75
81
|
return g8Post(`/${plural}/${keepId}/merge`, { secondary_id: dropId, suggestion_id: v.pair.suggestion_id });
|
|
76
|
-
}, { onSuccess: (_r, v) => { dropPick(v.pair.suggestion_id); setMerging(null); refreshAll(); } });
|
|
77
|
-
const reject = useG8Mutation((id: string) => { setBusy(id); return g8Post(`/duplicates/${id}/reject`, {}); }, { onSuccess: (_r, id) => { dropPick(id); setBusy(null); refreshAll(); }, onError: () => setBusy(null) });
|
|
78
|
-
const undo = useG8Mutation((id: string) => { setBusy(id); return g8Post(`/duplicates/undo-merge/${id}`, {}); }, { onSuccess: () => { setBusy(null); refreshAll(); }, onError: () => setBusy(null) });
|
|
82
|
+
}, { invalidates: [plural, "duplicates"], onSuccess: (_r, v) => { dropPick(v.pair.suggestion_id); setMerging(null); refreshAll(); } });
|
|
83
|
+
const reject = useG8Mutation((id: string) => { setBusy(id); return g8Post(`/duplicates/${id}/reject`, {}); }, { invalidates: ["duplicates"], onSuccess: (_r, id) => { dropPick(id); setBusy(null); refreshAll(); }, onError: () => setBusy(null) });
|
|
84
|
+
const undo = useG8Mutation((id: string) => { setBusy(id); return g8Post(`/duplicates/undo-merge/${id}`, {}); }, { invalidates: [plural, "duplicates"], onSuccess: () => { setBusy(null); refreshAll(); }, onError: () => setBusy(null) });
|
|
79
85
|
const bulk = useG8Mutation(() => g8Post<{ run_id: string }>("/duplicates/bulk-merge", { suggestion_ids: [...picked], criterion }), {
|
|
80
86
|
onSuccess: (r) => { setBulkOpen(false); setRunId(r.data.run_id); },
|
|
81
87
|
});
|
|
@@ -109,7 +115,7 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
|
|
|
109
115
|
{run.polling || run.status ? <p role="status" className="text-sm text-muted-foreground">Merging: {run.status?.merged ?? 0} of {run.status?.total ?? picked.size} done, {run.status?.blocked ?? 0} blocked.</p> : null}
|
|
110
116
|
{mergeError ? <ApiError error={mergeError} title="That did not go through" /> : null}
|
|
111
117
|
{pairs.error ? <ApiError error={pairs.error} title="Could not load duplicates" onRetry={() => void pairs.reload()} /> : null}
|
|
112
|
-
{!pairs.loading && !pairs.error && pairs.items.length === 0 ? <EmptyState title="No duplicates to review" description=
|
|
118
|
+
{!pairs.loading && !pairs.error && pairs.items.length === 0 ? <EmptyState title="No duplicates to review" description={`New suggestions appear here when ${brand.linkLabel} finds likely matches.`} /> : null}
|
|
113
119
|
|
|
114
120
|
{pairs.items.map((pair) => (
|
|
115
121
|
<div key={pair.suggestion_id} className="rounded-md border p-3" data-testid="duplicate-pair">
|
|
@@ -143,6 +149,7 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
|
|
|
143
149
|
{m.can_undo ? <Button size="sm" variant="outline" loading={undo.loading && busy === m.id} onClick={() => void undo.mutate(m.id)}>Undo</Button> : null}
|
|
144
150
|
</div>
|
|
145
151
|
))}
|
|
152
|
+
<FirstNotice shown={(recent.data?.data ?? []).length} limit={RECENT_LIMIT} noun="merges" />
|
|
146
153
|
</TabsContent>
|
|
147
154
|
</Tabs>
|
|
148
155
|
|
|
@@ -20,10 +20,12 @@
|
|
|
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";
|
|
26
27
|
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
28
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
27
29
|
|
|
28
30
|
export interface ImportWizardProps {
|
|
29
31
|
/** Largest file accepted. Default 5 MiB: the /api/g8 proxy rejects bigger bodies (raise maxBodyBytes there first). */
|
|
@@ -37,7 +39,6 @@ export interface ImportWizardProps {
|
|
|
37
39
|
export interface ImportResult { imported?: number | null; updated?: number | null; skipped?: number | null; failed?: number | null; list_id?: number | null }
|
|
38
40
|
interface Upload { datafile_id: string; row_count?: number | null; columns: string[] }
|
|
39
41
|
interface Preview { rows: Array<Record<string, unknown>>; total: number }
|
|
40
|
-
interface ListOption { id: number; title: string; type?: string | null }
|
|
41
42
|
|
|
42
43
|
export const IMPORT_TARGETS: Array<{ value: string; label: string }> = [
|
|
43
44
|
{ value: "CONTACT_FIRST_NAME", label: "First name" }, { value: "CONTACT_LAST_NAME", label: "Last name" },
|
|
@@ -68,6 +69,7 @@ export function guessMapping(columns: string[]): Record<string, string> {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, className }: ImportWizardProps) {
|
|
72
|
+
const brand = useBrand();
|
|
71
73
|
const [tooBig, setTooBig] = useState<string | null>(null);
|
|
72
74
|
const [upload, setUpload] = useState<Upload | null>(null);
|
|
73
75
|
const [mapping, setMapping] = useState<Record<string, string>>({});
|
|
@@ -78,7 +80,6 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
|
|
|
78
80
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
79
81
|
const [result, setResult] = useState<ImportResult | null>(null);
|
|
80
82
|
|
|
81
|
-
const lists = useG8Query((signal) => g8Get<ListOption[]>("/lists", { limit: 200 }, { signal }), [], { enabled: upload != null && listId == null });
|
|
82
83
|
const send = useG8Mutation((file: File) => { const body = new FormData(); body.append("file", file); return g8Post<Upload>("/imports", body); }, {
|
|
83
84
|
onSuccess: (r) => { setUpload(r.data); setMapping(guessMapping(r.data.columns ?? [])); },
|
|
84
85
|
});
|
|
@@ -105,7 +106,7 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
|
|
|
105
106
|
if (result) {
|
|
106
107
|
return (
|
|
107
108
|
<div className={`flex flex-col gap-3 ${className ?? ""}`} role="status">
|
|
108
|
-
<h3 className="text-base font-
|
|
109
|
+
<h3 className="text-base font-medium">Import finished</h3>
|
|
109
110
|
<p className="text-sm">{result.imported ?? 0} new, {result.updated ?? 0} updated, {result.skipped ?? 0} skipped, {result.failed ?? 0} failed.</p>
|
|
110
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>
|
|
111
112
|
</div>
|
|
@@ -159,15 +160,12 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
|
|
|
159
160
|
<h3 className="text-sm font-medium">3. Destination and rules</h3>
|
|
160
161
|
{listId == null ? (
|
|
161
162
|
<div className="flex flex-wrap items-center gap-2">
|
|
162
|
-
<
|
|
163
|
-
<option value="">New list...</option>
|
|
164
|
-
{(lists.data?.data ?? []).filter((l) => !l.type || l.type === "contacts").map((l) => <option key={l.id} value={l.id}>{l.title}</option>)}
|
|
165
|
-
</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)} />
|
|
166
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}
|
|
167
165
|
</div>
|
|
168
166
|
) : null}
|
|
169
167
|
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={policy === "override"} onChange={(e) => setPolicy(e.target.checked ? "override" : "autofill")} /> Overwrite existing values (default only fills blanks)</label>
|
|
170
|
-
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enrich} onChange={(e) => setEnrich(e.target.checked)} /> Fill missing fields from
|
|
168
|
+
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enrich} onChange={(e) => setEnrich(e.target.checked)} /> Fill missing fields from {brand.linkLabel} (uses credits)</label>
|
|
171
169
|
</section>
|
|
172
170
|
|
|
173
171
|
{run.error ? <ApiError error={run.error} title="The import failed" /> : null}
|
|
@@ -181,7 +179,7 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
|
|
|
181
179
|
open={confirmOpen}
|
|
182
180
|
tier={enrich ? "billable" : "destructive"}
|
|
183
181
|
title={`Import ${rows.toLocaleString()} rows`}
|
|
184
|
-
description={policy === "override" ? "Matching contacts get their existing values replaced by the file." :
|
|
182
|
+
description={policy === "override" ? "Matching contacts get their existing values replaced by the file." : `Missing fields are filled from ${brand.linkLabel}.`}
|
|
185
183
|
affected={enrich ? undefined : rows}
|
|
186
184
|
confirming={run.loading}
|
|
187
185
|
confirmLabel="Import"
|
|
@@ -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
|
+
}
|
|
@@ -45,11 +45,11 @@ export function NotesPanel({ entity, entityId, onChanged, className }: NotesPane
|
|
|
45
45
|
const [editing, setEditing] = useState<{ id: string; content: string } | null>(null);
|
|
46
46
|
const [deleting, setDeleting] = useState<NoteItem | null>(null);
|
|
47
47
|
|
|
48
|
-
const notes = useG8Query((signal) => g8Get<NoteItem[]>(base, undefined, { signal }), [base]);
|
|
48
|
+
const notes = useG8Query((signal) => g8Get<NoteItem[]>(base, undefined, { signal }), [base], { key: ["notes", entity, entityId] });
|
|
49
49
|
const done = () => { void notes.refetch(); onChanged?.(); };
|
|
50
|
-
const add = useG8Mutation((content: string) => g8Post(base, { content }), { onSuccess: () => { setDraft(""); done(); } });
|
|
51
|
-
const edit = useG8Mutation((v: { id: string; content: string }) => g8Patch(`/notes/${v.id}`, { content: v.content }), { onSuccess: () => { setEditing(null); done(); } });
|
|
52
|
-
const remove = useG8Mutation((id: string) => g8Delete(`/notes/${id}`), { onSuccess: () => { setDeleting(null); done(); } });
|
|
50
|
+
const add = useG8Mutation((content: string) => g8Post(base, { content }), { invalidates: [["notes", entity, entityId]], onSuccess: () => { setDraft(""); done(); } });
|
|
51
|
+
const edit = useG8Mutation((v: { id: string; content: string }) => g8Patch(`/notes/${v.id}`, { content: v.content }), { invalidates: [["notes"]], onSuccess: () => { setEditing(null); done(); } });
|
|
52
|
+
const remove = useG8Mutation((id: string) => g8Delete(`/notes/${id}`), { invalidates: [["notes"]], onSuccess: () => { setDeleting(null); done(); } });
|
|
53
53
|
|
|
54
54
|
const items = (notes.data?.data ?? []) as NoteItem[];
|
|
55
55
|
return (
|