@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,344 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ListsScreen: the finished Lists page. A searchable, paged table of every list with member counts and build status,
|
|
5
|
+
* a detail panel for the selected list (deep link `?id=`), and create / rename / delete.
|
|
6
|
+
*
|
|
7
|
+
* Composes: ScreenLayout, StatStrip, Toolbar, SplitView, DetailPanel, FormSheet (screen kit), ContactTable (crm),
|
|
8
|
+
* AddToListButton (this block), ConfirmSpendDialog.
|
|
9
|
+
*
|
|
10
|
+
* User flows:
|
|
11
|
+
* - find a list by name, page through lists, see type, members, status ("import failed" etc.) and last update
|
|
12
|
+
* - New list (name, description, contacts or companies) in a side sheet; Edit renames; Delete is confirmed
|
|
13
|
+
* - open a list: Members tab (contacts of the list, selectable), Details tab (counts, status, timestamps)
|
|
14
|
+
* - Add contacts: pick contacts from the CRM in a sheet and add them to the open list (conflicts shown, never auto-picked)
|
|
15
|
+
* - Remove selected members from the list (confirmed; the contacts stay in the CRM)
|
|
16
|
+
* - Refresh a dynamic list (re-runs its filters) and Check health (read-only integrity check)
|
|
17
|
+
*
|
|
18
|
+
* SDK operations, in order:
|
|
19
|
+
* 1. listLists GET /lists?page&limit&search (read)
|
|
20
|
+
* 2. createList POST /lists (write)
|
|
21
|
+
* 3. updateList PATCH /lists/{list_id} (write)
|
|
22
|
+
* 4. deleteList DELETE /lists/{list_id} (destructive, ConfirmSpendDialog)
|
|
23
|
+
* 5. listContacts GET /contacts?list_id via ContactTable (read)
|
|
24
|
+
* 6. addContactsToList POST /lists/{list_id}/contacts (write, via AddToListButton)
|
|
25
|
+
* 7. removeContactsFromList DELETE /lists/{list_id}/contacts (destructive, ConfirmSpendDialog)
|
|
26
|
+
* 8. refreshDynamicList POST /lists/{audience_id}/refresh (write)
|
|
27
|
+
* 9. checkListIntegrity POST /lists/{list_id}/integrity-check (read)
|
|
28
|
+
* 10. downloadList GET /lists/{audience_id}/download?format (read) -> { content, encoding, filename, media_type, format }
|
|
29
|
+
* saved to the person's computer as CSV or XLSX (a list can hold personal data; the proxy's authorize decides who may).
|
|
30
|
+
* Company lists have no member read operation in the API, so their members link out to the graph8 app (`appUrl`).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { useState } from "react";
|
|
34
|
+
import type { ReactNode } from "react";
|
|
35
|
+
import { Building2, Database, Download, ExternalLink, HeartPulse, Plus, RefreshCw, Trash2, UserPlus, Users } from "lucide-react";
|
|
36
|
+
import { Button, Input, Label, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Skeleton } from "@graph8/sdk/ui";
|
|
37
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
38
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
39
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
40
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
41
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
42
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
43
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
44
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
45
|
+
import { StatusPill, humanizeStatus, toneForStatus } from "@/g8/components/screen/status-pill";
|
|
46
|
+
import type { Tone } from "@/g8/components/screen/status-pill";
|
|
47
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
48
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
49
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
50
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
51
|
+
import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
52
|
+
import { ContactTable } from "@/g8/crm/components/contact-table";
|
|
53
|
+
import { useDebouncedValue } from "@/g8/crm/hooks/use-debounced";
|
|
54
|
+
import { usePageQuery } from "@/g8/crm/hooks/use-page-query";
|
|
55
|
+
import { AddToListButton } from "./add-to-list-button";
|
|
56
|
+
import type { ListSummary } from "./list-picker";
|
|
57
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
58
|
+
|
|
59
|
+
export interface ListsScreenProps {
|
|
60
|
+
pageSize?: number;
|
|
61
|
+
/** Extra buttons next to "New list" (for example a link to the suppressions page). */
|
|
62
|
+
toolbarActions?: ReactNode;
|
|
63
|
+
/** Extra buttons on each row's action cell. */
|
|
64
|
+
renderRowActions?: (list: ListSummary) => ReactNode;
|
|
65
|
+
/** Extra buttons in the open list's header. */
|
|
66
|
+
renderDetailActions?: (list: ListSummary) => ReactNode;
|
|
67
|
+
extraDetailTabs?: { id: string; label: string; render: (list: ListSummary) => ReactNode }[];
|
|
68
|
+
/** Where company-list members are managed. Default: the graph8 app. */
|
|
69
|
+
appUrl?: string;
|
|
70
|
+
className?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface DownloadPayload { content: string; encoding: "utf-8" | "base64"; filename: string; media_type: string; format: "csv" | "xlsx" }
|
|
74
|
+
|
|
75
|
+
/** Hand a downloaded payload to the browser as a file. */
|
|
76
|
+
export function saveDownload(file: DownloadPayload): void {
|
|
77
|
+
const bytes = file.encoding === "base64" ? Uint8Array.from(atob(file.content), (c) => c.charCodeAt(0)) : new TextEncoder().encode(file.content);
|
|
78
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: file.media_type }));
|
|
79
|
+
const a = document.createElement("a");
|
|
80
|
+
a.href = url;
|
|
81
|
+
a.download = file.filename;
|
|
82
|
+
a.click();
|
|
83
|
+
// Firefox and Safari cancel the download when the URL is revoked in the same tick as the click.
|
|
84
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Draft { id?: number; title: string; description: string; type: "contacts" | "companies" }
|
|
88
|
+
interface Integrity { [key: string]: unknown }
|
|
89
|
+
|
|
90
|
+
const count = (l: ListSummary): number => l.contact_count ?? l.company_count ?? l.total ?? 0;
|
|
91
|
+
|
|
92
|
+
function statusOf(l: ListSummary): { tone: Tone; label: string } {
|
|
93
|
+
if (l.status_reason) return { tone: "negative", label: humanizeStatus(l.status_reason) };
|
|
94
|
+
if (!l.status || l.status === "ready") return { tone: "positive", label: "Ready" };
|
|
95
|
+
return { tone: toneForStatus(l.status), label: l.status === "completed" ? "Ready" : humanizeStatus(l.status) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function when(value?: string | null): string {
|
|
99
|
+
return value ? new Date(value).toLocaleDateString() : "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function ListsScreen({ pageSize = 25, toolbarActions, renderRowActions, renderDetailActions, extraDetailTabs, appUrl: appUrlProp, className }: ListsScreenProps) {
|
|
103
|
+
const brand = useBrand();
|
|
104
|
+
const appUrl = appUrlProp ?? brand.appUrl;
|
|
105
|
+
const [search, setSearch] = useState("");
|
|
106
|
+
const term = useDebouncedValue(search).trim();
|
|
107
|
+
const [selectedId, setSelectedId] = useUrlState("id");
|
|
108
|
+
const [view, setView] = useUrlState("view");
|
|
109
|
+
const full = view === "full";
|
|
110
|
+
const [draft, setDraft] = useState<Draft | null>(null);
|
|
111
|
+
const [deleting, setDeleting] = useState<ListSummary | null>(null);
|
|
112
|
+
const [removing, setRemoving] = useState<number[] | null>(null);
|
|
113
|
+
const [adding, setAdding] = useState(false);
|
|
114
|
+
const [membersKey, setMembersKey] = useState(0);
|
|
115
|
+
const [health, setHealth] = useState<Integrity | null>(null);
|
|
116
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
117
|
+
const [exportFormat, setExportFormat] = useState<"csv" | "xlsx">("csv");
|
|
118
|
+
|
|
119
|
+
const lists = usePageQuery<ListSummary>(
|
|
120
|
+
(page, signal) => g8Get<ListSummary[]>("/lists", { page, limit: pageSize, search: term || undefined }, { signal }),
|
|
121
|
+
[term],
|
|
122
|
+
pageSize,
|
|
123
|
+
["lists", "list", "screen"],
|
|
124
|
+
);
|
|
125
|
+
// A deep link (`?id=`) can point at a list that is not on the current page. The API has no read-one operation, so
|
|
126
|
+
// scan the pages until it turns up instead of guessing its type and count.
|
|
127
|
+
const inPage = selectedId ? lists.rows.find((l) => String(l.id) === selectedId) : undefined;
|
|
128
|
+
const lookupNeeded = selectedId != null && !inPage && !lists.loading;
|
|
129
|
+
const lookup = useG8Query<ListSummary | null>(
|
|
130
|
+
async (signal) => {
|
|
131
|
+
for (let page = 1; page <= 50; page++) {
|
|
132
|
+
const res = await g8Get<ListSummary[]>("/lists", { page, limit: 200 }, { signal });
|
|
133
|
+
const hit = (res.data ?? []).find((l) => String(l.id) === selectedId);
|
|
134
|
+
if (hit) return hit;
|
|
135
|
+
if (!(res.pagination as { has_next?: boolean } | undefined)?.has_next) break;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
},
|
|
139
|
+
[selectedId, lookupNeeded],
|
|
140
|
+
{ enabled: lookupNeeded },
|
|
141
|
+
);
|
|
142
|
+
const found = lookup.data && String(lookup.data.id) === selectedId ? lookup.data : null;
|
|
143
|
+
const selected: ListSummary | null = inPage ?? (selectedId ? found : null);
|
|
144
|
+
const resolving = selectedId != null && !selected && (lists.loading || (lookupNeeded && lookup.loading) || (lookupNeeded && lookup.data === undefined && !lookup.error));
|
|
145
|
+
|
|
146
|
+
const save = useG8Mutation(
|
|
147
|
+
(v: Draft) => (v.id ? g8Patch(`/lists/${v.id}`, { title: v.title, description: v.description }) : g8Post("/lists", { title: v.title, type: v.type, description: v.description || undefined })),
|
|
148
|
+
{ invalidates: [["lists"]], onSuccess: (res, v) => { setDraft(null); setNotice(v.id ? null : ((res.data as { warning?: string | null } | undefined)?.warning ?? null)); void lists.refetch(); } },
|
|
149
|
+
);
|
|
150
|
+
const download = useG8Mutation(async (id: number) => saveDownload((await g8Get<DownloadPayload>(`/lists/${id}/download`, { format: exportFormat })).data));
|
|
151
|
+
const remove = useG8Mutation((id: number) => g8Delete(`/lists/${id}`), {
|
|
152
|
+
invalidates: [["lists"]],
|
|
153
|
+
onSuccess: (_r, id) => { setDeleting(null); if (String(id) === selectedId) setSelectedId(null); void lists.refetch(); },
|
|
154
|
+
});
|
|
155
|
+
const removeMembers = useG8Mutation((v: { listId: number; ids: number[] }) => g8Delete(`/lists/${v.listId}/contacts`, { body: { contact_ids: v.ids } }), {
|
|
156
|
+
invalidates: [["lists"], ["contacts", "list"]],
|
|
157
|
+
onSuccess: () => { setRemoving(null); setMembersKey((k) => k + 1); void lists.refetch(); },
|
|
158
|
+
});
|
|
159
|
+
const refresh = useG8Mutation((id: number) => g8Post(`/lists/${id}/refresh`, {}), { invalidates: [["lists"], ["contacts", "list"]], onSuccess: () => { setMembersKey((k) => k + 1); void lists.refetch(); } });
|
|
160
|
+
const check = useG8Mutation(async (id: number) => (await g8Post<Integrity>(`/lists/${id}/integrity-check`, {})).data, { onSuccess: (r) => setHealth(r) });
|
|
161
|
+
|
|
162
|
+
const select = (l: ListSummary) => { setHealth(null); setSelectedId(String(l.id)); };
|
|
163
|
+
const rows = lists.rows;
|
|
164
|
+
const stats = [
|
|
165
|
+
{ label: "Lists", value: lists.total.toLocaleString() },
|
|
166
|
+
{ label: "Members in view", value: rows.reduce((n, l) => n + count(l), 0).toLocaleString(), hint: "On this page" },
|
|
167
|
+
{ label: "Dynamic", value: rows.filter((l) => l.is_dynamic).length, hint: "On this page" },
|
|
168
|
+
{ label: "Need attention", value: rows.filter((l) => l.status === "failed" || l.status === "processing").length, hint: "On this page" },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const table = (
|
|
172
|
+
<div className="flex flex-col gap-3">
|
|
173
|
+
{lists.error ? <ApiError error={lists.error} title="Could not load lists" onRetry={() => void lists.refetch()} /> : null}
|
|
174
|
+
{remove.error ? <ApiError error={remove.error} title="Could not delete the list" /> : null}
|
|
175
|
+
{notice ? <p role="status" className="rounded-md border bg-muted/40 px-3 py-2 text-sm">{notice}</p> : null}
|
|
176
|
+
<div className="divide-y rounded-md border">
|
|
177
|
+
{lists.loading && rows.length === 0
|
|
178
|
+
? Array.from({ length: 5 }, (_, i) => <div key={`sk${i}`} className="p-3"><Skeleton className="h-8 w-full" /></div>)
|
|
179
|
+
: rows.map((l) => {
|
|
180
|
+
const status = statusOf(l);
|
|
181
|
+
const Icon = l.type === "companies" ? Building2 : Users;
|
|
182
|
+
return (
|
|
183
|
+
<div key={l.id} data-testid={`list-row-${l.id}`} className={`flex items-center gap-3 p-3 ${String(l.id) === selectedId ? "bg-muted/50" : ""}`}>
|
|
184
|
+
<button type="button" className="flex min-w-0 flex-1 items-center gap-3 text-left" onClick={() => select(l)}>
|
|
185
|
+
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
186
|
+
<span className="min-w-0 flex-1">
|
|
187
|
+
<span className="block truncate text-sm font-medium">{l.title}</span>
|
|
188
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
189
|
+
{l.type === "companies" ? "Companies" : "Contacts"}{l.is_dynamic ? ", dynamic" : ""}{l.updated_at ? `, updated ${when(l.updated_at)}` : ""}
|
|
190
|
+
</span>
|
|
191
|
+
</span>
|
|
192
|
+
<span className="text-sm tabular-nums">{count(l).toLocaleString()}</span>
|
|
193
|
+
<span title={l.status_detail ?? undefined}><StatusPill tone={status.tone} dot>{status.label}</StatusPill></span>
|
|
194
|
+
</button>
|
|
195
|
+
<div className="flex items-center gap-1">
|
|
196
|
+
{renderRowActions?.(l)}
|
|
197
|
+
<Button variant="ghost" size="sm" aria-label={`Edit ${l.title}`} onClick={() => setDraft({ id: l.id, title: l.title, description: l.description ?? "", type: l.type === "companies" ? "companies" : "contacts" })}>Edit</Button>
|
|
198
|
+
<Button variant="ghost" size="sm" aria-label={`Delete ${l.title}`} onClick={() => setDeleting(l)}><Trash2 className="size-4" aria-hidden /></Button>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
})}
|
|
203
|
+
{!lists.loading && !lists.error && rows.length === 0 ? (
|
|
204
|
+
<EmptyState
|
|
205
|
+
icon={<Database className="size-6" aria-hidden />}
|
|
206
|
+
title={term ? "No list matches" : "No lists yet"}
|
|
207
|
+
description={term ? "Try another name." : "A list groups contacts or companies for a campaign, sequence or export."}
|
|
208
|
+
action={!term ? <Button size="sm" onClick={() => setDraft({ title: "", description: "", type: "contacts" })}>Create your first list</Button> : undefined}
|
|
209
|
+
/>
|
|
210
|
+
) : null}
|
|
211
|
+
</div>
|
|
212
|
+
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
213
|
+
<span>{lists.total.toLocaleString()} lists</span>
|
|
214
|
+
<div className="flex items-center gap-2">
|
|
215
|
+
<Button variant="outline" size="sm" disabled={lists.page <= 1 || lists.loading} onClick={() => lists.setPage(lists.page - 1)}>Previous</Button>
|
|
216
|
+
<span>Page {lists.page} of {lists.pageCount}</span>
|
|
217
|
+
<Button variant="outline" size="sm" disabled={!lists.hasNext || lists.loading} onClick={() => lists.setPage(lists.page + 1)}>Next</Button>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const detail = selected ? (
|
|
224
|
+
<DetailPanel
|
|
225
|
+
key={selected.id}
|
|
226
|
+
onExpand={full ? undefined : () => setView("full")}
|
|
227
|
+
onCollapse={full ? () => setView(null) : undefined}
|
|
228
|
+
title={selected.title}
|
|
229
|
+
subtitle={`${count(selected).toLocaleString()} ${selected.type === "companies" ? "companies" : "contacts"}`}
|
|
230
|
+
badges={<StatusPill tone={statusOf(selected).tone} dot>{statusOf(selected).label}</StatusPill>}
|
|
231
|
+
onClose={() => setSelectedId(null)}
|
|
232
|
+
actions={(
|
|
233
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
234
|
+
{selected.type !== "companies" ? <Button size="sm" variant="outline" onClick={() => setAdding(true)}><UserPlus className="mr-1 size-4" aria-hidden />Add contacts</Button> : null}
|
|
235
|
+
{selected.is_dynamic ? <Button size="sm" variant="outline" loading={refresh.loading} onClick={() => void refresh.mutate(selected.id)}><RefreshCw className="mr-1 size-4" aria-hidden />Refresh</Button> : null}
|
|
236
|
+
<Button size="sm" variant="outline" loading={check.loading} onClick={() => void check.mutate(selected.id)}><HeartPulse className="mr-1 size-4" aria-hidden />Check health</Button>
|
|
237
|
+
<select aria-label="Export format" className="h-8 rounded-md border bg-background px-1 text-sm" value={exportFormat} onChange={(e) => setExportFormat(e.target.value as "csv" | "xlsx")}><option value="csv">CSV</option><option value="xlsx">XLSX</option></select>
|
|
238
|
+
<Button size="sm" variant="outline" loading={download.loading} onClick={() => void download.mutate(selected.id)}><Download className="mr-1 size-4" aria-hidden />Export</Button>
|
|
239
|
+
{renderDetailActions?.(selected)}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
tabs={[
|
|
243
|
+
{
|
|
244
|
+
id: "members",
|
|
245
|
+
label: "Members",
|
|
246
|
+
content: selected.type === "companies"
|
|
247
|
+
? <EmptyState icon={<Building2 className="size-6" aria-hidden />} title="Companies in this list" description={`${count(selected).toLocaleString()} companies. The API has no read operation for company-list members, so review them in the ${brand.linkLabel} app.`}
|
|
248
|
+
action={<a className="inline-flex items-center gap-1 text-sm underline" href={`${appUrl}/lists`} target="_blank" rel="noreferrer">Open in {brand.linkLabel}<ExternalLink className="size-3" aria-hidden /></a>} />
|
|
249
|
+
: (
|
|
250
|
+
<div className="flex flex-col gap-2">
|
|
251
|
+
{removeMembers.error ? <ApiError error={removeMembers.error} title="Could not remove them" /> : null}
|
|
252
|
+
<ContactTable listId={selected.id} selectable allowDelete={false} pageSize={10} refreshKey={membersKey}
|
|
253
|
+
renderBulkActions={({ ids }) => <Button size="sm" variant="outline" onClick={() => setRemoving(ids)}>Remove from list</Button>} />
|
|
254
|
+
</div>
|
|
255
|
+
),
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "details",
|
|
259
|
+
label: "Details",
|
|
260
|
+
content: (
|
|
261
|
+
<div className="flex flex-col gap-3">
|
|
262
|
+
{refresh.error ? <ApiError error={refresh.error} title="Could not refresh the list" /> : null}
|
|
263
|
+
{check.error ? <ApiError error={check.error} title="Could not check the list" /> : null}
|
|
264
|
+
{download.error ? <ApiError error={download.error} title="Could not export the list" /> : null}
|
|
265
|
+
{health ? <pre role="status" className="overflow-x-auto rounded-md border bg-muted/40 p-2 text-xs">{JSON.stringify(health, null, 2)}</pre> : null}
|
|
266
|
+
<KeyValueList columns={2} items={[
|
|
267
|
+
{ label: "Type", value: selected.type === "companies" ? "Companies" : "Contacts" },
|
|
268
|
+
{ label: "Members", value: count(selected).toLocaleString() },
|
|
269
|
+
{ label: "Build", value: selected.is_dynamic ? "Dynamic (re-evaluated from filters)" : "Static" },
|
|
270
|
+
{ label: "Description", value: selected.description || "None" },
|
|
271
|
+
{ label: "Created", value: when(selected.created_at) || "Unknown" },
|
|
272
|
+
{ label: "Updated", value: when(selected.updated_at) || "Unknown" },
|
|
273
|
+
{ label: "Source", value: selected.source ? humanizeStatus(selected.source) : "" },
|
|
274
|
+
{ label: "Created by", value: selected.created_by ?? "" },
|
|
275
|
+
{ label: "Tags", value: selected.tags?.length ? selected.tags.join(", ") : "" },
|
|
276
|
+
{ label: "Status detail", value: selected.status_detail ?? "", wide: true },
|
|
277
|
+
{ label: "List id", value: String(selected.id), mono: true },
|
|
278
|
+
]} />
|
|
279
|
+
</div>
|
|
280
|
+
),
|
|
281
|
+
},
|
|
282
|
+
...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
|
|
283
|
+
]}
|
|
284
|
+
/>
|
|
285
|
+
) : selectedId ? (
|
|
286
|
+
<div className="flex flex-col gap-3 p-4" data-testid="list-detail-pending">
|
|
287
|
+
{resolving ? (
|
|
288
|
+
<><Skeleton className="h-6 w-1/2" /><Skeleton className="h-4 w-1/3" /><Skeleton className="h-24 w-full" /></>
|
|
289
|
+
) : lookup.error ? (
|
|
290
|
+
<ApiError error={lookup.error} title="Could not load the list" onRetry={() => void lookup.refetch()} />
|
|
291
|
+
) : (
|
|
292
|
+
<EmptyState icon={<Database className="size-6" aria-hidden />} title="List not found" description="It may have been deleted, or you may not have access to it." />
|
|
293
|
+
)}
|
|
294
|
+
<div><Button size="sm" variant="outline" onClick={() => setSelectedId(null)}>Close</Button></div>
|
|
295
|
+
</div>
|
|
296
|
+
) : null;
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<ScreenLayout
|
|
300
|
+
className={className}
|
|
301
|
+
title="Lists"
|
|
302
|
+
description="Group contacts and companies for campaigns, sequences and exports."
|
|
303
|
+
icon={<Database className="size-5" aria-hidden />}
|
|
304
|
+
actions={<>{toolbarActions}<Button size="sm" onClick={() => setDraft({ title: "", description: "", type: "contacts" })}><Plus className="mr-1 size-4" aria-hidden />New list</Button></>}
|
|
305
|
+
stats={stats}
|
|
306
|
+
toolbar={<Toolbar search={{ value: search, onChange: setSearch, placeholder: "Search lists" }} />}
|
|
307
|
+
>
|
|
308
|
+
<SplitView expanded={full} onExpandedChange={(e) => setView(e ? "full" : null)} list={table} detail={detail} detailOpen={selectedId != null} onCloseDetail={() => setSelectedId(null)} />
|
|
309
|
+
|
|
310
|
+
<FormSheet open={draft != null} onOpenChange={(o) => !o && setDraft(null)} title={draft?.id ? "Edit list" : "New list"} description="Give it a name your team will recognise."
|
|
311
|
+
footer={(<><Button variant="ghost" onClick={() => setDraft(null)}>Cancel</Button><Button loading={save.loading} disabled={!draft?.title.trim()} onClick={() => draft && void save.mutate({ ...draft, title: draft.title.trim() })}>Save</Button></>)}>
|
|
312
|
+
<div className="flex flex-col gap-3">
|
|
313
|
+
<div className="flex flex-col gap-1"><Label htmlFor="list-title">Name</Label><Input id="list-title" value={draft?.title ?? ""} onChange={(e) => setDraft((v) => v && { ...v, title: e.target.value })} /></div>
|
|
314
|
+
<div className="flex flex-col gap-1"><Label htmlFor="list-desc">Description</Label><Input id="list-desc" value={draft?.description ?? ""} onChange={(e) => setDraft((v) => v && { ...v, description: e.target.value })} /></div>
|
|
315
|
+
{!draft?.id ? (
|
|
316
|
+
<div className="flex flex-col gap-1"><Label htmlFor="list-type">Holds</Label>
|
|
317
|
+
<select id="list-type" className="h-9 rounded-md border bg-background px-2 text-sm" value={draft?.type} onChange={(e) => setDraft((v) => v && { ...v, type: e.target.value as Draft["type"] })}>
|
|
318
|
+
<option value="contacts">Contacts</option><option value="companies">Companies</option>
|
|
319
|
+
</select>
|
|
320
|
+
</div>
|
|
321
|
+
) : null}
|
|
322
|
+
{save.error ? <ApiError error={save.error} title="Could not save the list" /> : null}
|
|
323
|
+
</div>
|
|
324
|
+
</FormSheet>
|
|
325
|
+
|
|
326
|
+
<Sheet open={adding && selected != null} onOpenChange={setAdding}>
|
|
327
|
+
<SheetContent className="w-full overflow-y-auto p-4 sm:max-w-3xl">
|
|
328
|
+
<SheetHeader><SheetTitle>Add contacts to {selected?.title}</SheetTitle><SheetDescription>Tick contacts from your CRM, then add them.</SheetDescription></SheetHeader>
|
|
329
|
+
{selected ? (
|
|
330
|
+
<ContactTable selectable allowDelete={false} pageSize={10}
|
|
331
|
+
renderBulkActions={({ ids, clear }) => <AddToListButton contactIds={ids} listId={selected.id} label={`Add ${ids.length} to list`} onAdded={() => { clear(); setMembersKey((k) => k + 1); void lists.refetch(); }} />} />
|
|
332
|
+
) : null}
|
|
333
|
+
</SheetContent>
|
|
334
|
+
</Sheet>
|
|
335
|
+
|
|
336
|
+
{/* Outside the sheets: a modal sheet makes everything outside it inert, and the confirm is only opened from the panel. */}
|
|
337
|
+
<ConfirmSpendDialog open={removing != null} tier="destructive" title={`Remove ${removing?.length ?? 0} contacts from "${selected?.title ?? "the list"}"`} description="They stay in your CRM; they only leave this list."
|
|
338
|
+
affected={removing?.length} confirmLabel="Remove" confirming={removeMembers.loading}
|
|
339
|
+
onConfirm={() => selected && removing && void removeMembers.mutate({ listId: selected.id, ids: removing })} onCancel={() => setRemoving(null)} />
|
|
340
|
+
<ConfirmSpendDialog open={deleting != null} tier="destructive" title={`Delete list "${deleting?.title ?? ""}"`} description="The list is removed. Its contacts stay in your CRM."
|
|
341
|
+
affected={deleting ? count(deleting) : undefined} confirming={remove.loading} onConfirm={() => deleting && void remove.mutate(deleting.id)} onCancel={() => setDeleting(null)} />
|
|
342
|
+
</ScreenLayout>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
@@ -23,6 +23,7 @@ import { EmptyState } from "@/g8/components/empty-state";
|
|
|
23
23
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
24
24
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
25
25
|
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
26
|
+
import { plural } from "@/g8/lib/plural";
|
|
26
27
|
|
|
27
28
|
export interface SuppressionRecord {
|
|
28
29
|
id: string;
|
|
@@ -32,6 +33,9 @@ export interface SuppressionRecord {
|
|
|
32
33
|
source?: string | null;
|
|
33
34
|
reason?: string | null;
|
|
34
35
|
created_at?: string | null;
|
|
36
|
+
created_by?: string | null;
|
|
37
|
+
source_ref?: string | null;
|
|
38
|
+
revision?: number | null;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
interface ContactStatus { contact_id: number; is_suppressed: boolean; active_channels: string[]; suppressions: SuppressionRecord[] }
|
|
@@ -86,7 +90,7 @@ export function SuppressionManager({ pageSize = 50, onOpenContact, className }:
|
|
|
86
90
|
<option value="all">All channels</option><option value="email">Email</option><option value="phone">Phone</option><option value="linkedin">LinkedIn</option>
|
|
87
91
|
</select>
|
|
88
92
|
<Input aria-label="Reason" placeholder="Reason (optional)" className="w-64" value={reason} onChange={(e) => setReason(e.target.value)} />
|
|
89
|
-
<Button size="sm" loading={add.loading} disabled={ids.length === 0} onClick={() => void add.mutate(ids)}>
|
|
93
|
+
<Button size="sm" loading={add.loading} disabled={ids.length === 0} onClick={() => void add.mutate(ids)}>{ids.length ? `Suppress ${plural(ids.length, "contact")}` : "Suppress contacts"}</Button>
|
|
90
94
|
</div>
|
|
91
95
|
{add.data ? <p role="status" className="text-sm">Suppressed {add.data.data.suppressed} of {add.data.data.total_requested}.{add.data.data.not_found.length ? ` ${add.data.data.not_found.length} ids do not exist.` : ""}</p> : null}
|
|
92
96
|
{add.error ? <ApiError error={add.error} title="Could not suppress" /> : null}
|
|
@@ -138,7 +142,7 @@ export function SuppressionManager({ pageSize = 50, onOpenContact, className }:
|
|
|
138
142
|
</div>
|
|
139
143
|
</section>
|
|
140
144
|
|
|
141
|
-
<ConfirmSpendDialog open={confirmReinstate} tier="external" title={`Reinstate ${selectedContacts.length
|
|
145
|
+
<ConfirmSpendDialog open={confirmReinstate} tier="external" title={`Reinstate ${plural(selectedContacts.length, "contact")}`}
|
|
142
146
|
description={(
|
|
143
147
|
<div className="flex flex-col gap-2">
|
|
144
148
|
<p>Reinstating a contact lifts EVERY suppression on it (all channels), not only the row you ticked. They can be messaged again by sequences, campaigns and newsletters. Contacts who unsubscribed themselves may be refused.</p>
|