@graph8/sdk 0.370.0 → 0.374.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 +22 -3
- package/blocks/_foundation/README.md +61 -0
- package/blocks/_foundation/block.json +57 -0
- package/blocks/_foundation/components/auth-gate.tsx +363 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
- package/blocks/_foundation/components/empty-state.tsx +5 -2
- package/blocks/_foundation/components/list-select.tsx +112 -0
- package/blocks/_foundation/components/paging-notices.tsx +55 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +2 -1
- package/blocks/_foundation/components/screen/filter-controls.tsx +92 -0
- package/blocks/_foundation/components/screen/index.ts +1 -0
- package/blocks/_foundation/components/screen/split-view.tsx +1 -1
- package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
- package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
- package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
- package/blocks/_foundation/lib/paging.ts +57 -0
- package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
- package/blocks/_foundation/server/auth.ts +815 -0
- package/blocks/agents/README.md +81 -4
- package/blocks/agents/block.json +311 -2
- package/blocks/agents/components/agent-activity.tsx +112 -0
- package/blocks/agents/components/agent-approvals.tsx +135 -0
- package/blocks/agents/components/agent-builder.tsx +17 -3
- package/blocks/agents/components/agent-conversations.tsx +143 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -2
- package/blocks/agents/components/agent-operators.tsx +164 -0
- package/blocks/agents/components/agent-run-detail.tsx +20 -3
- package/blocks/agents/components/agent-runs.tsx +3 -3
- package/blocks/agents/components/agent-skills-panel.tsx +7 -7
- package/blocks/agents/components/agent-test-history.tsx +121 -0
- package/blocks/agents/components/agents-screen.tsx +53 -11
- package/blocks/agents/components/assistant-sessions.tsx +150 -0
- package/blocks/agents/components/duplicate-agent-button.tsx +42 -0
- package/blocks/agents/components/from-template-button.tsx +65 -0
- package/blocks/agents/components/knowledge-browser.tsx +107 -0
- package/blocks/agents/components/knowledge-detail.tsx +156 -0
- package/blocks/agents/components/knowledge-form.tsx +69 -0
- package/blocks/agents/components/name-dialog.tsx +32 -0
- package/blocks/agents/components/receptionist-button.tsx +46 -0
- package/blocks/agents/components/save-as-template-button.tsx +32 -0
- package/blocks/agents/components/skill-editor.tsx +147 -0
- package/blocks/agents/components/skill-library.tsx +111 -0
- package/blocks/agents/lib/agent-types.ts +7 -1
- package/blocks/agents/lib/facts.tsx +13 -0
- package/blocks/agents/lib/knowledge-utils.ts +49 -0
- package/blocks/agents/lib/load-more.tsx +141 -0
- package/blocks/agents/lib/skill-utils.ts +91 -0
- package/blocks/agents/recipes/create-agent-and-attach-skill.ts +1 -1
- package/blocks/analytics/components/analytics-screen.tsx +16 -3
- package/blocks/analytics/components/kpi-tiles.tsx +1 -1
- package/blocks/analytics/components/report-builder.tsx +15 -2
- package/blocks/appointments/README.md +6 -0
- package/blocks/appointments/block.json +27 -0
- package/blocks/appointments/components/booking-page.tsx +2 -2
- package/blocks/appointments/components/meeting-contact-links.tsx +49 -0
- package/blocks/appointments/components/meeting-detail.tsx +2 -2
- package/blocks/appointments/components/meeting-list.tsx +29 -13
- package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
- package/blocks/campaigns/README.md +1 -1
- package/blocks/campaigns/block.json +34 -1
- package/blocks/campaigns/components/campaign-assets.tsx +1 -1
- package/blocks/campaigns/components/campaign-launch-step.tsx +16 -11
- package/blocks/campaigns/components/campaign-links.tsx +180 -0
- package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
- package/blocks/campaigns/components/campaign-wizard.tsx +10 -5
- package/blocks/campaigns/components/icp-detail.tsx +11 -2
- package/blocks/campaigns/components/icp-editor.tsx +8 -3
- package/blocks/campaigns/components/persona-detail.tsx +11 -2
- package/blocks/campaigns/components/persona-editor.tsx +6 -1
- package/blocks/campaigns/lib/campaigns-utils.ts +4 -4
- package/blocks/coach/components/call-scorecard.tsx +7 -7
- package/blocks/company-intelligence/README.md +132 -0
- package/blocks/company-intelligence/block.json +318 -0
- package/blocks/company-intelligence/components/company-intelligence-screen.tsx +114 -0
- package/blocks/company-intelligence/components/company-intelligence-tab.tsx +88 -0
- package/blocks/company-intelligence/components/company-profile.tsx +68 -0
- package/blocks/company-intelligence/components/intelligence-sources.tsx +63 -0
- package/blocks/company-intelligence/components/report-detail.tsx +189 -0
- package/blocks/company-intelligence/components/research-reports.tsx +136 -0
- package/blocks/company-intelligence/components/research-run.tsx +165 -0
- package/blocks/company-intelligence/components/source-detail.tsx +117 -0
- package/blocks/company-intelligence/components/source-view.tsx +274 -0
- package/blocks/company-intelligence/components/wiki-document-detail.tsx +116 -0
- package/blocks/company-intelligence/components/wiki-documents.tsx +66 -0
- package/blocks/company-intelligence/hooks/use-intelligence.ts +173 -0
- package/blocks/company-intelligence/lib/intelligence-types.ts +230 -0
- package/blocks/company-intelligence/lib/safe-markdown.tsx +150 -0
- package/blocks/company-intelligence/recipes/read-competitors-and-news.ts +33 -0
- package/blocks/company-intelligence/recipes/research-your-website.ts +43 -0
- package/blocks/company-intelligence/recipes/write-the-research-reports.ts +54 -0
- package/blocks/copilot/components/copilot-memory.tsx +6 -2
- package/blocks/copilot/components/copilot-panel.tsx +1 -1
- package/blocks/copilot/components/copilot-team-chat.tsx +6 -1
- package/blocks/crm/README.md +25 -0
- package/blocks/crm/block.json +257 -9
- package/blocks/crm/components/bulk-update.tsx +119 -0
- package/blocks/crm/components/companies-screen.tsx +23 -9
- package/blocks/crm/components/company-detail.tsx +2 -2
- package/blocks/crm/components/contact-detail.tsx +4 -4
- package/blocks/crm/components/contact-documents-tab.tsx +144 -0
- package/blocks/crm/components/contact-insights-tab.tsx +118 -0
- package/blocks/crm/components/contact-lookup-link.tsx +85 -0
- package/blocks/crm/components/contact-row-actions.tsx +43 -0
- package/blocks/crm/components/contact-tag-chips.tsx +113 -0
- package/blocks/crm/components/contacts-screen.tsx +77 -18
- package/blocks/crm/components/dedupe-review.tsx +6 -1
- package/blocks/crm/components/import-wizard.tsx +3 -7
- package/blocks/crm/components/list-select.tsx +20 -0
- package/blocks/crm/components/owner-assign.tsx +132 -0
- package/blocks/crm/components/record-engagement-tab.tsx +103 -0
- package/blocks/crm/components/record-form.tsx +1 -1
- package/blocks/crm/components/record-open-links.tsx +79 -0
- package/blocks/crm/components/tag-assign.tsx +112 -0
- package/blocks/crm/components/tag-manager.tsx +178 -0
- package/blocks/crm/components/tasks-screen.tsx +35 -15
- package/blocks/crm/components/unlock-info-button.tsx +82 -0
- package/blocks/crm/components/verify-email-button.tsx +93 -0
- package/blocks/crm/hooks/use-all-lists.ts +18 -0
- package/blocks/crm/hooks/use-contact-tags.ts +29 -0
- package/blocks/crm/hooks/use-page-query.ts +3 -1
- package/blocks/crm/lib/crm-types.ts +14 -0
- package/blocks/crm/lib/read-lists.ts +16 -0
- package/blocks/crm/lib/record-actions.ts +102 -0
- package/blocks/deals/README.md +25 -6
- package/blocks/deals/block.json +168 -2
- package/blocks/deals/components/close-deal-dialog.tsx +169 -0
- package/blocks/deals/components/create-deal-button.tsx +109 -0
- package/blocks/deals/components/deal-contacts-editor.tsx +134 -0
- package/blocks/deals/components/deal-detail.tsx +7 -5
- package/blocks/deals/components/deal-filters.tsx +65 -0
- package/blocks/deals/components/deal-line-items.tsx +120 -0
- package/blocks/deals/components/deal-notes-tab.tsx +97 -0
- package/blocks/deals/components/deal-related.tsx +30 -9
- package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
- package/blocks/deals/components/deals-screen.tsx +57 -22
- package/blocks/deals/components/forecast-view.tsx +4 -4
- package/blocks/deals/components/pipeline-board.tsx +8 -6
- package/blocks/deals/components/pipeline-editor.tsx +132 -0
- package/blocks/deals/components/record-deals-tab.tsx +62 -0
- package/blocks/deals/components/stage-editor.tsx +1 -1
- package/blocks/deals/hooks/use-pipeline-deals.ts +17 -4
- package/blocks/deals/lib/deal-extras.ts +156 -0
- package/blocks/dialer/README.md +14 -5
- package/blocks/dialer/block.json +17 -2
- package/blocks/dialer/components/call-contact-button.tsx +26 -9
- package/blocks/dialer/components/call-log-screen.tsx +10 -2
- package/blocks/dialer/components/call-log.tsx +16 -4
- package/blocks/dialer/components/call-queue.tsx +3 -3
- package/blocks/dialer/components/dialer-screen.tsx +69 -27
- package/blocks/dialer/components/disposition-panel.tsx +1 -1
- package/blocks/dialer/components/record-calls-tab.tsx +101 -0
- package/blocks/dialer/components/session-start-form.tsx +58 -11
- package/blocks/dialer/components/softphone.tsx +1 -1
- package/blocks/documents/README.md +86 -0
- package/blocks/documents/block.json +378 -0
- package/blocks/documents/components/account-bar.tsx +48 -0
- package/blocks/documents/components/correspondence-form.tsx +95 -0
- package/blocks/documents/components/correspondence-import.tsx +106 -0
- package/blocks/documents/components/correspondence-panel.tsx +191 -0
- package/blocks/documents/components/documents-screen.tsx +76 -0
- package/blocks/documents/components/library-document-detail.tsx +175 -0
- package/blocks/documents/components/library-document-form.tsx +105 -0
- package/blocks/documents/components/library-folder-form.tsx +74 -0
- package/blocks/documents/components/library-panel.tsx +235 -0
- package/blocks/documents/components/library-upload.tsx +160 -0
- package/blocks/documents/components/question-row.tsx +130 -0
- package/blocks/documents/components/questionnaire-detail.tsx +197 -0
- package/blocks/documents/components/questionnaires-panel.tsx +173 -0
- package/blocks/documents/components/record-documents-tab.tsx +109 -0
- package/blocks/documents/lib/documents-types.ts +252 -0
- package/blocks/documents/lib/download.ts +64 -0
- package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
- package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
- package/blocks/documents/recipes/organize-the-library.ts +25 -0
- package/blocks/email-signatures/README.md +123 -0
- package/blocks/email-signatures/block.json +341 -0
- package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
- package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
- package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
- package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
- package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
- package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
- package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
- package/blocks/email-signatures/components/signature-policy.tsx +130 -0
- package/blocks/email-signatures/components/signature-preview.tsx +119 -0
- package/blocks/email-signatures/components/signature-report.tsx +77 -0
- package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
- package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
- package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
- package/blocks/email-signatures/lib/signatures-types.ts +392 -0
- package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
- package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
- package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
- package/blocks/enrichment/README.md +38 -10
- package/blocks/enrichment/block.json +164 -12
- package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
- package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +79 -30
- package/blocks/enrichment/components/email-verify-badge.tsx +3 -3
- package/blocks/enrichment/components/enrich-button.tsx +1 -1
- package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
- package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +103 -19
- package/blocks/enrichment/components/list-pipelines.tsx +97 -0
- package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
- package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
- package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
- package/blocks/enrichment/components/waterfall-config.tsx +27 -7
- package/blocks/enrichment/lib/enrichment-types.ts +51 -0
- package/blocks/enrichment/lib/pipeline-types.ts +234 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
- package/blocks/forms/README.md +10 -0
- package/blocks/forms/block.json +107 -0
- package/blocks/forms/components/form-activity.tsx +71 -0
- package/blocks/forms/components/form-backfill.tsx +108 -0
- package/blocks/forms/components/form-builder.tsx +1 -1
- package/blocks/forms/components/form-embed.tsx +1 -1
- package/blocks/forms/components/form-groups.tsx +151 -0
- package/blocks/forms/components/form-mapping-editor.tsx +6 -9
- package/blocks/forms/components/form-route-history.tsx +72 -0
- package/blocks/forms/components/form-saved-views.tsx +81 -0
- package/blocks/forms/components/forms-screen.tsx +57 -20
- package/blocks/forms/components/more-rows.tsx +30 -0
- package/blocks/forms/components/submissions-export.tsx +67 -0
- package/blocks/forms/components/submissions-table.tsx +15 -6
- package/blocks/forms/lib/forms-extra.ts +67 -0
- package/blocks/inbox/README.md +45 -4
- package/blocks/inbox/block.json +145 -8
- package/blocks/inbox/components/blocked-senders.tsx +103 -0
- package/blocks/inbox/components/compose-message.tsx +193 -0
- package/blocks/inbox/components/inbox-screen.tsx +54 -6
- package/blocks/inbox/components/inbox-search.tsx +106 -0
- package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
- package/blocks/inbox/components/tag-manager.tsx +136 -0
- package/blocks/inbox/components/thread-actions.tsx +133 -0
- package/blocks/inbox/components/thread-badge.tsx +2 -2
- package/blocks/inbox/components/thread-view.tsx +3 -1
- package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
- package/blocks/inbox/lib/inbox-manage.ts +227 -0
- package/blocks/integrations/README.md +54 -1
- package/blocks/integrations/block.json +189 -15
- package/blocks/integrations/components/ad-accounts.tsx +82 -0
- package/blocks/integrations/components/connection-health.tsx +59 -0
- package/blocks/integrations/components/crm-detail.tsx +20 -1
- package/blocks/integrations/components/crm-records.tsx +103 -0
- package/blocks/integrations/components/field-mappings.tsx +61 -0
- package/blocks/integrations/components/integration-list.tsx +4 -4
- package/blocks/integrations/components/integrations-screen.tsx +20 -3
- package/blocks/integrations/components/migration-panel.tsx +133 -0
- package/blocks/integrations/components/sugarcrm-connect.tsx +95 -0
- package/blocks/integrations/components/sync-status.tsx +6 -6
- package/blocks/integrations/components/sync-triggers.tsx +134 -0
- package/blocks/integrations/components/webhook-detail.tsx +26 -7
- package/blocks/integrations/components/webhook-manager.tsx +30 -17
- package/blocks/integrations/components/workspace-tools.tsx +127 -0
- package/blocks/integrations/lib/crm-utils.ts +39 -0
- package/blocks/integrations/lib/facts.tsx +13 -0
- package/blocks/integrations/lib/integrations-utils.ts +5 -0
- package/blocks/integrations/lib/load-more.tsx +141 -0
- package/blocks/intent/components/account-intent-score.tsx +5 -5
- package/blocks/intent/components/intent-feed.tsx +8 -8
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -8
- package/blocks/intent/lib/intent-utils.ts +4 -4
- package/blocks/lifecycle/components/account-related.tsx +8 -1
- package/blocks/lifecycle/components/churn-list.tsx +5 -2
- package/blocks/lifecycle/components/customer-table.tsx +1 -1
- package/blocks/lists/README.md +7 -1
- package/blocks/lists/block.json +31 -3
- package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
- package/blocks/lists/components/add-to-list-button.tsx +17 -7
- package/blocks/lists/components/list-picker.tsx +13 -3
- package/blocks/lists/components/list-table.tsx +1 -1
- package/blocks/lists/components/lists-screen.tsx +4 -2
- package/blocks/lists/components/record-lists-tab.tsx +99 -0
- package/blocks/lists/components/suppression-manager.tsx +27 -6
- package/blocks/lists/components/suppressions-screen.tsx +21 -12
- package/blocks/mailboxes/block.json +4 -0
- package/blocks/mailboxes/components/connect-mailbox.tsx +9 -8
- package/blocks/mailboxes/components/domain-detail.tsx +10 -4
- package/blocks/mailboxes/components/domain-purchase.tsx +14 -11
- package/blocks/mailboxes/components/mailbox-detail.tsx +3 -2
- package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
- package/blocks/mailboxes/components/mailboxes-screen.tsx +8 -3
- package/blocks/mailboxes/components/warmup-health.tsx +20 -20
- package/blocks/mailboxes/lib/mailboxes-utils.ts +4 -4
- package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
- package/blocks/newsletter/README.md +12 -0
- package/blocks/newsletter/block.json +117 -0
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -2
- package/blocks/newsletter/components/issue-versions.tsx +93 -0
- package/blocks/newsletter/components/more-rows.tsx +30 -0
- package/blocks/newsletter/components/newsletter-editor.tsx +1 -1
- package/blocks/newsletter/components/newsletter-list-syncs.tsx +121 -0
- package/blocks/newsletter/components/newsletter-screen.tsx +38 -13
- package/blocks/newsletter/components/newsletter-settings.tsx +211 -0
- package/blocks/newsletter/components/newsletter-stats.tsx +2 -2
- package/blocks/newsletter/components/newsletter-templates.tsx +133 -0
- package/blocks/newsletter/components/subscriber-table.tsx +5 -7
- package/blocks/newsletter/components/subscriber-timeline.tsx +65 -0
- package/blocks/newsletter/lib/newsletter-extra.ts +60 -0
- package/blocks/nurture/components/nurture-builder.tsx +10 -2
- package/blocks/nurture/components/nurture-enroll.tsx +6 -7
- package/blocks/nurture/components/nurture-form.tsx +5 -8
- package/blocks/nurture/components/nurture-screen.tsx +16 -5
- package/blocks/nurture/components/nurture-stats.tsx +5 -4
- package/blocks/objects/README.md +1 -1
- package/blocks/objects/block.json +13 -5
- package/blocks/objects/components/attribute-field-input.tsx +14 -1
- package/blocks/objects/components/object-record-detail.tsx +8 -1
- package/blocks/objects/components/object-schema-editor.tsx +1 -1
- package/blocks/objects/components/record-roles-panel.tsx +15 -5
- package/blocks/pages/README.md +14 -0
- package/blocks/pages/block.json +123 -2
- package/blocks/pages/components/more-rows.tsx +30 -0
- package/blocks/pages/components/page-ai-tools.tsx +196 -0
- package/blocks/pages/components/page-analytics.tsx +176 -0
- package/blocks/pages/components/page-clone.tsx +99 -0
- package/blocks/pages/components/page-domains.tsx +167 -0
- package/blocks/pages/components/page-editor.tsx +1 -1
- package/blocks/pages/components/page-generation-status.tsx +48 -0
- package/blocks/pages/components/page-tracking.tsx +137 -0
- package/blocks/pages/components/pages-screen.tsx +57 -16
- package/blocks/pages/components/seo-report.tsx +6 -1
- package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
- package/blocks/pages/lib/studio.ts +7 -0
- package/blocks/phone-numbers/README.md +74 -0
- package/blocks/phone-numbers/block.json +262 -0
- package/blocks/phone-numbers/components/a2p-registration.tsx +230 -0
- package/blocks/phone-numbers/components/assign-number.tsx +104 -0
- package/blocks/phone-numbers/components/number-search.tsx +239 -0
- package/blocks/phone-numbers/components/number-settings.tsx +134 -0
- package/blocks/phone-numbers/components/number-usage.tsx +97 -0
- package/blocks/phone-numbers/components/phone-number-detail.tsx +84 -0
- package/blocks/phone-numbers/components/phone-number-list.tsx +110 -0
- package/blocks/phone-numbers/components/phone-numbers-screen.tsx +201 -0
- package/blocks/phone-numbers/components/register-owned-number.tsx +98 -0
- package/blocks/phone-numbers/components/release-number.tsx +126 -0
- package/blocks/phone-numbers/hooks/use-phone-numbers.ts +58 -0
- package/blocks/phone-numbers/lib/phone-numbers-utils.ts +202 -0
- package/blocks/phone-numbers/recipes/01-search-and-buy-number.ts +49 -0
- package/blocks/phone-numbers/recipes/02-assign-number-to-rep.ts +25 -0
- package/blocks/phone-numbers/recipes/03-audit-numbers.ts +36 -0
- package/blocks/phone-numbers/recipes/04-register-text-messaging.ts +39 -0
- package/blocks/pickers/README.md +23 -0
- package/blocks/pickers/block.json +103 -0
- package/blocks/pickers/components/company-picker.tsx +90 -0
- package/blocks/pickers/components/contact-picker.tsx +131 -0
- package/blocks/pickers/components/member-picker.tsx +99 -0
- package/blocks/pickers/components/search-picker.tsx +98 -0
- package/blocks/pickers/hooks/use-current-member.ts +104 -0
- package/blocks/pickers/hooks/use-picker-pages.ts +73 -0
- package/blocks/pickers/lib/use-debounced.ts +13 -0
- package/blocks/programmatic-pages/README.md +82 -0
- package/blocks/programmatic-pages/block.json +292 -0
- package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
- package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
- package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
- package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
- package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
- package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
- package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
- package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
- package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
- package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
- package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
- package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
- package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
- package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
- package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
- package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
- package/blocks/quotes/README.md +38 -1
- package/blocks/quotes/block.json +170 -6
- package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
- package/blocks/quotes/components/manual-products.tsx +144 -0
- package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
- package/blocks/quotes/components/quote-builder.tsx +1 -1
- package/blocks/quotes/components/quote-documents.tsx +94 -0
- package/blocks/quotes/components/quote-evidence.tsx +95 -0
- package/blocks/quotes/components/quote-lifecycle-actions.tsx +146 -0
- package/blocks/quotes/components/quote-list.tsx +28 -8
- package/blocks/quotes/components/quote-related.tsx +3 -2
- package/blocks/quotes/components/quote-settings.tsx +184 -0
- package/blocks/quotes/components/quote-templates.tsx +189 -0
- package/blocks/quotes/components/quote-viewer.tsx +23 -5
- package/blocks/quotes/components/quotes-screen.tsx +15 -5
- package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
- package/blocks/quotes/lib/quote-admin.ts +264 -0
- package/blocks/radar/components/competitor-battle-card.tsx +1 -1
- package/blocks/radar/components/radar-overview.tsx +4 -4
- package/blocks/radar/components/radar-screen.tsx +2 -2
- package/blocks/sequencer/README.md +61 -0
- package/blocks/sequencer/block.json +200 -6
- package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +2 -2
- package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
- package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
- package/blocks/sequencer/components/sequence-builder.tsx +32 -23
- package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
- package/blocks/sequencer/components/sequence-detail.tsx +10 -2
- package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +5 -2
- package/blocks/sequencer/components/sequence-list.tsx +1 -1
- package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
- package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
- package/blocks/sequencer/components/sequence-stats.tsx +8 -8
- package/blocks/sequencer/components/sequence-templates.tsx +160 -0
- package/blocks/sequencer/components/sequences-screen.tsx +39 -6
- package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
- package/blocks/sequencer/components/step-editor.tsx +21 -12
- package/blocks/sequencer/components/step-templates.tsx +224 -0
- package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
- package/blocks/sequencer/lib/sequencer-utils.ts +9 -5
- package/blocks/signals/README.md +39 -0
- package/blocks/signals/block.json +287 -0
- package/blocks/signals/components/auto-seed-panel.tsx +111 -0
- package/blocks/signals/components/listener-form.tsx +184 -0
- package/blocks/signals/components/matched-posts.tsx +195 -0
- package/blocks/signals/components/post-engagements.tsx +114 -0
- package/blocks/signals/components/record-social-tab.tsx +49 -0
- package/blocks/signals/components/social-listeners-screen.tsx +250 -0
- package/blocks/signals/components/social-settings.tsx +128 -0
- package/blocks/signals/components/source-lists.tsx +83 -0
- package/blocks/signals/components/top-engagers.tsx +68 -0
- package/blocks/signals/components/x-integration.tsx +129 -0
- package/blocks/signals/lib/signals-types.ts +228 -0
- package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
- package/blocks/signals/recipes/spend-against-caps.ts +14 -0
- package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
- package/blocks/social-ads/README.md +8 -0
- package/blocks/social-ads/block.json +137 -4
- package/blocks/social-ads/components/audience-sync-detail.tsx +9 -3
- package/blocks/social-ads/components/audience-sync.tsx +6 -2
- package/blocks/social-ads/components/campaign-ads.tsx +166 -0
- package/blocks/social-ads/components/campaign-detail.tsx +6 -1
- package/blocks/social-ads/components/campaign-editor.tsx +134 -0
- package/blocks/social-ads/components/creative-concepts.tsx +74 -0
- package/blocks/social-ads/components/creative-detail.tsx +6 -1
- package/blocks/social-ads/components/creative-gallery.tsx +20 -2
- package/blocks/social-ads/components/creative-revisions.tsx +57 -0
- package/blocks/social-ads/components/creative-tools.tsx +176 -0
- package/blocks/social-ads/components/post-composer.tsx +4 -2
- package/blocks/social-ads/components/post-detail.tsx +3 -0
- package/blocks/social-ads/components/social-ideas.tsx +64 -0
- package/blocks/social-ads/components/social-performance.tsx +80 -0
- package/blocks/social-ads/components/social-screen.tsx +33 -6
- package/blocks/social-ads/lib/ads-extra.ts +67 -0
- package/blocks/surveys/README.md +91 -0
- package/blocks/surveys/block.json +299 -0
- package/blocks/surveys/components/account-nps.tsx +69 -0
- package/blocks/surveys/components/contact-survey-history.tsx +52 -0
- package/blocks/surveys/components/org-summary.tsx +82 -0
- package/blocks/surveys/components/sending-domains.tsx +113 -0
- package/blocks/surveys/components/survey-form.tsx +126 -0
- package/blocks/surveys/components/survey-responses.tsx +145 -0
- package/blocks/surveys/components/survey-results.tsx +72 -0
- package/blocks/surveys/components/survey-send-history.tsx +58 -0
- package/blocks/surveys/components/survey-send.tsx +162 -0
- package/blocks/surveys/components/survey-triggers.tsx +166 -0
- package/blocks/surveys/components/surveys-screen.tsx +234 -0
- package/blocks/surveys/lib/surveys-types.ts +228 -0
- package/blocks/surveys/recipes/create-an-nps-survey.ts +22 -0
- package/blocks/surveys/recipes/send-a-survey-and-follow-it.ts +27 -0
- package/blocks/surveys/recipes/survey-score-report.ts +27 -0
- package/blocks/visitors/components/snippet-install.tsx +18 -9
- package/blocks/visitors/components/traffic-chart.tsx +7 -6
- package/blocks/visitors/components/visitor-company-card.tsx +24 -14
- package/blocks/visitors/components/visitor-feed.tsx +5 -5
- package/blocks/web-analytics/README.md +102 -0
- package/blocks/web-analytics/block.json +292 -0
- package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
- package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
- package/blocks/web-analytics/components/audience-tab.tsx +135 -0
- package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
- package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
- package/blocks/web-analytics/components/overview-tab.tsx +102 -0
- package/blocks/web-analytics/components/pages-tab.tsx +148 -0
- package/blocks/web-analytics/components/ranked-table.tsx +103 -0
- package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
- package/blocks/web-analytics/components/report-card.tsx +71 -0
- package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
- package/blocks/web-analytics/hooks/use-report.ts +48 -0
- package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
- package/blocks/web-analytics/lib/page-path.ts +14 -0
- package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
- package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
- package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
- package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
- package/blocks/webchat/components/chat-widget-config.tsx +1 -1
- package/blocks/webchat/components/conversation-list.tsx +1 -1
- package/blocks/workbench/README.md +54 -2
- package/blocks/workbench/block.json +234 -5
- package/blocks/workbench/components/column-recipe-editor.tsx +1 -1
- package/blocks/workbench/components/run-progress.tsx +1 -1
- package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
- package/blocks/workbench/components/workbench-automation.tsx +36 -0
- package/blocks/workbench/components/workbench-connections.tsx +136 -0
- package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
- package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
- package/blocks/workbench/components/workbench-grid.tsx +71 -8
- package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
- package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
- package/blocks/workbench/components/workbench-schedule.tsx +191 -0
- package/blocks/workbench/components/workbench-screen.tsx +65 -22
- package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
- package/blocks/workbench/components/workbench-table-list.tsx +14 -2
- package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
- package/blocks/workbench/components/workbench-triggers.tsx +178 -0
- package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
- package/blocks/workbench/lib/api-status.ts +8 -0
- package/blocks/workbench/lib/automation-types.ts +257 -0
- package/blocks/workflows/components/workflow-editor.tsx +1 -1
- package/dist/cli.js +744 -394
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/auth.d.ts +45 -0
- package/dist/types/blocks/brand.d.ts +11 -2
- package/dist/types/blocks/create.d.ts +11 -4
- package/dist/types/blocks/doctor.d.ts +1 -0
- package/dist/types/blocks/flows.d.ts +7 -1
- package/dist/types/blocks/orgBrand.d.ts +34 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/version.d.ts +2 -2
- package/docs/api/desk.d.ts +2 -2
- package/docs/api/desk.md +3 -3
- package/docs/api/forms.md +1 -1
- package/package.json +1 -1
- package/stacks/6sense/pages/companies.tsx.tmpl +21 -0
- package/stacks/6sense/pages/contacts.tsx.tmpl +21 -0
- package/stacks/6sense/pages/tasks.tsx.tmpl +19 -0
- package/stacks/6sense.json +37 -4
- package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
- package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/apollo/pages/companies.tsx.tmpl +19 -36
- package/stacks/apollo/pages/contacts.tsx.tmpl +35 -95
- package/stacks/apollo/pages/deals.tsx.tmpl +17 -0
- package/stacks/apollo/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/apollo/pages/lists.tsx.tmpl +24 -0
- package/stacks/apollo/pages/meetings.tsx.tmpl +13 -0
- package/stacks/apollo/pages/sequences.tsx.tmpl +18 -0
- package/stacks/apollo/pages/tasks.tsx.tmpl +20 -0
- package/stacks/apollo.json +68 -9
- package/stacks/calendly/pages/companies.tsx.tmpl +19 -0
- package/stacks/calendly/pages/contacts.tsx.tmpl +21 -0
- package/stacks/calendly/pages/meetings.tsx.tmpl +13 -0
- package/stacks/calendly/pages/tasks.tsx.tmpl +19 -0
- package/stacks/calendly.json +8 -4
- package/stacks/clay/pages/companies.tsx.tmpl +17 -1
- package/stacks/clay/pages/contacts.tsx.tmpl +14 -0
- package/stacks/clay/pages/lists.tsx.tmpl +24 -0
- package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
- package/stacks/clay.json +16 -3
- package/stacks/gong/pages/calls.tsx.tmpl +23 -0
- package/stacks/gong/pages/companies.tsx.tmpl +22 -0
- package/stacks/gong/pages/contacts.tsx.tmpl +34 -0
- package/stacks/gong/pages/lists.tsx.tmpl +12 -0
- package/stacks/gong/pages/meetings.tsx.tmpl +13 -0
- package/stacks/gong/pages/tasks.tsx.tmpl +19 -0
- package/stacks/gong.json +23 -6
- package/stacks/hootsuite/pages/companies.tsx.tmpl +22 -0
- package/stacks/hootsuite/pages/contacts.tsx.tmpl +30 -0
- package/stacks/hootsuite/pages/lists.tsx.tmpl +12 -0
- package/stacks/hootsuite/pages/tasks.tsx.tmpl +19 -0
- package/stacks/hootsuite.json +19 -4
- package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +16 -35
- package/stacks/hubspot/pages/contacts.tsx.tmpl +35 -95
- package/stacks/hubspot/pages/deals.tsx.tmpl +14 -1
- package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +12 -1
- package/stacks/hubspot/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/hubspot/pages/lists.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/meetings.tsx.tmpl +13 -0
- package/stacks/hubspot/pages/sequences.tsx.tmpl +18 -0
- package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
- package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
- package/stacks/hubspot.json +66 -7
- package/stacks/intercom/pages/companies.tsx.tmpl +19 -0
- package/stacks/intercom/pages/contacts.tsx.tmpl +21 -0
- package/stacks/intercom/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/intercom/pages/tasks.tsx.tmpl +19 -0
- package/stacks/intercom.json +18 -4
- package/stacks/mailchimp/pages/companies.tsx.tmpl +22 -0
- package/stacks/mailchimp/pages/contacts.tsx.tmpl +30 -0
- package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
- package/stacks/mailchimp/pages/lists.tsx.tmpl +12 -0
- package/stacks/mailchimp/pages/tasks.tsx.tmpl +19 -0
- package/stacks/mailchimp.json +22 -6
- package/stacks/outreach/pages/calls.tsx.tmpl +23 -0
- package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/outreach/pages/companies.tsx.tmpl +22 -0
- package/stacks/outreach/pages/contacts.tsx.tmpl +29 -66
- package/stacks/outreach/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/outreach/pages/lists.tsx.tmpl +12 -0
- package/stacks/outreach/pages/meetings.tsx.tmpl +13 -0
- package/stacks/outreach/pages/sequences.tsx.tmpl +18 -0
- package/stacks/outreach/pages/tasks.tsx.tmpl +19 -0
- package/stacks/outreach.json +38 -9
- package/stacks/pipedrive/pages/companies.tsx.tmpl +11 -35
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +17 -35
- package/stacks/pipedrive/pages/deals.tsx.tmpl +10 -1
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +12 -1
- package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
- package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
- package/stacks/pipedrive.json +14 -2
- package/stacks/salesforce/pages/companies.tsx.tmpl +16 -35
- package/stacks/salesforce/pages/contacts.tsx.tmpl +26 -35
- package/stacks/salesforce/pages/deals.tsx.tmpl +14 -1
- package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
- package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
- package/stacks/salesforce.json +17 -3
- package/stacks/zoominfo/pages/companies.tsx.tmpl +17 -1
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +16 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +24 -0
- package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +36 -3
- package/styles/styles.css +1 -1
- package/templates/auth/auth-route.ts.tmpl +16 -0
- package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
- package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
- package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
- package/templates/auth/g8.auth.password.ts.tmpl +18 -0
- package/templates/auth/g8.auth.session.ts.tmpl +23 -0
- package/templates/auth/middleware.ts.tmpl +14 -0
- package/templates/next-app/README.md.tmpl +5 -0
- package/templates/next-app/app/globals.css.tmpl +349 -64
- package/templates/next-app/components/app-shell.tsx.tmpl +6 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MatchedPosts: the posts your listeners matched, with filters, paging and triage.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. searchMatchedPosts (POST /social-listeners/posts/search) read a database read of what the worker stored; paged, 100 a page
|
|
8
|
+
* 2. markPost (POST /social-listeners/posts/{post_id}/mark) billable tier only: ConfirmSpendDialog states 0 credits (graph8 documents it as graph-only)
|
|
9
|
+
* 3. savePostAuthorAsContact (POST /social-listeners/posts/{post_id}/save-contact) billable tier: creates a contact, no enrichment, no credits; confirmed
|
|
10
|
+
* 4. listSequences (GET /sequences) read live sequences, paged, when "Add to sequence" opens
|
|
11
|
+
* 5. addPostAuthorToSequence (POST /social-listeners/posts/{post_id}/add-to-sequence) external asks the sequencer to enrol a real person: ConfirmSpendDialog
|
|
12
|
+
* A post is identified by its platform AND its id (the same id means different posts on different channels). Counts the
|
|
13
|
+
* channel did not report are dashes. The confirm for "add to sequence" says the API only accepts the request.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { Check, ExternalLink, ListPlus, MessageSquare, UserPlus, X } from "lucide-react";
|
|
18
|
+
import { Button, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Skeleton } from "@graph8/sdk/ui";
|
|
19
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
20
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
21
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
22
|
+
import { LoadMore } from "@/g8/components/paging-notices";
|
|
23
|
+
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
24
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
25
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
26
|
+
import { byPage } from "@/g8/lib/paging";
|
|
27
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
28
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
29
|
+
import { useLoadMoreList } from "@/g8/hooks/use-load-more-list";
|
|
30
|
+
import { PAGE_SIZE, POST_STATUSES, PLATFORMS, fmt, isLiveSequence, itemsOf, morePages, platformLabel, when } from "../lib/signals-types";
|
|
31
|
+
import type { Listener, MatchedPost, SequenceRow } from "../lib/signals-types";
|
|
32
|
+
import { PostEngagements } from "./post-engagements";
|
|
33
|
+
|
|
34
|
+
export interface MatchedPostsProps {
|
|
35
|
+
/** Fix the listener filter (a listener's own matches). */
|
|
36
|
+
listenerId?: string;
|
|
37
|
+
/** Listeners for the filter box; omit to hide it. */
|
|
38
|
+
listeners?: Listener[];
|
|
39
|
+
/** Link to the contact behind an author (for example to your contact page). */
|
|
40
|
+
renderContact?: (post: MatchedPost) => React.ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
|
|
44
|
+
const keyOf = (p: Pick<MatchedPost, "platform" | "post_external_id">): string => `${p.platform}:${p.post_external_id}`;
|
|
45
|
+
type Action = { kind: "mark"; post: MatchedPost; status: "reviewed" | "dismissed" } | { kind: "contact"; post: MatchedPost } | { kind: "sequence"; post: MatchedPost; sequence: SequenceRow };
|
|
46
|
+
|
|
47
|
+
/** Live sequences, paged through (the API answers a page at a time). */
|
|
48
|
+
async function liveSequences(signal: AbortSignal): Promise<SequenceRow[]> {
|
|
49
|
+
const out: SequenceRow[] = [];
|
|
50
|
+
for (let page = 1; page <= 50; page++) {
|
|
51
|
+
const env = await g8Get<SequenceRow[]>("/sequences", { page, limit: 100 }, { signal });
|
|
52
|
+
const rows = itemsOf<SequenceRow>(env.data);
|
|
53
|
+
out.push(...rows.filter(isLiveSequence));
|
|
54
|
+
if (!env.pagination?.has_next || rows.length === 0) break;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function MatchedPosts({ listenerId, listeners, renderContact }: MatchedPostsProps) {
|
|
60
|
+
const [status, setStatus] = useState("");
|
|
61
|
+
const [platform, setPlatform] = useState("");
|
|
62
|
+
const [listener, setListener] = useState("");
|
|
63
|
+
const [withContact, setWithContact] = useState(false);
|
|
64
|
+
const [hideLow, setHideLow] = useState(false);
|
|
65
|
+
const [since, setSince] = useState("");
|
|
66
|
+
const [until, setUntil] = useState("");
|
|
67
|
+
const [engagersOf, setEngagersOf] = useState<MatchedPost | null>(null);
|
|
68
|
+
const [picking, setPicking] = useState<MatchedPost | null>(null);
|
|
69
|
+
const [sequenceId, setSequenceId] = useState("");
|
|
70
|
+
const [action, setAction] = useState<Action | null>(null);
|
|
71
|
+
const [done, setDone] = useState<string | null>(null);
|
|
72
|
+
const effectiveListener = listenerId ?? listener;
|
|
73
|
+
|
|
74
|
+
const list = useLoadMoreList<MatchedPost>(byPage(PAGE_SIZE, async (page, signal) => {
|
|
75
|
+
const body: Record<string, unknown> = { page, limit: PAGE_SIZE };
|
|
76
|
+
if (status) body.status = status;
|
|
77
|
+
if (platform) body.platform = platform;
|
|
78
|
+
if (effectiveListener) body.listener_id = effectiveListener;
|
|
79
|
+
if (withContact) body.has_contact = true;
|
|
80
|
+
if (hideLow) body.hide_low_confidence = true;
|
|
81
|
+
// Local midnight, not UTC: a date picked in the browser means that day where the person is.
|
|
82
|
+
if (since) body.since = new Date(`${since}T00:00:00`).toISOString();
|
|
83
|
+
if (until) body.until = new Date(`${until}T23:59:59.999`).toISOString();
|
|
84
|
+
const env = await g8Post<unknown>("/social-listeners/posts/search", body, { signal });
|
|
85
|
+
const rows = itemsOf<MatchedPost>(env.data);
|
|
86
|
+
return { rows, hasNext: morePages(env.data, rows.length, page, PAGE_SIZE) };
|
|
87
|
+
}), [status, platform, effectiveListener, withContact, hideLow, since, until], { key: ["signals", "posts", { status, platform, effectiveListener, withContact, hideLow, since, until }] });
|
|
88
|
+
|
|
89
|
+
const sequences = useG8Query(liveSequences, [], { enabled: picking !== null, key: ["signals", "sequences", "live"] });
|
|
90
|
+
const run = useG8Mutation(async (a: Action): Promise<void> => {
|
|
91
|
+
const path = `/social-listeners/posts/${encodeURIComponent(a.post.post_external_id)}`;
|
|
92
|
+
const query = { platform: a.post.platform };
|
|
93
|
+
if (a.kind === "mark") await g8Post(`${path}/mark`, { status: a.status }, { query });
|
|
94
|
+
else if (a.kind === "contact") await g8Post(`${path}/save-contact`, undefined, { query });
|
|
95
|
+
else await g8Post(`${path}/add-to-sequence`, { sequence_id: a.sequence.id, platform: a.post.platform });
|
|
96
|
+
}, {
|
|
97
|
+
onSuccess: (_r, a) => {
|
|
98
|
+
setAction(null);
|
|
99
|
+
setDone(a.kind === "mark" ? `Marked ${a.status}.` : a.kind === "contact" ? "Saved as a contact." : `Requested enrolment in ${a.sequence.name ?? "the sequence"}. The sequencer enrols the person afterwards.`);
|
|
100
|
+
void list.refetch();
|
|
101
|
+
},
|
|
102
|
+
invalidates: [["signals", "stats"]],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const posts = list.data ?? [];
|
|
106
|
+
const confirmCopy = (a: Action | null): { title: string; tier: "billable" | "external"; body: string; label: string; credits?: number } => {
|
|
107
|
+
if (!a) return { title: "", tier: "billable", body: "", label: "Confirm" };
|
|
108
|
+
const who = a.post.author_name || a.post.author_handle || "this author";
|
|
109
|
+
if (a.kind === "mark") return { title: `Mark this post from ${who} as ${a.status}?`, tier: "billable", credits: 0, label: a.status === "reviewed" ? "Mark reviewed" : "Dismiss", body: "Only the post's triage state changes. Nothing is sent and no credits are spent." };
|
|
110
|
+
if (a.kind === "contact") return { title: `Save ${who} as a contact?`, tier: "billable", credits: 0, label: "Save contact", body: "A contact is created from what the post already carries, or the existing one is linked. Nothing is enriched and no credits are spent." };
|
|
111
|
+
return { title: `Enrol ${who} in ${a.sequence.name ?? "this sequence"}?`, tier: "external", label: "Request enrolment", body: "The author is resolved to a contact and an enrolment request is queued. The sequencer then starts sending to a real person; a success here means the request was accepted, not that they are enrolled." };
|
|
112
|
+
};
|
|
113
|
+
const copy = confirmCopy(action);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="grid gap-3">
|
|
117
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
118
|
+
<select aria-label="Filter by status" className={SELECT} value={status} onChange={(e) => setStatus(e.target.value)}><option value="">Any status</option>{POST_STATUSES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}</select>
|
|
119
|
+
<select aria-label="Filter by channel" className={SELECT} value={platform} onChange={(e) => setPlatform(e.target.value)}><option value="">All channels</option>{PLATFORMS.map((p) => <option key={p.value} value={p.value}>{p.label}</option>)}</select>
|
|
120
|
+
{listeners && !listenerId ? <select aria-label="Filter by listener" className={SELECT} value={listener} onChange={(e) => setListener(e.target.value)}><option value="">All listeners</option>{listeners.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}</select> : null}
|
|
121
|
+
<input type="date" aria-label="Posted from" className={SELECT} value={since} onChange={(e) => setSince(e.target.value)} />
|
|
122
|
+
<input type="date" aria-label="Posted until" className={SELECT} value={until} onChange={(e) => setUntil(e.target.value)} />
|
|
123
|
+
<label className="flex items-center gap-1 text-sm"><input type="checkbox" checked={withContact} onChange={(e) => setWithContact(e.target.checked)} />Author is a contact</label>
|
|
124
|
+
<label className="flex items-center gap-1 text-sm"><input type="checkbox" checked={hideLow} onChange={(e) => setHideLow(e.target.checked)} />Hide low confidence</label>
|
|
125
|
+
</div>
|
|
126
|
+
{done ? <p role="status" className="text-sm">{done}</p> : null}
|
|
127
|
+
{run.error ? <ApiError error={run.error} title="The action did not go through" /> : null}
|
|
128
|
+
{list.error ? <ApiError error={list.error} title="Could not load posts" onRetry={() => void list.refetch()} /> : list.loading && !list.data ? (
|
|
129
|
+
<div aria-busy="true" className="grid gap-2"><Skeleton className="h-20 w-full" /><Skeleton className="h-20 w-full" /></div>
|
|
130
|
+
) : posts.length === 0 ? (
|
|
131
|
+
<EmptyState compact title="No matched posts" description="Posts appear here once a listener finds them. Nothing matches these filters yet." />
|
|
132
|
+
) : (
|
|
133
|
+
<ul className="grid gap-2">
|
|
134
|
+
{posts.map((p) => (
|
|
135
|
+
<li key={keyOf(p)} className="grid gap-2 rounded-md border p-3">
|
|
136
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
137
|
+
<EntityAvatar name={p.author_name || p.author_handle || "?"} imageUrl={p.author_profile_image_url} size="sm" />
|
|
138
|
+
<span className="font-medium">{p.author_name || "-"}</span>
|
|
139
|
+
<span className="text-muted-foreground text-xs">{p.author_handle ? `@${p.author_handle.replace(/^@/, "")}` : ""} · {platformLabel(p.platform)} · {fmt(p.author_followers)} followers · {when(p.created_at)}</span>
|
|
140
|
+
<StatusPill tone={p.status === "new" ? "info" : p.status === "reviewed" ? "positive" : "neutral"}>{p.status ?? "-"}</StatusPill>
|
|
141
|
+
{p.engagement_intent ? <StatusPill tone="neutral">{p.engagement_intent}</StatusPill> : null}
|
|
142
|
+
</div>
|
|
143
|
+
<p className="whitespace-pre-wrap text-sm">{p.text || "-"}</p>
|
|
144
|
+
<p className="text-muted-foreground text-xs">Likes {fmt(p.like_count)} · Replies {fmt(p.reply_count)} · Reposts {fmt(p.repost_count)} · Views {fmt(p.view_count)} · Fit {typeof p.classifier_score === "number" ? p.classifier_score.toFixed(2) : "-"}</p>
|
|
145
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
146
|
+
<Button size="sm" variant="ghost" aria-label={`Mark reviewed ${keyOf(p)}`} disabled={p.status === "reviewed"} onClick={() => setAction({ kind: "mark", post: p, status: "reviewed" })}><Check className="size-4" />Reviewed</Button>
|
|
147
|
+
<Button size="sm" variant="ghost" aria-label={`Dismiss ${keyOf(p)}`} disabled={p.status === "dismissed"} onClick={() => setAction({ kind: "mark", post: p, status: "dismissed" })}><X className="size-4" />Dismiss</Button>
|
|
148
|
+
{p.resolved_mashup_contact_id ? (renderContact?.(p) ?? <span className="text-muted-foreground text-xs">Already a contact</span>) : <Button size="sm" variant="ghost" aria-label={`Save author as contact ${keyOf(p)}`} onClick={() => setAction({ kind: "contact", post: p })}><UserPlus className="size-4" />Save as contact</Button>}
|
|
149
|
+
<Button size="sm" variant="ghost" aria-label={`Add author to sequence ${keyOf(p)}`} onClick={() => { setSequenceId(""); setPicking(p); }}><ListPlus className="size-4" />Add to sequence</Button>
|
|
150
|
+
<Button size="sm" variant="ghost" aria-label={`Engagers of ${keyOf(p)}`} onClick={() => setEngagersOf(p)}><MessageSquare className="size-4" />Engagers</Button>
|
|
151
|
+
{p.post_url && /^https?:\/\//.test(p.post_url) ? <a className="text-muted-foreground inline-flex items-center gap-1 text-xs underline" href={p.post_url} target="_blank" rel="noreferrer noopener"><ExternalLink className="size-3" />View post</a> : null}
|
|
152
|
+
</div>
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ul>
|
|
156
|
+
)}
|
|
157
|
+
<LoadMore shown={posts.length} noun="posts" hasMore={list.hasMore} loading={list.loadingMore} onLoadMore={list.loadMore} />
|
|
158
|
+
<Sheet open={picking !== null} onOpenChange={(o) => { if (!o) setPicking(null); }}>
|
|
159
|
+
<SheetContent className="w-full sm:max-w-md">
|
|
160
|
+
<SheetHeader><SheetTitle>Add to sequence</SheetTitle><SheetDescription>The author of this post is enrolled in the sequence you pick, after you confirm.</SheetDescription></SheetHeader>
|
|
161
|
+
<div className="space-y-3 p-4">
|
|
162
|
+
{sequences.error ? <ApiError error={sequences.error} title="Could not load sequences" onRetry={() => void sequences.refetch()} /> : null}
|
|
163
|
+
{sequences.loading && !sequences.data ? <Skeleton className="h-9 w-full" aria-label="Loading sequences" /> : null}
|
|
164
|
+
{sequences.data && sequences.data.length === 0 ? <EmptyState compact title="No live sequences" description="Launch a sequence first." /> : null}
|
|
165
|
+
{sequences.data && sequences.data.length > 0 ? (
|
|
166
|
+
<>
|
|
167
|
+
<label htmlFor="g8-sl-seq" className="text-sm font-medium">Sequence</label>
|
|
168
|
+
<select id="g8-sl-seq" className={`${SELECT} w-full`} value={sequenceId} onChange={(e) => setSequenceId(e.target.value)}><option value="">Choose a sequence</option>{sequences.data.map((s) => <option key={s.id} value={s.id}>{s.name || "Untitled sequence"}</option>)}</select>
|
|
169
|
+
<Button disabled={!sequenceId} onClick={() => { const s = sequences.data?.find((x) => x.id === sequenceId); if (s && picking) { setAction({ kind: "sequence", post: picking, sequence: s }); setPicking(null); } }}>Continue</Button>
|
|
170
|
+
</>
|
|
171
|
+
) : null}
|
|
172
|
+
</div>
|
|
173
|
+
</SheetContent>
|
|
174
|
+
</Sheet>
|
|
175
|
+
<Sheet open={engagersOf !== null} onOpenChange={(o) => { if (!o) setEngagersOf(null); }}>
|
|
176
|
+
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
|
|
177
|
+
<SheetHeader><SheetTitle>Engagers</SheetTitle><SheetDescription>People who commented on or reacted to this post.</SheetDescription></SheetHeader>
|
|
178
|
+
<div className="p-4">{engagersOf ? <PostEngagements key={keyOf(engagersOf)} post={engagersOf} /> : null}</div>
|
|
179
|
+
</SheetContent>
|
|
180
|
+
</Sheet>
|
|
181
|
+
<ConfirmSpendDialog
|
|
182
|
+
open={action !== null}
|
|
183
|
+
tier={copy.tier}
|
|
184
|
+
title={copy.title}
|
|
185
|
+
estimatedCredits={copy.credits}
|
|
186
|
+
recipients={action?.kind === "sequence" ? 1 : undefined}
|
|
187
|
+
description={<><p>{copy.body}</p>{run.error ? <ApiError error={run.error} title="The action did not go through" /> : null}</>}
|
|
188
|
+
confirmLabel={copy.label}
|
|
189
|
+
confirming={run.loading}
|
|
190
|
+
onCancel={() => setAction(null)}
|
|
191
|
+
onConfirm={() => { if (action) void run.mutate(action); }}
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PostEngagements: who commented on or reacted to one matched post, and a one-step way to turn them into a list.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. searchPostEngagements (POST /social-listeners/posts/{post_id}/engagements/search) read stored rows only; paged, 100 a page
|
|
8
|
+
* 2. listLists (GET /lists), through ListSelect read the existing list to add to
|
|
9
|
+
* 3. addEngagersToList (POST /social-listeners/posts/{post_id}/engagements/add-to-list) billable tier: CREATES CONTACTS for people not in graph8
|
|
10
|
+
* yet; ConfirmSpendDialog, sized from the engager count the API reported for the SAME comment/reaction filter it sends.
|
|
11
|
+
* An empty result can mean nobody engaged or that engagement fetching is off for the channel (Settings tab).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
|
|
16
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
17
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
18
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
19
|
+
import { LoadMore } from "@/g8/components/paging-notices";
|
|
20
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
21
|
+
import { ListSelect } from "@/g8/crm/components/list-select";
|
|
22
|
+
import type { ListSelectOption } from "@/g8/crm/components/list-select";
|
|
23
|
+
import { g8Post } from "@/g8/lib/g8-client";
|
|
24
|
+
import { byPage } from "@/g8/lib/paging";
|
|
25
|
+
import { plural } from "@/g8/lib/plural";
|
|
26
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
27
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
28
|
+
import { useLoadMoreList } from "@/g8/hooks/use-load-more-list";
|
|
29
|
+
import { PAGE_SIZE, itemsOf, morePages, totalOf, when } from "../lib/signals-types";
|
|
30
|
+
import type { MatchedPost, PostEngagement } from "../lib/signals-types";
|
|
31
|
+
|
|
32
|
+
export interface PostEngagementsProps {
|
|
33
|
+
post: Pick<MatchedPost, "platform" | "post_external_id">;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface AddResult { audience_id?: string; audience_name?: string; contacts_created?: number; contacts_matched?: number; skipped_no_identity?: number; total_engagers?: number }
|
|
37
|
+
const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
|
|
38
|
+
|
|
39
|
+
export function PostEngagements({ post }: PostEngagementsProps) {
|
|
40
|
+
const brand = useBrand();
|
|
41
|
+
const [type, setType] = useState("");
|
|
42
|
+
const [target, setTarget] = useState<ListSelectOption | null>(null);
|
|
43
|
+
const [newName, setNewName] = useState("");
|
|
44
|
+
// The total belongs to the filter it was read for; a different filter must not show it while its own read is in flight.
|
|
45
|
+
const [read, setRead] = useState<{ type: string; total: number | undefined }>({ type: "", total: undefined });
|
|
46
|
+
const [confirm, setConfirm] = useState(false);
|
|
47
|
+
const path = `/social-listeners/posts/${encodeURIComponent(post.post_external_id)}/engagements`;
|
|
48
|
+
|
|
49
|
+
const list = useLoadMoreList<PostEngagement>(byPage(PAGE_SIZE, async (page, signal) => {
|
|
50
|
+
const body: Record<string, unknown> = { platform: post.platform, page, limit: PAGE_SIZE };
|
|
51
|
+
if (type) body.engagement_type = type;
|
|
52
|
+
const env = await g8Post<unknown>(`${path}/search`, body, { signal });
|
|
53
|
+
const rows = itemsOf<PostEngagement>(env.data);
|
|
54
|
+
setRead({ type, total: totalOf(env.data) });
|
|
55
|
+
return { rows, hasNext: morePages(env.data, rows.length, page, PAGE_SIZE) };
|
|
56
|
+
}), [post.platform, post.post_external_id, type], { key: ["signals", "engagements", post.platform, post.post_external_id, type] });
|
|
57
|
+
|
|
58
|
+
const add = useG8Mutation(async (): Promise<AddResult> => {
|
|
59
|
+
const body: Record<string, unknown> = { platform: post.platform };
|
|
60
|
+
if (type) body.engagement_type = type;
|
|
61
|
+
if (target) body.audience_id = String(target.id);
|
|
62
|
+
else body.audience_name = newName.trim();
|
|
63
|
+
return (await g8Post<AddResult>(`${path}/add-to-list`, body)).data;
|
|
64
|
+
}, { onSuccess: () => setConfirm(false), invalidates: [["signals", "stats"], ["lists"]] });
|
|
65
|
+
|
|
66
|
+
const rows = list.data ?? [];
|
|
67
|
+
const ready = rows.length > 0 && !list.loading && (target !== null || newName.trim() !== "");
|
|
68
|
+
const total = read.type === type ? read.total : undefined;
|
|
69
|
+
const size = total ?? rows.length;
|
|
70
|
+
const r = add.data;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="grid gap-3">
|
|
74
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
75
|
+
<select aria-label="Engagement type" className={SELECT} value={type} onChange={(e) => setType(e.target.value)}>
|
|
76
|
+
<option value="">Comments and reactions</option><option value="comment">Comments</option><option value="reaction">Reactions</option>
|
|
77
|
+
</select>
|
|
78
|
+
<span className="text-muted-foreground text-xs" role="status">{list.data ? `${size.toLocaleString()} ${size === 1 ? "engager" : "engagers"}` : ""}</span>
|
|
79
|
+
</div>
|
|
80
|
+
{list.error ? <ApiError error={list.error} title="Could not load engagers" onRetry={() => void list.refetch()} /> : list.loading && !list.data ? <Skeleton className="h-16 w-full" aria-label="Loading engagers" /> : rows.length === 0 ? (
|
|
81
|
+
<EmptyState compact title="No engagers stored" description="Nobody has engaged yet, or engagement fetching is off for this channel (see Settings)." />
|
|
82
|
+
) : (
|
|
83
|
+
<ul className="grid gap-2">
|
|
84
|
+
{rows.map((e) => (
|
|
85
|
+
<li key={e.engagement_external_id} className="grid gap-1 rounded-md border p-2 text-sm">
|
|
86
|
+
<span className="flex flex-wrap items-center gap-2"><span className="font-medium">{e.author_name || e.author_handle || "-"}</span><StatusPill tone="neutral">{e.engagement_type}</StatusPill><span className="text-muted-foreground text-xs">{e.author_headline || "-"} · {when(e.created_at)}</span></span>
|
|
87
|
+
{e.text ? <span className="text-muted-foreground">{e.text}</span> : null}
|
|
88
|
+
</li>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
)}
|
|
92
|
+
<LoadMore shown={rows.length} noun="engagers" hasMore={list.hasMore} loading={list.loadingMore} onLoadMore={list.loadMore} />
|
|
93
|
+
<div className="grid gap-2 rounded-md border p-3">
|
|
94
|
+
<p className="text-sm font-medium">Add these people to a list</p>
|
|
95
|
+
<div><Label htmlFor="g8-sl-eng-list" className="mb-1">Existing list</Label><ListSelect id="g8-sl-eng-list" label="Existing list" emptyOption="Create a new list instead" value={target?.id ?? ""} valueLabel={target?.title} selectClassName={`${SELECT} w-full`} onChange={setTarget} /></div>
|
|
96
|
+
{target === null ? <div><Label htmlFor="g8-sl-eng-name" className="mb-1">New list name</Label><Input id="g8-sl-eng-name" maxLength={255} value={newName} onChange={(e) => setNewName(e.target.value)} /></div> : null}
|
|
97
|
+
<div><Button disabled={!ready} onClick={() => setConfirm(true)}>Add to list</Button></div>
|
|
98
|
+
{r ? <p role="status" className="text-sm">Added to {r.audience_name ?? "the list"}: {r.contacts_created ?? 0} new contacts, {r.contacts_matched ?? 0} matched, {r.skipped_no_identity ?? 0} skipped for no identity.</p> : null}
|
|
99
|
+
</div>
|
|
100
|
+
<ConfirmSpendDialog
|
|
101
|
+
open={confirm}
|
|
102
|
+
tier="billable"
|
|
103
|
+
title={`Add ${plural(size, "engager")} to ${target?.title ?? (newName.trim() || "a new list")}?`}
|
|
104
|
+
estimatedCredits={0}
|
|
105
|
+
affected={size}
|
|
106
|
+
description={<><p>People with no {brand.linkLabel} contact yet are created as contacts, so this grows your contact database as well as the list. No enrichment runs and no credits are spent.</p>{add.error ? <ApiError error={add.error} title="Could not add the engagers" /> : null}</>}
|
|
107
|
+
confirmLabel="Add to list"
|
|
108
|
+
confirming={add.loading}
|
|
109
|
+
onCancel={() => setConfirm(false)}
|
|
110
|
+
onConfirm={() => void add.mutate(undefined)}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RecordSocialTab: the posts one contact has made that a listener matched. Drop it into ContactsScreen's `extraDetailTabs`.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations: listPostsByContact (GET /social-listeners/posts/by-contact/{contact_id}) read. One read of up to 200
|
|
7
|
+
* posts (the API's cap); a full read says it shows the first 200. Nothing is written here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ExternalLink } from "lucide-react";
|
|
11
|
+
import { Skeleton } from "@graph8/sdk/ui";
|
|
12
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
13
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
14
|
+
import { FirstNotice } from "@/g8/components/paging-notices";
|
|
15
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
16
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
17
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
18
|
+
import { fmt, itemsOf, platformLabel, when } from "../lib/signals-types";
|
|
19
|
+
import type { MatchedPost } from "../lib/signals-types";
|
|
20
|
+
|
|
21
|
+
const LIMIT = 200;
|
|
22
|
+
|
|
23
|
+
export function RecordSocialTab({ contactId, onOpenListeners }: { contactId: number | string; onOpenListeners?: () => void }) {
|
|
24
|
+
const q = useG8Query(
|
|
25
|
+
(signal) => g8Get<unknown>(`/social-listeners/posts/by-contact/${encodeURIComponent(String(contactId))}`, { limit: LIMIT }, { signal }).then((r) => itemsOf<MatchedPost>(r.data)),
|
|
26
|
+
[contactId],
|
|
27
|
+
{ key: ["signals", "by-contact", String(contactId)] },
|
|
28
|
+
);
|
|
29
|
+
if (q.error) return <ApiError error={q.error} title="Could not load this person's posts" onRetry={() => void q.refetch()} />;
|
|
30
|
+
if (q.loading && !q.data) return <Skeleton className="h-24 w-full" aria-label="Loading posts" />;
|
|
31
|
+
const rows = q.data ?? [];
|
|
32
|
+
if (rows.length === 0) {
|
|
33
|
+
return <EmptyState compact title="No matched posts" description="No listener has matched a post from this person yet." action={onOpenListeners ? <button type="button" className="text-sm underline" onClick={onOpenListeners}>Open social listening</button> : undefined} />;
|
|
34
|
+
}
|
|
35
|
+
return (
|
|
36
|
+
<div className="grid gap-2">
|
|
37
|
+
<ul className="grid gap-2">
|
|
38
|
+
{rows.map((p) => (
|
|
39
|
+
<li key={`${p.platform}:${p.post_external_id}`} className="grid gap-1 rounded-md border p-3 text-sm">
|
|
40
|
+
<span className="flex flex-wrap items-center gap-2"><span className="font-medium">{platformLabel(p.platform)}</span><span className="text-muted-foreground text-xs">{when(p.created_at)}</span><StatusPill tone={p.status === "new" ? "info" : "neutral"}>{p.status ?? "-"}</StatusPill></span>
|
|
41
|
+
<span className="whitespace-pre-wrap">{p.text || "-"}</span>
|
|
42
|
+
<span className="text-muted-foreground text-xs">Likes {fmt(p.like_count)} · Replies {fmt(p.reply_count)} · Views {fmt(p.view_count)}{p.post_url && /^https?:\/\//.test(p.post_url) ? <a className="ml-2 inline-flex items-center gap-1 underline" href={p.post_url} target="_blank" rel="noreferrer noopener"><ExternalLink className="size-3" />View post</a> : null}</span>
|
|
43
|
+
</li>
|
|
44
|
+
))}
|
|
45
|
+
</ul>
|
|
46
|
+
<FirstNotice shown={rows.length} limit={LIMIT} noun="posts" />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SocialListenersScreen: the finished social listening workspace. No props needed.
|
|
5
|
+
*
|
|
6
|
+
* Composes: ScreenLayout (title, tabs, stats), Toolbar (search, type, channel and state filters), SplitView + DetailPanel
|
|
7
|
+
* (the selected listener, deep link `?id=`), FormSheet (new listener via ListenerForm), MatchedPosts, PostEngagements (inside
|
|
8
|
+
* MatchedPosts), TopEngagers, SourceLists, AutoSeedPanel, SocialSettingsPanel and XIntegration.
|
|
9
|
+
*
|
|
10
|
+
* Tabs (`?tab=`): Listeners (list and detail), Posts, Top engagers, Sources, Settings. A listener's detail (`?id=`) has
|
|
11
|
+
* Overview, Matches and Edit; `&view=full` shows it as a page.
|
|
12
|
+
*
|
|
13
|
+
* User flows: see every listener with its state and channel; search and filter; create one (with a cost estimate and an
|
|
14
|
+
* optional generated query); open one to see its matches, credit burn and settings; pause and resume it (confirmed); edit
|
|
15
|
+
* only what changed; delete it (confirmed, pausing keeps the posts); triage matched posts, save an author as a contact, add
|
|
16
|
+
* them to a sequence (confirmed); turn a post's engagers into a list; see the accounts that engage most; watch every
|
|
17
|
+
* profile on a list; seed starter listeners (each confirmed with its credits); change the org-wide spend limits and
|
|
18
|
+
* connect or remove your own X app (each confirmed).
|
|
19
|
+
*
|
|
20
|
+
* SDK operations: listListeners, getSocialStats (reads), updateListener (pause and resume, billable tier: confirmed),
|
|
21
|
+
* deleteListener (destructive: confirmed), getListener (read). Everything else lives in the composed parts.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useState } from "react";
|
|
25
|
+
import type { ReactNode } from "react";
|
|
26
|
+
import { Ear, Pause, Play, Plus, Trash2 } from "lucide-react";
|
|
27
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
28
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
29
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
30
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
31
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
32
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
33
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
34
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
35
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
36
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
37
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
38
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
39
|
+
import { g8Delete, g8Get, g8Patch } from "@/g8/lib/g8-client";
|
|
40
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
41
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
42
|
+
import { plural } from "@/g8/lib/plural";
|
|
43
|
+
import { LISTENER_TYPES, PLATFORMS, cadence, capText, fmt, itemsOf, platformLabel, statusOf, typeLabel, when } from "../lib/signals-types";
|
|
44
|
+
import type { Listener, ListenerDetailData, MatchedPost, SocialStats } from "../lib/signals-types";
|
|
45
|
+
import { AutoSeedPanel } from "./auto-seed-panel";
|
|
46
|
+
import { ListenerForm } from "./listener-form";
|
|
47
|
+
import { MatchedPosts } from "./matched-posts";
|
|
48
|
+
import { SocialSettingsPanel } from "./social-settings";
|
|
49
|
+
import { SourceLists } from "./source-lists";
|
|
50
|
+
import { TopEngagers } from "./top-engagers";
|
|
51
|
+
import { XIntegration } from "./x-integration";
|
|
52
|
+
|
|
53
|
+
export interface SocialListenersScreenProps {
|
|
54
|
+
renderRowActions?: (listener: Listener) => ReactNode;
|
|
55
|
+
renderDetailActions?: (listener: Listener) => ReactNode;
|
|
56
|
+
/** Render a link to the contact behind a matched post's author. */
|
|
57
|
+
renderContact?: (post: MatchedPost) => ReactNode;
|
|
58
|
+
extraDetailTabs?: { id: string; label: string; render: (listener: Listener) => ReactNode }[];
|
|
59
|
+
toolbarActions?: ReactNode;
|
|
60
|
+
className?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type Tab = "listeners" | "posts" | "engagers" | "sources" | "settings";
|
|
64
|
+
const TABS: Tab[] = ["listeners", "posts", "engagers", "sources", "settings"];
|
|
65
|
+
const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
|
|
66
|
+
|
|
67
|
+
export function SocialListenersScreen({ renderRowActions, renderDetailActions, renderContact, extraDetailTabs, toolbarActions, className }: SocialListenersScreenProps) {
|
|
68
|
+
const [id, setId] = useUrlState("id", "");
|
|
69
|
+
const [view, setView] = useUrlState("view", "");
|
|
70
|
+
const [tabParam, setTabParam] = useUrlState("tab", "listeners");
|
|
71
|
+
const tab = (TABS.includes(tabParam as Tab) ? tabParam : "listeners") as Tab;
|
|
72
|
+
const [search, setSearch] = useState("");
|
|
73
|
+
const [typeFilter, setTypeFilter] = useState("");
|
|
74
|
+
const [platformFilter, setPlatformFilter] = useState("");
|
|
75
|
+
const [stateFilter, setStateFilter] = useState("");
|
|
76
|
+
const [creating, setCreating] = useState(false);
|
|
77
|
+
const [toDelete, setToDelete] = useState<Listener | null>(null);
|
|
78
|
+
const [toToggle, setToToggle] = useState<Listener | null>(null);
|
|
79
|
+
|
|
80
|
+
const list = useG8Query((signal) => g8Get<unknown>("/social-listeners", {}, { signal }).then((r) => itemsOf<Listener>(r.data)), [], { key: ["signals", "listeners"] });
|
|
81
|
+
const stats = useG8Query((signal) => g8Get<SocialStats>("/social-listeners/stats", {}, { signal }).then((r) => r.data), [], { key: ["signals", "stats"] });
|
|
82
|
+
const toggle = useG8Mutation((l: Listener) => g8Patch<Listener>(`/social-listeners/${encodeURIComponent(l.id)}`, { is_active: !l.is_active }), {
|
|
83
|
+
onSuccess: () => setToToggle(null),
|
|
84
|
+
invalidates: (_r, l) => [["signals", "listeners"], ["signals", "listener", l.id], ["signals", "stats"]],
|
|
85
|
+
});
|
|
86
|
+
const remove = useG8Mutation((l: Listener) => g8Delete(`/social-listeners/${encodeURIComponent(l.id)}`), {
|
|
87
|
+
onSuccess: (_r, l) => { setToDelete(null); if (l.id === id) closeDetail(); },
|
|
88
|
+
invalidates: [["signals"]],
|
|
89
|
+
});
|
|
90
|
+
function closeDetail(): void { setId(null); setView(null); }
|
|
91
|
+
|
|
92
|
+
const all = list.data ?? [];
|
|
93
|
+
const q = search.trim().toLowerCase();
|
|
94
|
+
const shown = all.filter((l) => (!typeFilter || l.listener_type === typeFilter) && (!platformFilter || l.platform === platformFilter) && (!stateFilter || statusOf(l) === stateFilter) && (!q || `${l.name} ${l.query ?? ""} ${l.target_handle ?? ""}`.toLowerCase().includes(q)));
|
|
95
|
+
const selected = all.find((l) => l.id === id);
|
|
96
|
+
const expandProps = { onExpand: () => setView("full"), onCollapse: view === "full" ? () => setView(null) : undefined };
|
|
97
|
+
|
|
98
|
+
const rows = list.error ? <ApiError error={list.error} title="Could not load listeners" onRetry={() => void list.refetch()} /> : list.loading && !list.data ? (
|
|
99
|
+
<div aria-busy="true" className="grid gap-2"><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /></div>
|
|
100
|
+
) : shown.length === 0 ? (
|
|
101
|
+
<EmptyState icon={<Ear className="size-8" />} title={all.length === 0 ? "No listeners yet" : "No listeners match"} description={all.length === 0 ? "A listener watches X, LinkedIn, Reddit, Instagram or YouTube for posts about your brand, competitors or keywords. Create one, or seed starters from the Sources tab." : "Try another search or filter."} action={all.length === 0 ? <Button onClick={() => setCreating(true)}>Create your first listener</Button> : undefined} />
|
|
102
|
+
) : (
|
|
103
|
+
<>
|
|
104
|
+
<p role="status" className="text-muted-foreground mb-2 text-xs">{shown.length === all.length ? `All ${plural(all.length, "listener")}` : `${shown.length.toLocaleString()} of ${plural(all.length, "listener")}`}</p>
|
|
105
|
+
<ul className="grid gap-2">
|
|
106
|
+
{shown.map((l) => (
|
|
107
|
+
<li key={l.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 ${l.id === id ? "bg-muted" : ""}`}>
|
|
108
|
+
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setId(l.id)}>
|
|
109
|
+
<span className="block truncate font-medium">{l.name}</span>
|
|
110
|
+
<span className="text-muted-foreground block truncate text-xs">{l.listener_type === "user" ? (l.target_handle ?? "-") : (l.query ?? "-")} · {platformLabel(l.platform)}</span>
|
|
111
|
+
</button>
|
|
112
|
+
<StatusPill tone="info">{typeLabel(l.listener_type)}</StatusPill>
|
|
113
|
+
<StatusPill tone={l.is_active ? "positive" : "neutral"}>{statusOf(l)}</StatusPill>
|
|
114
|
+
<div className="flex gap-1">
|
|
115
|
+
<Button size="sm" variant="ghost" aria-label={`${l.is_active ? "Pause" : "Resume"} ${l.name}`} onClick={() => setToToggle(l)}>{l.is_active ? <Pause className="size-4" /> : <Play className="size-4" />}</Button>
|
|
116
|
+
<Button size="sm" variant="ghost" aria-label={`Delete ${l.name}`} onClick={() => setToDelete(l)}><Trash2 className="size-4" /></Button>
|
|
117
|
+
{renderRowActions?.(l)}
|
|
118
|
+
</div>
|
|
119
|
+
</li>
|
|
120
|
+
))}
|
|
121
|
+
</ul>
|
|
122
|
+
</>
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const detail = selected ? (
|
|
126
|
+
<DetailPanel
|
|
127
|
+
title={selected.name}
|
|
128
|
+
subtitle={selected.listener_type === "user" ? selected.target_handle ?? undefined : selected.query ?? undefined}
|
|
129
|
+
badges={<><StatusPill tone="info">{typeLabel(selected.listener_type)}</StatusPill><StatusPill tone={selected.is_active ? "positive" : "neutral"}>{statusOf(selected)}</StatusPill></>}
|
|
130
|
+
actions={<>{renderDetailActions?.(selected)}<Button size="sm" variant="outline" onClick={() => setToToggle(selected)}>{selected.is_active ? <Pause className="size-4" /> : <Play className="size-4" />}{selected.is_active ? "Pause" : "Resume"}</Button></>}
|
|
131
|
+
onClose={closeDetail}
|
|
132
|
+
{...expandProps}
|
|
133
|
+
tabs={[
|
|
134
|
+
{ id: "overview", label: "Overview", content: <ListenerOverview key={selected.id} listener={selected} /> },
|
|
135
|
+
{ id: "matches", label: "Matches", content: <MatchedPosts key={selected.id} listenerId={selected.id} renderContact={renderContact} /> },
|
|
136
|
+
{ id: "edit", label: "Edit", content: <ListenerForm key={selected.id} listener={selected} onSaved={() => void list.refetch()} /> },
|
|
137
|
+
...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
|
|
138
|
+
]}
|
|
139
|
+
/>
|
|
140
|
+
) : null;
|
|
141
|
+
|
|
142
|
+
const s = stats.data;
|
|
143
|
+
const listenersTab = (
|
|
144
|
+
<SplitView
|
|
145
|
+
list={(
|
|
146
|
+
<div className="grid gap-3">
|
|
147
|
+
<Toolbar
|
|
148
|
+
search={{ value: search, onChange: setSearch, placeholder: "Search listeners" }}
|
|
149
|
+
filters={(
|
|
150
|
+
<>
|
|
151
|
+
<select aria-label="Filter by type" className={SELECT} value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}><option value="">All types</option>{LISTENER_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}</select>
|
|
152
|
+
<select aria-label="Filter by channel" className={SELECT} value={platformFilter} onChange={(e) => setPlatformFilter(e.target.value)}><option value="">All channels</option>{PLATFORMS.map((p) => <option key={p.value} value={p.value}>{p.label}</option>)}</select>
|
|
153
|
+
<select aria-label="Filter by state" className={SELECT} value={stateFilter} onChange={(e) => setStateFilter(e.target.value)}><option value="">Active and paused</option><option value="active">Active</option><option value="paused">Paused</option></select>
|
|
154
|
+
</>
|
|
155
|
+
)}
|
|
156
|
+
actions={toolbarActions}
|
|
157
|
+
/>
|
|
158
|
+
{rows}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
detail={detail}
|
|
162
|
+
detailOpen={Boolean(selected)}
|
|
163
|
+
onCloseDetail={closeDetail}
|
|
164
|
+
expanded={view === "full"}
|
|
165
|
+
onExpandedChange={(e) => setView(e ? "full" : null)}
|
|
166
|
+
detailWidth="xl"
|
|
167
|
+
/>
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<ScreenLayout
|
|
172
|
+
className={className}
|
|
173
|
+
title="Social listening"
|
|
174
|
+
description="Watch X, LinkedIn, Reddit, Instagram and YouTube for people talking about you, your competitors and your keywords."
|
|
175
|
+
icon={<Ear className="size-5" />}
|
|
176
|
+
actions={<Button size="sm" onClick={() => setCreating(true)}><Plus className="size-4" />New listener</Button>}
|
|
177
|
+
tabs={[{ id: "listeners", label: "Listeners", count: list.data ? all.length : undefined }, { id: "posts", label: "Posts" }, { id: "engagers", label: "Top engagers" }, { id: "sources", label: "Sources" }, { id: "settings", label: "Settings" }]}
|
|
178
|
+
activeTab={tab}
|
|
179
|
+
onTabChange={(next) => { setTabParam(next === "listeners" ? null : next); closeDetail(); }}
|
|
180
|
+
statsLoading={stats.loading && !stats.data}
|
|
181
|
+
stats={[
|
|
182
|
+
{ label: "Active listeners", value: fmt(s?.listeners_active) },
|
|
183
|
+
{ label: "Paused", value: fmt(s?.listeners_paused) },
|
|
184
|
+
{ label: "Posts matched", value: fmt(s?.posts_matched), hint: `${fmt(s?.posts_reviewed)} reviewed, ${fmt(s?.posts_dismissed)} dismissed` },
|
|
185
|
+
{ label: "Contacts resolved", value: fmt(s?.contacts_resolved) },
|
|
186
|
+
{ label: "Credits this month", value: fmt(s?.monthly_credit_burn) },
|
|
187
|
+
{ label: "X spend", value: typeof s?.x_api_spend_usd === "number" ? `$${s.x_api_spend_usd.toFixed(2)}` : "-" },
|
|
188
|
+
]}
|
|
189
|
+
>
|
|
190
|
+
{stats.error ? <ApiError compact error={stats.error} title="Could not load the totals" onRetry={() => void stats.refetch()} /> : null}
|
|
191
|
+
{tab === "listeners" ? listenersTab : tab === "posts" ? <MatchedPosts listeners={all} renderContact={renderContact} /> : tab === "engagers" ? <TopEngagers /> : tab === "sources" ? (
|
|
192
|
+
<div className="grid gap-4 lg:grid-cols-2"><SourceLists /><AutoSeedPanel /></div>
|
|
193
|
+
) : (
|
|
194
|
+
<div className="grid gap-6"><SocialSettingsPanel /><XIntegration /></div>
|
|
195
|
+
)}
|
|
196
|
+
<FormSheet open={creating} onOpenChange={setCreating} title="New listener" description="Nothing is created until you confirm.">
|
|
197
|
+
{creating ? <ListenerForm onCancel={() => setCreating(false)} onSaved={(created) => { setCreating(false); setTabParam(null); void list.refetch(); setId(created.id); }} /> : null}
|
|
198
|
+
</FormSheet>
|
|
199
|
+
<ConfirmSpendDialog
|
|
200
|
+
open={toToggle !== null}
|
|
201
|
+
tier="billable"
|
|
202
|
+
title={`${toToggle?.is_active ? "Pause" : "Resume"} ${toToggle?.name ?? "this listener"}?`}
|
|
203
|
+
estimatedCredits={toToggle?.is_active ? 0 : undefined}
|
|
204
|
+
description={<><p>{toToggle?.is_active ? "Polling stops and its matched posts are kept. No credits are spent while it is paused." : `Polling restarts. Credits are spent as posts are read, ${capText(toToggle?.monthly_credit_cap)}.`}</p>{toggle.error ? <ApiError error={toggle.error} title="Could not change the listener" /> : null}</>}
|
|
205
|
+
confirmLabel={toToggle?.is_active ? "Pause" : "Resume"}
|
|
206
|
+
confirming={toggle.loading}
|
|
207
|
+
onCancel={() => setToToggle(null)}
|
|
208
|
+
onConfirm={() => { if (toToggle) void toggle.mutate(toToggle); }}
|
|
209
|
+
/>
|
|
210
|
+
<ConfirmSpendDialog
|
|
211
|
+
open={toDelete !== null}
|
|
212
|
+
tier="destructive"
|
|
213
|
+
title={`Delete ${toDelete?.name ?? "this listener"}?`}
|
|
214
|
+
affected={1}
|
|
215
|
+
description={<><p>The listener stops for good. To stop the spend but keep the matched posts, pause it instead.</p>{remove.error ? <ApiError error={remove.error} title="Could not delete the listener" /> : null}</>}
|
|
216
|
+
confirmLabel="Delete"
|
|
217
|
+
confirming={remove.loading}
|
|
218
|
+
onCancel={() => setToDelete(null)}
|
|
219
|
+
onConfirm={() => { if (toDelete) void remove.mutate(toDelete); }}
|
|
220
|
+
/>
|
|
221
|
+
</ScreenLayout>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function ListenerOverview({ listener }: { listener: Listener }) {
|
|
226
|
+
const full = useG8Query((signal) => g8Get<ListenerDetailData>(`/social-listeners/${encodeURIComponent(listener.id)}`, {}, { signal }).then((r) => r.data), [listener.id], { key: ["signals", "listener-detail", listener.id] });
|
|
227
|
+
const l = full.data?.listener ?? listener;
|
|
228
|
+
return (
|
|
229
|
+
<div className="grid gap-3">
|
|
230
|
+
{full.error ? <ApiError compact error={full.error} title="Could not load the totals" onRetry={() => void full.refetch()} /> : null}
|
|
231
|
+
<KeyValueList
|
|
232
|
+
columns={2}
|
|
233
|
+
items={[
|
|
234
|
+
{ label: "Type", value: typeLabel(l.listener_type) },
|
|
235
|
+
{ label: "Channel", value: platformLabel(l.platform) },
|
|
236
|
+
{ label: "Matches so far", value: full.data ? fmt(full.data.match_count) : "..." },
|
|
237
|
+
{ label: "Credits spent", value: full.data ? fmt(full.data.credit_burn) : "..." },
|
|
238
|
+
{ label: "Query", value: l.query ?? "", wide: true },
|
|
239
|
+
{ label: "Account", value: l.target_handle ?? "" },
|
|
240
|
+
{ label: "Checks", value: cadence(l.poll_interval_sec) },
|
|
241
|
+
{ label: "Monthly cap", value: l.monthly_credit_cap == null ? "" : l.monthly_credit_cap === 0 ? "No per-listener cap" : `${l.monthly_credit_cap.toLocaleString()} credits` },
|
|
242
|
+
{ label: "Last checked", value: when(l.last_polled_at) },
|
|
243
|
+
{ label: "Paused because", value: l.paused_reason ?? "" },
|
|
244
|
+
{ label: "Created", value: when(l.created_at) },
|
|
245
|
+
{ label: "Listener id", value: l.id, mono: true },
|
|
246
|
+
]}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|