@graph8/sdk 0.361.0 → 0.370.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/blocks/_foundation/README.md +172 -1
- package/blocks/_foundation/block.json +234 -13
- package/blocks/_foundation/components/api-error.tsx +71 -25
- package/blocks/_foundation/components/brand-provider.tsx +23 -0
- package/blocks/_foundation/components/confirmed-action.tsx +90 -0
- package/blocks/_foundation/components/connect-graph8.tsx +280 -0
- package/blocks/_foundation/components/empty-state.tsx +39 -7
- package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
- package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
- package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
- package/blocks/_foundation/components/screen/index.ts +22 -0
- package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
- package/blocks/_foundation/components/screen/section-card.tsx +47 -0
- package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
- package/blocks/_foundation/components/screen/split-view.tsx +125 -0
- package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
- package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +32 -0
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
- package/blocks/_foundation/hooks/use-brand.ts +52 -0
- package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
- package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
- package/blocks/_foundation/hooks/use-paginated.ts +120 -0
- package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
- package/blocks/_foundation/hooks/use-url-state.ts +74 -0
- package/blocks/_foundation/lib/g8-cache.ts +454 -0
- package/blocks/_foundation/lib/g8-client.ts +1 -1
- package/blocks/_foundation/lib/plural.ts +23 -0
- package/blocks/_foundation/server/connect-route.ts +15 -0
- package/blocks/_foundation/server/connect.ts +127 -0
- package/blocks/_foundation/server/local-key.ts +70 -0
- package/blocks/_foundation/server/proxy.ts +103 -3
- package/blocks/_foundation/server/route.ts +16 -4
- package/blocks/agents/README.md +14 -0
- package/blocks/agents/block.json +33 -1
- package/blocks/agents/components/agent-builder.tsx +13 -8
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
- package/blocks/agents/components/agent-run-detail.tsx +102 -0
- package/blocks/agents/components/agent-runs.tsx +16 -7
- package/blocks/agents/components/agents-screen.tsx +184 -0
- package/blocks/analytics/README.md +13 -0
- package/blocks/analytics/block.json +45 -1
- package/blocks/analytics/components/analytics-screen.tsx +209 -0
- package/blocks/analytics/components/leaderboard.tsx +14 -7
- package/blocks/analytics/components/report-builder.tsx +23 -9
- package/blocks/analytics/components/report-detail.tsx +84 -0
- package/blocks/analytics/components/trend-chart.tsx +62 -0
- package/blocks/appointments/README.md +21 -1
- package/blocks/appointments/block.json +43 -2
- package/blocks/appointments/components/availability-editor.tsx +17 -5
- package/blocks/appointments/components/event-type-editor.tsx +2 -1
- package/blocks/appointments/components/event-type-related.tsx +77 -0
- package/blocks/appointments/components/meeting-list.tsx +14 -6
- package/blocks/appointments/components/meetings-screen.tsx +157 -0
- package/blocks/appointments/components/scheduling-screen.tsx +122 -0
- package/blocks/appointments/hooks/use-event-types.ts +17 -0
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +26 -0
- package/blocks/campaigns/README.md +41 -11
- package/blocks/campaigns/block.json +105 -42
- package/blocks/campaigns/components/campaign-assets.tsx +16 -5
- package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
- package/blocks/campaigns/components/campaign-list.tsx +7 -3
- package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
- package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
- package/blocks/campaigns/components/icp-detail.tsx +154 -0
- package/blocks/campaigns/components/icp-editor.tsx +15 -15
- package/blocks/campaigns/components/persona-detail.tsx +172 -0
- package/blocks/campaigns/components/persona-editor.tsx +14 -11
- package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
- package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
- package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
- package/blocks/coach/README.md +10 -0
- package/blocks/coach/block.json +16 -1
- package/blocks/coach/components/coach-screen.tsx +222 -0
- package/blocks/copilot/README.md +13 -0
- package/blocks/copilot/block.json +14 -1
- package/blocks/copilot/components/copilot-panel.tsx +14 -5
- package/blocks/copilot/components/copilot-screen.tsx +148 -0
- package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
- package/blocks/crm/README.md +28 -0
- package/blocks/crm/block.json +58 -2
- package/blocks/crm/components/companies-screen.tsx +345 -0
- package/blocks/crm/components/company-detail.tsx +8 -6
- package/blocks/crm/components/company-table.tsx +11 -4
- package/blocks/crm/components/contact-detail.tsx +6 -3
- package/blocks/crm/components/contact-table.tsx +10 -3
- package/blocks/crm/components/contacts-screen.tsx +359 -0
- package/blocks/crm/components/dedupe-review.tsx +7 -5
- package/blocks/crm/components/import-wizard.tsx +4 -2
- package/blocks/crm/components/notes-panel.tsx +4 -4
- package/blocks/crm/components/record-form.tsx +5 -3
- package/blocks/crm/components/task-list.tsx +4 -4
- package/blocks/crm/components/tasks-screen.tsx +379 -0
- package/blocks/crm/hooks/use-custom-fields.ts +6 -1
- package/blocks/crm/hooks/use-page-query.ts +7 -1
- package/blocks/crm/lib/crm-types.ts +12 -2
- package/blocks/crm/lib/screen-parts.tsx +105 -0
- package/blocks/deals/README.md +34 -3
- package/blocks/deals/block.json +45 -4
- package/blocks/deals/components/deal-detail.tsx +27 -13
- package/blocks/deals/components/deal-related.tsx +87 -0
- package/blocks/deals/components/deals-screen.tsx +298 -0
- package/blocks/deals/components/pipeline-board.tsx +4 -2
- package/blocks/deals/components/stage-editor.tsx +4 -4
- package/blocks/deals/hooks/use-default-owner.ts +60 -0
- package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/deals/lib/deals-types.ts +14 -0
- package/blocks/dialer/README.md +34 -2
- package/blocks/dialer/block.json +67 -1
- package/blocks/dialer/components/call-contact-button.tsx +252 -0
- package/blocks/dialer/components/call-log-screen.tsx +205 -0
- package/blocks/dialer/components/call-log.tsx +7 -3
- package/blocks/dialer/components/call-queue.tsx +1 -1
- package/blocks/dialer/components/dialer-screen.tsx +319 -0
- package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
- package/blocks/dialer/components/live-calls-table.tsx +7 -3
- package/blocks/dialer/components/session-start-form.tsx +4 -2
- package/blocks/dialer/components/softphone.tsx +3 -1
- package/blocks/enrichment/README.md +14 -2
- package/blocks/enrichment/block.json +32 -4
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
- package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
- package/blocks/enrichment/components/enrich-button.tsx +32 -9
- package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
- package/blocks/enrichment/components/waterfall-config.tsx +39 -10
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
- package/blocks/forms/README.md +12 -0
- package/blocks/forms/block.json +39 -17
- package/blocks/forms/components/form-builder.tsx +3 -1
- package/blocks/forms/components/form-embed.tsx +3 -1
- package/blocks/forms/components/forms-screen.tsx +353 -0
- package/blocks/forms/components/submissions-table.tsx +16 -6
- package/blocks/inbox/README.md +27 -3
- package/blocks/inbox/block.json +14 -2
- package/blocks/inbox/components/inbox-list.tsx +7 -3
- package/blocks/inbox/components/inbox-screen.tsx +294 -27
- package/blocks/inbox/components/reply-composer.tsx +65 -9
- package/blocks/inbox/components/tag-and-assign.tsx +4 -2
- package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
- package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
- package/blocks/inbox/lib/inbox-types.ts +47 -1
- package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
- package/blocks/integrations/README.md +9 -0
- package/blocks/integrations/block.json +46 -1
- package/blocks/integrations/components/crm-detail.tsx +100 -0
- package/blocks/integrations/components/integration-list.tsx +9 -4
- package/blocks/integrations/components/integrations-screen.tsx +135 -0
- package/blocks/integrations/components/sync-status.tsx +5 -3
- package/blocks/integrations/components/webhook-detail.tsx +160 -0
- package/blocks/integrations/components/webhook-manager.tsx +13 -8
- package/blocks/integrations/lib/integrations-utils.ts +6 -1
- package/blocks/intent/README.md +14 -0
- package/blocks/intent/block.json +27 -1
- package/blocks/intent/components/intent-feed.tsx +5 -2
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
- package/blocks/intent/components/intent-screen.tsx +265 -0
- package/blocks/intent/components/keyword-detail.tsx +124 -0
- package/blocks/lifecycle/README.md +16 -0
- package/blocks/lifecycle/block.json +34 -7
- package/blocks/lifecycle/components/account-related.tsx +66 -0
- package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +149 -0
- package/blocks/lifecycle/lib/lifecycle.ts +1 -1
- package/blocks/lists/README.md +13 -0
- package/blocks/lists/block.json +39 -1
- package/blocks/lists/components/add-to-list-button.tsx +3 -2
- package/blocks/lists/components/list-picker.tsx +5 -1
- package/blocks/lists/components/list-table.tsx +11 -5
- package/blocks/lists/components/lists-screen.tsx +344 -0
- package/blocks/lists/components/suppression-manager.tsx +6 -2
- package/blocks/lists/components/suppressions-screen.tsx +255 -0
- package/blocks/mailboxes/README.md +17 -0
- package/blocks/mailboxes/block.json +65 -17
- package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
- package/blocks/mailboxes/components/domain-detail.tsx +142 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
- package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
- package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
- package/blocks/mailboxes/components/warmup-health.tsx +4 -2
- package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
- package/blocks/newsletter/README.md +13 -0
- package/blocks/newsletter/block.json +47 -23
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
- package/blocks/newsletter/components/newsletter-screen.tsx +404 -0
- package/blocks/newsletter/components/subscriber-table.tsx +12 -6
- package/blocks/nurture/README.md +12 -0
- package/blocks/nurture/block.json +35 -13
- package/blocks/nurture/components/nurture-builder.tsx +3 -1
- package/blocks/nurture/components/nurture-enroll.tsx +6 -3
- package/blocks/nurture/components/nurture-form.tsx +23 -3
- package/blocks/nurture/components/nurture-screen.tsx +283 -0
- package/blocks/nurture/lib/nurture-types.ts +1 -1
- package/blocks/objects/README.md +17 -0
- package/blocks/objects/block.json +29 -16
- package/blocks/objects/components/object-not-found.tsx +3 -1
- package/blocks/objects/components/object-record-detail.tsx +2 -1
- package/blocks/objects/components/object-schema-editor.tsx +3 -1
- package/blocks/objects/components/object-table.tsx +13 -3
- package/blocks/objects/components/objects-screen.tsx +147 -0
- package/blocks/objects/lib/object-types.ts +1 -1
- package/blocks/pages/README.md +12 -0
- package/blocks/pages/block.json +35 -22
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +46 -11
- package/blocks/pages/components/page-list.tsx +6 -3
- package/blocks/pages/components/pages-screen.tsx +154 -0
- package/blocks/quotes/README.md +17 -0
- package/blocks/quotes/block.json +38 -9
- package/blocks/quotes/components/product-picker.tsx +3 -1
- package/blocks/quotes/components/quote-list.tsx +7 -3
- package/blocks/quotes/components/quote-related.tsx +123 -0
- package/blocks/quotes/components/quotes-screen.tsx +169 -0
- package/blocks/radar/README.md +10 -0
- package/blocks/radar/block.json +41 -1
- package/blocks/radar/components/competitor-detail.tsx +117 -0
- package/blocks/radar/components/competitor-tracker.tsx +3 -1
- package/blocks/radar/components/radar-report.tsx +4 -2
- package/blocks/radar/components/radar-screen.tsx +214 -0
- package/blocks/radar/components/report-detail.tsx +104 -0
- package/blocks/search/README.md +10 -0
- package/blocks/search/block.json +16 -1
- package/blocks/search/components/filter-builder.tsx +3 -3
- package/blocks/search/components/prospect-results.tsx +5 -3
- package/blocks/search/components/prospect-search-screen.tsx +235 -0
- package/blocks/search/components/saved-searches.tsx +4 -1
- package/blocks/search/hooks/use-filter-options.ts +20 -6
- package/blocks/search/lib/search-types.ts +2 -0
- package/blocks/sequencer/README.md +49 -9
- package/blocks/sequencer/block.json +99 -19
- package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
- package/blocks/sequencer/components/sequence-builder.tsx +211 -46
- package/blocks/sequencer/components/sequence-detail.tsx +259 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
- package/blocks/sequencer/components/sequence-list.tsx +33 -8
- package/blocks/sequencer/components/sequence-stats.tsx +3 -2
- package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
- package/blocks/sequencer/components/sequences-screen.tsx +159 -0
- package/blocks/sequencer/components/step-editor.tsx +3 -1
- package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
- package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
- package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
- package/blocks/social-ads/README.md +11 -0
- package/blocks/social-ads/block.json +101 -2
- package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
- package/blocks/social-ads/components/ads-screen.tsx +152 -0
- package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
- package/blocks/social-ads/components/audience-sync.tsx +11 -7
- package/blocks/social-ads/components/campaign-detail.tsx +133 -0
- package/blocks/social-ads/components/creative-detail.tsx +150 -0
- package/blocks/social-ads/components/creative-gallery.tsx +15 -5
- package/blocks/social-ads/components/post-composer.tsx +3 -1
- package/blocks/social-ads/components/post-detail.tsx +160 -0
- package/blocks/social-ads/components/social-post-list.tsx +11 -3
- package/blocks/social-ads/components/social-screen.tsx +160 -0
- package/blocks/visitors/README.md +10 -0
- package/blocks/visitors/block.json +25 -1
- package/blocks/visitors/components/snippet-install.tsx +3 -1
- package/blocks/visitors/components/visitor-company-card.tsx +2 -2
- package/blocks/visitors/components/visitor-detail.tsx +105 -0
- package/blocks/visitors/components/visitor-feed.tsx +11 -2
- package/blocks/visitors/components/visitors-screen.tsx +166 -0
- package/blocks/webchat/README.md +10 -0
- package/blocks/webchat/block.json +21 -7
- package/blocks/webchat/components/chat-widget-config.tsx +7 -4
- package/blocks/webchat/components/conversation-list.tsx +7 -3
- package/blocks/webchat/components/webchat-screen.tsx +245 -0
- package/blocks/workbench/README.md +11 -0
- package/blocks/workbench/block.json +48 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
- package/blocks/workbench/components/workbench-grid.tsx +9 -3
- package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
- package/blocks/workbench/components/workbench-screen.tsx +192 -0
- package/blocks/workbench/components/workbench-sources.tsx +194 -0
- package/blocks/workflows/README.md +13 -0
- package/blocks/workflows/block.json +28 -9
- package/blocks/workflows/components/run-history.tsx +31 -9
- package/blocks/workflows/components/trigger-config.tsx +3 -1
- package/blocks/workflows/components/workflow-editor.tsx +3 -1
- package/blocks/workflows/components/workflow-list.tsx +13 -6
- package/blocks/workflows/components/workflows-screen.tsx +193 -0
- package/dist/cli.js +2785 -1637
- package/dist/errors.d.mts +34 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +46 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.mjs +21 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.browser.js +11 -5
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +11 -5
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +164 -25
- package/dist/index.d.ts +164 -25
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +10 -4
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +10 -4
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +142 -5
- package/dist/react.d.ts +142 -5
- package/dist/react.js +10 -4
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +10 -4
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/add.d.ts +11 -4
- package/dist/types/blocks/brand.d.ts +113 -0
- package/dist/types/blocks/compose.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +25 -8
- package/dist/types/blocks/doctor.d.ts +6 -1
- package/dist/types/blocks/flows.d.ts +20 -0
- package/dist/types/blocks/navfile.d.ts +35 -0
- package/dist/types/blocks/paths.d.ts +4 -0
- package/dist/types/blocks/prefixes.d.ts +37 -0
- package/dist/types/blocks/registry.d.ts +32 -0
- package/dist/types/blocks/screens.d.ts +53 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/errors.d.ts +33 -0
- package/dist/types/http.d.ts +2 -18
- package/dist/types/version.d.ts +2 -2
- package/docs/agents.md +15 -4
- package/docs/api/README.md +2 -2
- package/docs/api/by-path.md +1 -0
- package/docs/api/desk.d.ts +98 -2
- package/docs/api/desk.md +123 -1
- package/docs/api/inbox.d.ts +10 -2
- package/docs/api/inbox.md +10 -2
- package/docs/api/seo.d.ts +19 -1
- package/docs/api/seo.md +24 -1
- package/package.json +16 -2
- package/stacks/6sense.json +41 -16
- package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
- package/stacks/apollo.json +118 -36
- package/stacks/calendly.json +60 -0
- package/stacks/clay/pages/companies.tsx.tmpl +15 -0
- package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
- package/stacks/clay.json +59 -26
- package/stacks/gong.json +81 -35
- package/stacks/hootsuite.json +99 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
- package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
- package/stacks/hubspot.json +167 -66
- package/stacks/intercom.json +48 -20
- package/stacks/mailchimp.json +52 -33
- package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
- package/stacks/outreach.json +88 -25
- package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
- package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
- package/stacks/pipedrive.json +115 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
- package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
- package/stacks/salesforce.json +81 -31
- package/stacks/stack.schema.json +6 -1
- package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +102 -0
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +55 -8
- package/templates/next-app/app/globals.css.tmpl +105 -0
- package/templates/next-app/app/layout.tsx.tmpl +18 -4
- package/templates/next-app/components/app-shell.tsx.tmpl +248 -23
- package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
- package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
- package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/lib/nav.ts.tmpl +1 -1
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -0,0 +1,359 @@
|
|
|
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 { g8Delete, g8Get } from "@/g8/lib/g8-client";
|
|
44
|
+
import { useDebouncedValue } from "../hooks/use-debounced";
|
|
45
|
+
import { usePageQuery } from "../hooks/use-page-query";
|
|
46
|
+
import { contactName, errorStatus, isMasked, location } from "../lib/crm-types";
|
|
47
|
+
import type { ContactRecord, ScreenActionContext } from "../lib/crm-types";
|
|
48
|
+
import { Pager, SortHead, formatDay, sortRows, useBulkDelete, useRowSelection, useSort } from "../lib/screen-parts";
|
|
49
|
+
import { ActivityTimeline } from "./activity-timeline";
|
|
50
|
+
import { DedupeReview } from "./dedupe-review";
|
|
51
|
+
import { ImportWizard } from "./import-wizard";
|
|
52
|
+
import { NotesPanel } from "./notes-panel";
|
|
53
|
+
import { RecordForm } from "./record-form";
|
|
54
|
+
import { TaskList } from "./task-list";
|
|
55
|
+
|
|
56
|
+
export interface ContactsScreenProps {
|
|
57
|
+
/** Only members of this list. */
|
|
58
|
+
listId?: number;
|
|
59
|
+
pageSize?: number;
|
|
60
|
+
/** Buttons at the end of each row (enrich, add to list...). */
|
|
61
|
+
renderRowActions?: (row: ContactRecord, ctx: ScreenActionContext) => ReactNode;
|
|
62
|
+
/** Buttons in the open contact's header. */
|
|
63
|
+
renderDetailActions?: (contact: ContactRecord, ctx: ScreenActionContext) => ReactNode;
|
|
64
|
+
/** Extra tabs on the open contact. */
|
|
65
|
+
extraDetailTabs?: Array<{ id: string; label: string; render: (contact: ContactRecord) => ReactNode }>;
|
|
66
|
+
/** Extra buttons in the page header, before "New contact". */
|
|
67
|
+
toolbarActions?: ReactNode;
|
|
68
|
+
/** Extra bulk actions next to Delete (add to list, enrich, enroll...). */
|
|
69
|
+
renderBulkActions?: (selection: { ids: number[]; rows: ContactRecord[]; clear: () => void }) => ReactNode;
|
|
70
|
+
/** Called with a company id from the contact panel ("Open company"). Without it the button is hidden. */
|
|
71
|
+
onOpenCompany?: (companyId: number) => void;
|
|
72
|
+
className?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Filters { name: string; job_title: string; seniority_level: string; country: string }
|
|
76
|
+
const NO_FILTERS: Filters = { name: "", job_title: "", seniority_level: "", country: "" };
|
|
77
|
+
const SENIORITY = ["C-Suite", "VP", "Director", "Manager"];
|
|
78
|
+
|
|
79
|
+
function sortValue(row: ContactRecord, key: string): string | null | undefined {
|
|
80
|
+
if (key === "name") return contactName(row);
|
|
81
|
+
if (key === "title") return row.job_title;
|
|
82
|
+
if (key === "company") return row.company_name;
|
|
83
|
+
if (key === "location") return location(row);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function Locked() {
|
|
88
|
+
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>;
|
|
89
|
+
}
|
|
90
|
+
function Cell({ value }: { value?: string | null }) {
|
|
91
|
+
return isMasked(value) ? <Locked /> : <>{value || <span className="text-muted-foreground">-</span>}</>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ContactsScreen(props: ContactsScreenProps) {
|
|
95
|
+
const { listId, pageSize = 25 } = props;
|
|
96
|
+
const [idParam, setIdParamRaw] = useUrlState("id");
|
|
97
|
+
const [view, setView] = useUrlState("view");
|
|
98
|
+
const full = view === "full" && idParam != null && idParam !== "";
|
|
99
|
+
/** Changing or clearing the open record also leaves the full-page view. */
|
|
100
|
+
const setIdParam = (v: string | null): void => { if (v == null) setView(null); setIdParamRaw(v); };
|
|
101
|
+
const parsedId = idParam ? Number(idParam) : null;
|
|
102
|
+
/** A malformed deep link (`?id=abc`) opens nothing instead of fetching /contacts/NaN. */
|
|
103
|
+
const openId = parsedId != null && Number.isFinite(parsedId) ? parsedId : null;
|
|
104
|
+
const [draft, setDraft] = useState<Filters>(NO_FILTERS);
|
|
105
|
+
const filters = useDebouncedValue(draft);
|
|
106
|
+
const [sheet, setSheet] = useState<"new" | "import" | "dupes" | null>(null);
|
|
107
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
108
|
+
const [note, setNote] = useState<string | null>(null);
|
|
109
|
+
const { sort, toggle: toggleSort } = useSort();
|
|
110
|
+
|
|
111
|
+
const list = usePageQuery<ContactRecord>(
|
|
112
|
+
(page, signal) => g8Get<ContactRecord[]>("/contacts", { page, limit: pageSize, list_id: listId, ...Object.fromEntries(Object.entries(filters).filter(([, v]) => v)) }, { signal }),
|
|
113
|
+
[listId, filters],
|
|
114
|
+
pageSize,
|
|
115
|
+
["contacts", "list", "screen"],
|
|
116
|
+
);
|
|
117
|
+
const dupes = useG8Query((signal) => g8Get<{ pending?: number; count?: number }>("/duplicates/counts", { entity_type: "contact" }, { signal }), [], { key: ["duplicates", "counts", "contact"] });
|
|
118
|
+
const selection = useRowSelection<ContactRecord>(`${listId}|${JSON.stringify(filters)}`);
|
|
119
|
+
const rows = useMemo(() => sortRows(list.rows, sort, sortValue), [list.rows, sort]);
|
|
120
|
+
const hasFilters = Object.values(draft).some(Boolean);
|
|
121
|
+
const pending = dupes.data?.data.pending ?? dupes.data?.data.count;
|
|
122
|
+
const lockedOnPage = list.rows.filter((r) => isMasked(r.work_email)).length;
|
|
123
|
+
|
|
124
|
+
const bulk = useBulkDelete("/contacts", (gone, total) => {
|
|
125
|
+
selection.drop(gone);
|
|
126
|
+
setConfirmDelete(false);
|
|
127
|
+
setNote(gone.length < total ? `Deleted ${gone.length} of ${total}. ${total - gone.length} could not be deleted; they are still selected.` : null);
|
|
128
|
+
if (openId != null && gone.includes(openId)) setIdParam(null);
|
|
129
|
+
void list.refetch();
|
|
130
|
+
void dupes.refetch();
|
|
131
|
+
});
|
|
132
|
+
const allOnPage = rows.length > 0 && rows.every((r) => selection.selected.has(r.id));
|
|
133
|
+
const reload = () => { void list.refetch(); void dupes.refetch(); };
|
|
134
|
+
const set = (key: keyof Filters, v: string) => setDraft((f) => ({ ...f, [key]: v }));
|
|
135
|
+
|
|
136
|
+
useEffect(() => { setNote(null); }, [filters, listId]);
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<ScreenLayout
|
|
140
|
+
className={props.className}
|
|
141
|
+
title="Contacts"
|
|
142
|
+
description="Everyone you sell to, with their history, notes and follow-ups."
|
|
143
|
+
icon={<Users className="size-5" aria-hidden />}
|
|
144
|
+
actions={
|
|
145
|
+
<>
|
|
146
|
+
{props.toolbarActions}
|
|
147
|
+
<Button variant="outline" size="sm" onClick={() => setSheet("dupes")}><GitMerge className="size-4" aria-hidden />Duplicates{pending ? ` (${pending})` : ""}</Button>
|
|
148
|
+
<Button variant="outline" size="sm" onClick={() => setSheet("import")}><Upload className="size-4" aria-hidden />Import</Button>
|
|
149
|
+
<Button size="sm" onClick={() => setSheet("new")}><Plus className="size-4" aria-hidden />New contact</Button>
|
|
150
|
+
</>
|
|
151
|
+
}
|
|
152
|
+
stats={[
|
|
153
|
+
{ label: "Contacts", value: list.loading && list.total === 0 ? "-" : list.total.toLocaleString(), hint: hasFilters ? "Matching the filters" : listId ? "In this list" : "In your workspace" },
|
|
154
|
+
{ label: "Possible duplicates", value: pending ?? "-", tone: pending ? "warning" : "neutral", hint: "Review and merge" },
|
|
155
|
+
{ label: "Locked on this page", value: lockedOnPage, hint: "Save to a list or enrich to reveal" },
|
|
156
|
+
]}
|
|
157
|
+
>
|
|
158
|
+
<SplitView
|
|
159
|
+
detailOpen={openId != null}
|
|
160
|
+
expanded={full}
|
|
161
|
+
onExpandedChange={(e) => setView(e ? "full" : null)}
|
|
162
|
+
onCloseDetail={() => setIdParam(null)}
|
|
163
|
+
detailWidth="lg"
|
|
164
|
+
list={
|
|
165
|
+
<div className="flex flex-col gap-3">
|
|
166
|
+
<Toolbar
|
|
167
|
+
search={{ value: draft.name, onChange: (v) => set("name", v), placeholder: "Search contacts by name" }}
|
|
168
|
+
filters={
|
|
169
|
+
<>
|
|
170
|
+
<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)} />
|
|
171
|
+
<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)}>
|
|
172
|
+
<option value="">Any seniority</option>
|
|
173
|
+
{SENIORITY.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
174
|
+
</select>
|
|
175
|
+
<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
|
+
{hasFilters ? <Button variant="ghost" size="sm" onClick={() => setDraft(NO_FILTERS)}>Clear filters</Button> : null}
|
|
177
|
+
</>
|
|
178
|
+
}
|
|
179
|
+
/>
|
|
180
|
+
|
|
181
|
+
{selection.ids.length > 0 ? (
|
|
182
|
+
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm" role="status">
|
|
183
|
+
<span className="tabular-nums">{selection.ids.length} selected</span>
|
|
184
|
+
{props.renderBulkActions?.({ ids: selection.ids, rows: selection.rows, clear: selection.clear })}
|
|
185
|
+
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}><Trash2 className="size-4" aria-hidden />Delete {selection.ids.length}</Button>
|
|
186
|
+
<Button variant="ghost" size="sm" onClick={selection.clear}>Clear selection</Button>
|
|
187
|
+
</div>
|
|
188
|
+
) : null}
|
|
189
|
+
{list.error ? <ApiError error={list.error} title="Could not load contacts" onRetry={() => void list.refetch()} /> : null}
|
|
190
|
+
{note ? <p role="alert" className="text-sm text-destructive">{note}</p> : null}
|
|
191
|
+
|
|
192
|
+
<div className="overflow-hidden rounded-md border">
|
|
193
|
+
<Table>
|
|
194
|
+
<TableHeader>
|
|
195
|
+
<TableRow>
|
|
196
|
+
<TableHead className="w-8"><input type="checkbox" aria-label="Select page" checked={allOnPage} onChange={() => selection.togglePage(rows)} /></TableHead>
|
|
197
|
+
<SortHead label="Name" sortKey="name" sort={sort} onSort={toggleSort} />
|
|
198
|
+
<SortHead label="Title" sortKey="title" sort={sort} onSort={toggleSort} />
|
|
199
|
+
<SortHead label="Company" sortKey="company" sort={sort} onSort={toggleSort} />
|
|
200
|
+
<TableHead>Email</TableHead>
|
|
201
|
+
<SortHead label="Location" sortKey="location" sort={sort} onSort={toggleSort} />
|
|
202
|
+
{props.renderRowActions ? <TableHead className="w-8"><span className="sr-only">Actions</span></TableHead> : null}
|
|
203
|
+
</TableRow>
|
|
204
|
+
</TableHeader>
|
|
205
|
+
<TableBody>
|
|
206
|
+
{list.loading && rows.length === 0
|
|
207
|
+
? Array.from({ length: 6 }, (_, i) => <TableRow key={`sk${i}`}><TableCell colSpan={6 + (props.renderRowActions ? 1 : 0)}><Skeleton className="h-6 w-full" /></TableCell></TableRow>)
|
|
208
|
+
: rows.map((row) => {
|
|
209
|
+
const name = contactName(row);
|
|
210
|
+
return (
|
|
211
|
+
<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)))}>
|
|
212
|
+
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
213
|
+
<input type="checkbox" aria-label={`Select ${name}`} checked={selection.selected.has(row.id)} onChange={() => selection.toggle(row)} />
|
|
214
|
+
</TableCell>
|
|
215
|
+
<TableCell>
|
|
216
|
+
<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>
|
|
217
|
+
</TableCell>
|
|
218
|
+
<TableCell><Cell value={row.job_title} /></TableCell>
|
|
219
|
+
<TableCell><Cell value={row.company_name} /></TableCell>
|
|
220
|
+
<TableCell><Cell value={row.work_email} /></TableCell>
|
|
221
|
+
<TableCell><Cell value={location(row)} /></TableCell>
|
|
222
|
+
{props.renderRowActions ? <TableCell onClick={(e) => e.stopPropagation()}>{props.renderRowActions(row, { reload })}</TableCell> : null}
|
|
223
|
+
</TableRow>
|
|
224
|
+
);
|
|
225
|
+
})}
|
|
226
|
+
</TableBody>
|
|
227
|
+
</Table>
|
|
228
|
+
{!list.loading && !list.error && rows.length === 0 ? (
|
|
229
|
+
<EmptyState
|
|
230
|
+
title={hasFilters ? "No contacts match these filters" : "No contacts yet"}
|
|
231
|
+
description={hasFilters ? "Try fewer filters." : "Add your first contact or import a file."}
|
|
232
|
+
action={hasFilters ? <Button variant="outline" onClick={() => setDraft(NO_FILTERS)}>Clear filters</Button> : <Button onClick={() => setSheet("new")}>New contact</Button>}
|
|
233
|
+
/>
|
|
234
|
+
) : null}
|
|
235
|
+
<Pager page={list.page} pageCount={list.pageCount} total={list.total} noun="contacts" hasNext={list.hasNext} loading={list.loading} onPage={list.setPage} />
|
|
236
|
+
</div>
|
|
237
|
+
{sort ? <p className="text-xs text-muted-foreground">Sorted within this page only.</p> : null}
|
|
238
|
+
</div>
|
|
239
|
+
}
|
|
240
|
+
detail={openId != null ? (
|
|
241
|
+
<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(); }}
|
|
242
|
+
onOpenCompany={props.onOpenCompany} renderActions={props.renderDetailActions} extraTabs={props.extraDetailTabs} />
|
|
243
|
+
) : null}
|
|
244
|
+
/>
|
|
245
|
+
|
|
246
|
+
<FormSheet open={sheet === "new"} onOpenChange={(o) => setSheet(o ? "new" : null)} title="New contact" description="A name, an email or a LinkedIn URL is enough.">
|
|
247
|
+
<RecordForm entity="contact" listId={listId} onCancel={() => setSheet(null)}
|
|
248
|
+
onSaved={({ id }) => { setSheet(null); reload(); if (id != null) setIdParam(String(id)); }}
|
|
249
|
+
onDuplicate={(existing) => { setSheet(null); if (existing != null) setIdParam(String(existing)); }} />
|
|
250
|
+
</FormSheet>
|
|
251
|
+
<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.">
|
|
252
|
+
<ImportWizard listId={listId} onImported={reload} />
|
|
253
|
+
</FormSheet>
|
|
254
|
+
<FormSheet open={sheet === "dupes"} onOpenChange={(o) => setSheet(o ? "dupes" : null)} title="Duplicate contacts" description="Merge pairs, or mark them as not duplicates.">
|
|
255
|
+
<DedupeReview entity="contact" onChanged={reload} />
|
|
256
|
+
</FormSheet>
|
|
257
|
+
|
|
258
|
+
<ConfirmSpendDialog
|
|
259
|
+
open={confirmDelete}
|
|
260
|
+
tier="destructive"
|
|
261
|
+
title={`Delete ${plural(selection.ids.length, "contact")}`}
|
|
262
|
+
description="They are removed from every list and sequence."
|
|
263
|
+
affected={selection.ids.length}
|
|
264
|
+
confirming={bulk.loading}
|
|
265
|
+
onConfirm={() => void bulk.mutate(selection.ids)}
|
|
266
|
+
onCancel={() => setConfirmDelete(false)}
|
|
267
|
+
/>
|
|
268
|
+
</ScreenLayout>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface ContactPanelProps {
|
|
273
|
+
full: boolean;
|
|
274
|
+
setFull: (full: boolean) => void;
|
|
275
|
+
contactId: number;
|
|
276
|
+
/** The list row, for fields the detail answer lacks (owner). */
|
|
277
|
+
seed?: ContactRecord;
|
|
278
|
+
onClose: () => void;
|
|
279
|
+
onChanged: () => void;
|
|
280
|
+
onDeleted: () => void;
|
|
281
|
+
onOpenCompany?: (companyId: number) => void;
|
|
282
|
+
renderActions?: ContactsScreenProps["renderDetailActions"];
|
|
283
|
+
extraTabs?: ContactsScreenProps["extraDetailTabs"];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function ContactPanel({ full, setFull, contactId, seed, onClose, onChanged, onDeleted, onOpenCompany, renderActions, extraTabs = [] }: ContactPanelProps) {
|
|
287
|
+
const [editing, setEditing] = useState(false);
|
|
288
|
+
const [confirm, setConfirm] = useState(false);
|
|
289
|
+
const [feedKey, setFeedKey] = useState(0);
|
|
290
|
+
const query = useG8Query((signal) => g8Get<ContactRecord>(`/contacts/${contactId}`, { include_custom_fields: true }, { signal }), [contactId], {
|
|
291
|
+
key: ["contacts", "detail", contactId],
|
|
292
|
+
// The row the contact was opened from paints the panel at once; the full record replaces it.
|
|
293
|
+
placeholderData: seed ? { data: seed } : undefined,
|
|
294
|
+
});
|
|
295
|
+
/** Edit, delete and slot actions wait for the full record: a list row must never seed the edit form. */
|
|
296
|
+
const partial = query.isPlaceholderData === true;
|
|
297
|
+
const remove = useG8Mutation(async () => {
|
|
298
|
+
try { await g8Delete(`/contacts/${contactId}`); } catch (e) { if (errorStatus(e) !== 404) throw e; }
|
|
299
|
+
}, { invalidates: [["contacts", "list"]], onSuccess: () => { setConfirm(false); onDeleted(); } });
|
|
300
|
+
const bump = () => setFeedKey((k) => k + 1);
|
|
301
|
+
|
|
302
|
+
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>;
|
|
303
|
+
// A failed full record must not leave the list row on screen with every action disabled.
|
|
304
|
+
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>;
|
|
305
|
+
const contact = query.data!.data;
|
|
306
|
+
const name = contactName(contact);
|
|
307
|
+
const val = (v?: string | null): ReactNode => (isMasked(v) ? <Locked /> : v || <span className="text-muted-foreground">-</span>);
|
|
308
|
+
|
|
309
|
+
const overview = (
|
|
310
|
+
<KeyValueList columns={2} items={[
|
|
311
|
+
{ label: "Work email", value: val(contact.work_email) },
|
|
312
|
+
{ label: "Direct phone", value: val(contact.direct_phone) },
|
|
313
|
+
{ label: "Mobile phone", value: val(contact.mobile_phone) },
|
|
314
|
+
{ label: "LinkedIn", value: val(contact.linkedin_url) },
|
|
315
|
+
{ label: "Seniority", value: val(contact.seniority_level) },
|
|
316
|
+
{ label: "Department", value: val(contact.job_department) },
|
|
317
|
+
{ label: "Location", value: val(location(contact)) },
|
|
318
|
+
{ label: "Owner", value: val(contact.owner_name ?? seed?.owner_name) },
|
|
319
|
+
{ label: "Added", value: val(formatDay(contact.created_at)) },
|
|
320
|
+
...Object.entries(contact.custom_fields ?? {}).map(([k, v]) => ({ label: k, value: val(v) })),
|
|
321
|
+
...(contact.about ? [{ label: "About", value: contact.about }] : []),
|
|
322
|
+
]} />
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<>
|
|
327
|
+
<DetailPanel
|
|
328
|
+
title={name}
|
|
329
|
+
subtitle={[contact.job_title, contact.company?.name ?? contact.company_name].filter(Boolean).join(" at ") || "No title or company"}
|
|
330
|
+
avatar={<EntityAvatar name={name} kind="person" size="lg" />}
|
|
331
|
+
badges={isMasked(contact.work_email) ? <StatusPill tone="warning" dot>Locked email</StatusPill> : undefined}
|
|
332
|
+
onClose={onClose}
|
|
333
|
+
onExpand={full ? undefined : () => setFull(true)}
|
|
334
|
+
onCollapse={full ? () => setFull(false) : undefined}
|
|
335
|
+
actions={
|
|
336
|
+
<>
|
|
337
|
+
{partial ? null : renderActions?.(contact, { reload: () => { void query.refetch(); bump(); onChanged(); } })}
|
|
338
|
+
{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}
|
|
339
|
+
<Button variant="outline" size="sm" disabled={partial} title={partial ? "Loading the full record" : undefined} onClick={() => setEditing(true)}>Edit</Button>
|
|
340
|
+
<Button variant="destructive" size="sm" disabled={partial} onClick={() => setConfirm(true)}>Delete</Button>
|
|
341
|
+
</>
|
|
342
|
+
}
|
|
343
|
+
tabs={[
|
|
344
|
+
{ id: "overview", label: "Overview", content: overview },
|
|
345
|
+
{ id: "activity", label: "Activity", content: <ActivityTimeline entityType="contact" entityId={contactId} refreshKey={feedKey} /> },
|
|
346
|
+
{ id: "notes", label: "Notes", content: <NotesPanel entity="contact" entityId={contactId} onChanged={bump} /> },
|
|
347
|
+
{ id: "tasks", label: "Tasks", content: <TaskList entity="contact" entityId={contactId} onChanged={bump} /> },
|
|
348
|
+
...(partial ? [] : extraTabs).map((t) => ({ id: t.id, label: t.label, content: t.render(contact) })),
|
|
349
|
+
]}
|
|
350
|
+
/>
|
|
351
|
+
{remove.error ? <div className="p-4"><ApiError error={remove.error} title="Could not delete the contact" /></div> : null}
|
|
352
|
+
<FormSheet open={editing && !partial} onOpenChange={setEditing} title={`Edit ${name}`}>
|
|
353
|
+
<RecordForm entity="contact" record={contact} onCancel={() => setEditing(false)} onSaved={() => { setEditing(false); void query.refetch(); bump(); onChanged(); }} />
|
|
354
|
+
</FormSheet>
|
|
355
|
+
<ConfirmSpendDialog open={confirm} tier="destructive" title={`Delete ${name}`} description="The contact is removed from every list and sequence." affected={1}
|
|
356
|
+
confirming={remove.loading} onConfirm={() => void remove.mutate(undefined)} onCancel={() => setConfirm(false)} />
|
|
357
|
+
</>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
@@ -29,6 +29,7 @@ import { useJobPoll } from "@/g8/hooks/use-job-poll";
|
|
|
29
29
|
import { usePaginated } from "@/g8/hooks/use-paginated";
|
|
30
30
|
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
31
31
|
import { isMasked } from "../lib/crm-types";
|
|
32
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
32
33
|
|
|
33
34
|
export interface DedupeReviewProps {
|
|
34
35
|
entity: "contact" | "company";
|
|
@@ -49,10 +50,11 @@ const CRITERIA = [
|
|
|
49
50
|
function side(pair: Pair, s: "a" | "b", entity: "contact" | "company") {
|
|
50
51
|
const name = entity === "contact" ? [pair[`${s}_first_name`], pair[`${s}_last_name`]].filter(Boolean).join(" ") : String(pair[`${s}_name`] ?? "");
|
|
51
52
|
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 ||
|
|
53
|
+
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
54
|
}
|
|
54
55
|
|
|
55
56
|
export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps) {
|
|
57
|
+
const brand = useBrand();
|
|
56
58
|
const plural = entity === "contact" ? "contacts" : "companies";
|
|
57
59
|
const [merging, setMerging] = useState<{ pair: Pair; keep: "a" | "b" } | null>(null);
|
|
58
60
|
const [picked, setPicked] = useState<Set<string>>(new Set());
|
|
@@ -73,9 +75,9 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
|
|
|
73
75
|
const merge = useG8Mutation((v: { pair: Pair; keep: "a" | "b" }) => {
|
|
74
76
|
const keepId = v.pair[`${v.keep}_id`], dropId = v.pair[`${v.keep === "a" ? "b" : "a"}_id`];
|
|
75
77
|
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) });
|
|
78
|
+
}, { invalidates: [plural, "duplicates"], onSuccess: (_r, v) => { dropPick(v.pair.suggestion_id); setMerging(null); refreshAll(); } });
|
|
79
|
+
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) });
|
|
80
|
+
const undo = useG8Mutation((id: string) => { setBusy(id); return g8Post(`/duplicates/undo-merge/${id}`, {}); }, { invalidates: [plural, "duplicates"], onSuccess: () => { setBusy(null); refreshAll(); }, onError: () => setBusy(null) });
|
|
79
81
|
const bulk = useG8Mutation(() => g8Post<{ run_id: string }>("/duplicates/bulk-merge", { suggestion_ids: [...picked], criterion }), {
|
|
80
82
|
onSuccess: (r) => { setBulkOpen(false); setRunId(r.data.run_id); },
|
|
81
83
|
});
|
|
@@ -109,7 +111,7 @@ export function DedupeReview({ entity, onChanged, className }: DedupeReviewProps
|
|
|
109
111
|
{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
112
|
{mergeError ? <ApiError error={mergeError} title="That did not go through" /> : null}
|
|
111
113
|
{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=
|
|
114
|
+
{!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
115
|
|
|
114
116
|
{pairs.items.map((pair) => (
|
|
115
117
|
<div key={pair.suggestion_id} className="rounded-md border p-3" data-testid="duplicate-pair">
|
|
@@ -24,6 +24,7 @@ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
|
24
24
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
25
25
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
26
26
|
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
27
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
27
28
|
|
|
28
29
|
export interface ImportWizardProps {
|
|
29
30
|
/** Largest file accepted. Default 5 MiB: the /api/g8 proxy rejects bigger bodies (raise maxBodyBytes there first). */
|
|
@@ -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>>({});
|
|
@@ -167,7 +169,7 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
|
|
|
167
169
|
</div>
|
|
168
170
|
) : null}
|
|
169
171
|
<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
|
|
172
|
+
<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
173
|
</section>
|
|
172
174
|
|
|
173
175
|
{run.error ? <ApiError error={run.error} title="The import failed" /> : null}
|
|
@@ -181,7 +183,7 @@ export function ImportWizard({ listId, onImported, maxBytes = 5 * 1024 * 1024, c
|
|
|
181
183
|
open={confirmOpen}
|
|
182
184
|
tier={enrich ? "billable" : "destructive"}
|
|
183
185
|
title={`Import ${rows.toLocaleString()} rows`}
|
|
184
|
-
description={policy === "override" ? "Matching contacts get their existing values replaced by the file." :
|
|
186
|
+
description={policy === "override" ? "Matching contacts get their existing values replaced by the file." : `Missing fields are filled from ${brand.linkLabel}.`}
|
|
185
187
|
affected={enrich ? undefined : rows}
|
|
186
188
|
confirming={run.loading}
|
|
187
189
|
confirmLabel="Import"
|
|
@@ -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 (
|
|
@@ -35,6 +35,8 @@ export interface RecordFormProps {
|
|
|
35
35
|
/** Create only: a contact with the same identity exists; receives its id. */
|
|
36
36
|
onDuplicate?: (existingId: number | undefined) => void;
|
|
37
37
|
onCancel?: () => void;
|
|
38
|
+
/** Create only: prefill base fields, for example `{ company_domain: "acme.com" }`. */
|
|
39
|
+
defaults?: Record<string, string>;
|
|
38
40
|
/** Show the inline "Add custom field" control. Default true. */
|
|
39
41
|
allowNewFields?: boolean;
|
|
40
42
|
className?: string;
|
|
@@ -94,13 +96,13 @@ export function RecordForm(props: RecordFormProps) {
|
|
|
94
96
|
const [newType, setNewType] = useState("text");
|
|
95
97
|
const [validation, setValidation] = useState<string | null>(null);
|
|
96
98
|
|
|
97
|
-
const initial = initialValues(base, record, custom.fields);
|
|
99
|
+
const initial = { ...initialValues(base, record, custom.fields), ...(record ? {} : props.defaults ?? {}) };
|
|
98
100
|
const value = (key: string) => edits[key] ?? initial[key] ?? "";
|
|
99
101
|
const set = (key: string, v: string) => setEdits((e) => ({ ...e, [key]: v }));
|
|
100
102
|
|
|
101
103
|
const addField = useG8Mutation(
|
|
102
104
|
(title: string) => g8Post("/fields", { title, data_type: newType, entity: plural, list_id: listId ?? null }),
|
|
103
|
-
{ onSuccess: () => { setNewTitle(""); void custom.refetch(); } },
|
|
105
|
+
{ invalidates: ["fields"], onSuccess: () => { setNewTitle(""); void custom.refetch(); } },
|
|
104
106
|
);
|
|
105
107
|
|
|
106
108
|
const save = useG8Mutation(async () => {
|
|
@@ -126,7 +128,7 @@ export function RecordForm(props: RecordFormProps) {
|
|
|
126
128
|
await g8Patch("/fields/values/batch", { entity: plural, rows: [{ record_id: id, fields: toWrite.map((f) => ({ column_id: f.id, value: value(`cf:${f.name}`) || null })) }] });
|
|
127
129
|
}
|
|
128
130
|
return { id: id as number, merged };
|
|
129
|
-
}, { onSuccess: (r) => props.onSaved?.(r) });
|
|
131
|
+
}, { invalidates: [plural], onSuccess: (r) => props.onSaved?.(r) });
|
|
130
132
|
|
|
131
133
|
const submit = (event: FormEvent) => {
|
|
132
134
|
event.preventDefault();
|
|
@@ -51,14 +51,14 @@ export function TaskList({ entity, entityId, includeCompleted = false, onChanged
|
|
|
51
51
|
const [showDone, setShowDone] = useState(includeCompleted);
|
|
52
52
|
const [deleting, setDeleting] = useState<TaskItem | null>(null);
|
|
53
53
|
|
|
54
|
-
const tasks = useG8Query((signal) => g8Get<TaskItem[]>(base, { status: showDone ? undefined : "open" }, { signal }), [base, showDone]);
|
|
54
|
+
const tasks = useG8Query((signal) => g8Get<TaskItem[]>(base, { status: showDone ? undefined : "open" }, { signal }), [base, showDone], { key: ["tasks", "record", entity, entityId, { showDone }] });
|
|
55
55
|
const done = () => { void tasks.refetch(); onChanged?.(); };
|
|
56
56
|
const add = useG8Mutation(
|
|
57
57
|
() => g8Post(base, { title: title.trim(), priority, ...(due ? { due_date: due } : {}) }),
|
|
58
|
-
{ onSuccess: () => { setTitle(""); setDue(""); done(); } },
|
|
58
|
+
{ invalidates: [["tasks"]], onSuccess: () => { setTitle(""); setDue(""); done(); } },
|
|
59
59
|
);
|
|
60
|
-
const toggle = useG8Mutation((t: TaskItem) => g8Patch(`/tasks/${t.id}`, { status: t.status === "completed" ? "open" : "completed" }), { onSuccess: done });
|
|
61
|
-
const remove = useG8Mutation((id: string) => g8Delete(`/tasks/${id}`), { onSuccess: () => { setDeleting(null); done(); } });
|
|
60
|
+
const toggle = useG8Mutation((t: TaskItem) => g8Patch(`/tasks/${t.id}`, { status: t.status === "completed" ? "open" : "completed" }), { invalidates: [["tasks"]], onSuccess: done });
|
|
61
|
+
const remove = useG8Mutation((id: string) => g8Delete(`/tasks/${id}`), { invalidates: [["tasks"]], onSuccess: () => { setDeleting(null); done(); } });
|
|
62
62
|
|
|
63
63
|
const items = (tasks.data?.data ?? []) as TaskItem[];
|
|
64
64
|
return (
|