@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,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RecordOpenLinks: "Open contact" and "Open company" buttons for something that only knows ids (a call log entry, a
|
|
5
|
+
* website visitor). The ids are CHECKED against your CRM first, so a button is only shown for a record that exists
|
|
6
|
+
* and, when you pass `expectCompanyDomain`, for a company whose domain matches (event ids and CRM ids are not always
|
|
7
|
+
* the same id space). A record that cannot be loaded renders nothing, never a link to the wrong or a missing page.
|
|
8
|
+
*
|
|
9
|
+
* SDK operations, in order:
|
|
10
|
+
* 1. getContact GET /contacts/{contact_id} (read; only with a numeric `contactId`)
|
|
11
|
+
* 2. getCompany GET /companies/{company_id} (read; only with a `companyId` that the contact did not already give)
|
|
12
|
+
* Nothing here is billable, external or destructive.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Button } from "@graph8/sdk/ui";
|
|
16
|
+
import { Building2, UserRound } from "lucide-react";
|
|
17
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
18
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
19
|
+
import type { ContactRecord } from "../lib/crm-types";
|
|
20
|
+
|
|
21
|
+
export interface RecordOpenLinksProps {
|
|
22
|
+
/** A CRM contact id (number or numeric string). Anything else is ignored. */
|
|
23
|
+
contactId?: number | string | null;
|
|
24
|
+
/** A CRM company id. Optional: a contact that has a company supplies it. */
|
|
25
|
+
companyId?: number | string | null;
|
|
26
|
+
/** When set, a company is linked only if its domain equals this (case-insensitive). */
|
|
27
|
+
expectCompanyDomain?: string | null;
|
|
28
|
+
/** When set, the contact is linked only if its name equals this (case-insensitive, extra spaces ignored). */
|
|
29
|
+
expectContactName?: string | null;
|
|
30
|
+
onOpenContact?: (contactId: number) => void;
|
|
31
|
+
onOpenCompany?: (companyId: number) => void;
|
|
32
|
+
size?: "default" | "sm";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const idOf = (v: number | string | null | undefined): number | null => {
|
|
36
|
+
const s = String(v ?? "").trim();
|
|
37
|
+
return /^\d+$/.test(s) ? Number(s) : null;
|
|
38
|
+
};
|
|
39
|
+
const nameKey = (n: string | null | undefined): string => (n ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean).join(" ");
|
|
40
|
+
const domainOf = (d: string | null | undefined): string => (d ?? "").trim().toLowerCase().replace(/^www\./, "");
|
|
41
|
+
|
|
42
|
+
export function RecordOpenLinks({ contactId, companyId, expectCompanyDomain, expectContactName, onOpenContact, onOpenCompany, size = "sm" }: RecordOpenLinksProps) {
|
|
43
|
+
const cid = idOf(contactId);
|
|
44
|
+
const contact = useG8Query(
|
|
45
|
+
async (signal) => (await g8Get<ContactRecord>(`/contacts/${cid}`, undefined, { signal })).data ?? null,
|
|
46
|
+
[cid],
|
|
47
|
+
{ enabled: cid != null && Boolean(onOpenContact || onOpenCompany), key: ["contacts", "open-links", cid] },
|
|
48
|
+
);
|
|
49
|
+
const c = contact.data;
|
|
50
|
+
// A contact whose name is not the expected one is some other person that happens to share the id: link nothing from it.
|
|
51
|
+
const contactOk = Boolean(c) && (!expectContactName || nameKey(expectContactName) === nameKey(c?.full_name || [c?.first_name, c?.last_name].filter(Boolean).join(" ")));
|
|
52
|
+
const fromContact = c && contactOk ? idOf(c.company_id ?? c.company?.id) : null;
|
|
53
|
+
const ownCompany = idOf(companyId);
|
|
54
|
+
const coId = fromContact ?? ownCompany;
|
|
55
|
+
// A company id that came from the caller (not from the contact) is verified by loading it.
|
|
56
|
+
const needsCheck = fromContact == null && ownCompany != null && Boolean(onOpenCompany);
|
|
57
|
+
const company = useG8Query(
|
|
58
|
+
async (signal) => (await g8Get<{ id?: number; domain?: string | null }>(`/companies/${ownCompany}`, undefined, { signal })).data ?? null,
|
|
59
|
+
[ownCompany],
|
|
60
|
+
{ enabled: needsCheck && (cid == null || contact.data !== undefined || Boolean(contact.error)), key: ["companies", "open-links", ownCompany] },
|
|
61
|
+
);
|
|
62
|
+
const companyOk = fromContact != null || (needsCheck && company.data != null && (!expectCompanyDomain || domainOf(company.data.domain) === domainOf(expectCompanyDomain)));
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
{onOpenContact && contactOk && cid != null ? (
|
|
66
|
+
<Button size={size} variant="outline" onClick={() => onOpenContact(cid)}>
|
|
67
|
+
<UserRound className="mr-1 size-4" aria-hidden />
|
|
68
|
+
Open contact
|
|
69
|
+
</Button>
|
|
70
|
+
) : null}
|
|
71
|
+
{onOpenCompany && coId != null && companyOk ? (
|
|
72
|
+
<Button size={size} variant="outline" onClick={() => onOpenCompany(coId)}>
|
|
73
|
+
<Building2 className="mr-1 size-4" aria-hidden />
|
|
74
|
+
Open company
|
|
75
|
+
</Button>
|
|
76
|
+
) : null}
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TagAssignButton: add tags to, or remove tags from, a selection of contacts. A dialog lists the org's tags as checkboxes;
|
|
5
|
+
* the person picks the tags and whether to add or remove them.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. listContactTags GET /contacts/tags (read, via useContactTags, when opened)
|
|
9
|
+
* 2. assignContactTags POST /contacts/tags/assign { tag_ids, contact_ids } (write, idempotent)
|
|
10
|
+
* 3. unassignContactTags POST /contacts/tags/unassign { tag_ids, contact_ids } (write; tag definitions survive)
|
|
11
|
+
*
|
|
12
|
+
* One request for the whole selection (the API takes up to 200,000 contacts). The answer's `tagged` / `untagged` count is
|
|
13
|
+
* the number of contacts that changed; contacts that already had the tag are not counted, and the dialog says so.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@graph8/sdk/ui";
|
|
18
|
+
import { Tags } from "lucide-react";
|
|
19
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
20
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
21
|
+
import { g8Post } from "@/g8/lib/g8-client";
|
|
22
|
+
import { plural } from "@/g8/lib/plural";
|
|
23
|
+
import { useContactTags } from "../hooks/use-contact-tags";
|
|
24
|
+
import { TagChip } from "./contact-tag-chips";
|
|
25
|
+
|
|
26
|
+
export interface TagAssignButtonProps {
|
|
27
|
+
contactIds: ReadonlyArray<number>;
|
|
28
|
+
onDone?: (result: { changed: number | null; mode: "add" | "remove" }) => void;
|
|
29
|
+
label?: string;
|
|
30
|
+
variant?: "default" | "outline" | "secondary" | "ghost";
|
|
31
|
+
size?: "default" | "sm";
|
|
32
|
+
className?: string;
|
|
33
|
+
defaultOpen?: boolean;
|
|
34
|
+
onClosed?: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Mode = "add" | "remove";
|
|
38
|
+
|
|
39
|
+
export function TagAssignButton({ contactIds, onDone, label = "Tags", variant = "outline", size = "sm", className, defaultOpen = false, onClosed }: TagAssignButtonProps) {
|
|
40
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
41
|
+
const [mode, setMode] = useState<Mode>("add");
|
|
42
|
+
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set());
|
|
43
|
+
const [done, setDone] = useState<{ changed: number | null; mode: Mode; total: number } | null>(null);
|
|
44
|
+
const tags = useContactTags({ enabled: open });
|
|
45
|
+
|
|
46
|
+
const apply = useG8Mutation(async (v: { mode: Mode; tagIds: string[]; ids: number[] }) => {
|
|
47
|
+
const body = { tag_ids: v.tagIds, contact_ids: v.ids };
|
|
48
|
+
const res = v.mode === "add" ? await g8Post<Record<string, unknown>>("/contacts/tags/assign", body) : await g8Post<Record<string, unknown>>("/contacts/tags/unassign", body);
|
|
49
|
+
const n = res.data?.[v.mode === "add" ? "tagged" : "untagged"];
|
|
50
|
+
return { changed: typeof n === "number" ? n : null, mode: v.mode, total: v.ids.length };
|
|
51
|
+
}, {
|
|
52
|
+
invalidates: [["contact-tags"], ["contacts", "tags"], ["contacts", "list"]],
|
|
53
|
+
onSuccess: (r) => { setDone(r); onDone?.({ changed: r.changed, mode: r.mode }); },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const close = () => { setOpen(false); setPicked(new Set()); setDone(null); apply.reset(); onClosed?.(); };
|
|
57
|
+
const toggle = (id: string) => setPicked((s) => { const n = new Set(s); if (!n.delete(id)) n.add(id); return n; });
|
|
58
|
+
const count = contactIds.length;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
{defaultOpen ? null : <Button className={className} variant={variant} size={size} disabled={count === 0} onClick={() => setOpen(true)}><Tags className="size-4" aria-hidden />{label}</Button>}
|
|
63
|
+
<Dialog open={open} onOpenChange={(o) => (o ? setOpen(true) : close())}>
|
|
64
|
+
<DialogContent>
|
|
65
|
+
<DialogHeader>
|
|
66
|
+
<DialogTitle>Tag {plural(count, "contact")}</DialogTitle>
|
|
67
|
+
<DialogDescription>Tags group contacts outside lists. Adding a tag a contact already has changes nothing.</DialogDescription>
|
|
68
|
+
</DialogHeader>
|
|
69
|
+
{done ? (
|
|
70
|
+
<p role="status" className="text-sm font-medium">
|
|
71
|
+
{done.changed == null ? "Done." : done.mode === "add" ? `Tagged ${plural(done.changed, "contact")}.` : `Removed the tags from ${plural(done.changed, "contact")}.`}
|
|
72
|
+
{done.changed != null && done.changed < done.total ? <span className="block font-normal text-muted-foreground">{(done.total - done.changed).toLocaleString()} already matched and did not change.</span> : null}
|
|
73
|
+
</p>
|
|
74
|
+
) : (
|
|
75
|
+
<>
|
|
76
|
+
<div role="radiogroup" aria-label="Action" className="flex gap-2">
|
|
77
|
+
{(["add", "remove"] as const).map((m) => (
|
|
78
|
+
<Button key={m} role="radio" aria-checked={mode === m} variant={mode === m ? "default" : "outline"} size="sm" onClick={() => { setMode(m); apply.reset(); }}>{m === "add" ? "Add tags" : "Remove tags"}</Button>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
{tags.error ? <ApiError error={tags.error} title="Could not load tags" onRetry={() => void tags.refetch()} /> : null}
|
|
82
|
+
{tags.loading ? <p className="text-sm text-muted-foreground" role="status">Loading tags</p> : null}
|
|
83
|
+
{!tags.loading && !tags.error && tags.tags.length === 0 ? <p className="text-sm text-muted-foreground">No tags yet. Create one in Manage tags.</p> : null}
|
|
84
|
+
<ul className="flex max-h-64 flex-col gap-1 overflow-y-auto">
|
|
85
|
+
{tags.tags.map((t) => (
|
|
86
|
+
<li key={t.id}>
|
|
87
|
+
<label className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-muted">
|
|
88
|
+
<input type="checkbox" checked={picked.has(t.id)} onChange={() => toggle(t.id)} aria-label={t.name} />
|
|
89
|
+
<TagChip tag={t} />
|
|
90
|
+
{t.contact_count != null ? <span className="ml-auto text-xs tabular-nums text-muted-foreground">{t.contact_count.toLocaleString()}</span> : null}
|
|
91
|
+
</label>
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
</ul>
|
|
95
|
+
{apply.error ? <ApiError error={apply.error} title="Could not change the tags" /> : null}
|
|
96
|
+
</>
|
|
97
|
+
)}
|
|
98
|
+
<DialogFooter>
|
|
99
|
+
{done ? <Button onClick={close}>Done</Button> : (
|
|
100
|
+
<>
|
|
101
|
+
<Button variant="ghost" onClick={close}>Cancel</Button>
|
|
102
|
+
<Button loading={apply.loading} disabled={picked.size === 0 || count === 0} onClick={() => void apply.mutate({ mode, tagIds: [...picked], ids: [...contactIds] })}>
|
|
103
|
+
{mode === "add" ? `Add ${plural(picked.size, "tag")}` : `Remove ${plural(picked.size, "tag")}`}
|
|
104
|
+
</Button>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
107
|
+
</DialogFooter>
|
|
108
|
+
</DialogContent>
|
|
109
|
+
</Dialog>
|
|
110
|
+
</>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TagManager: the org's contact tags in one sheet. Create, rename, recolour, archive and restore a tag, delete one
|
|
5
|
+
* (optionally moving its contacts to another tag first) and merge several tags into one.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. listContactTags GET /contacts/tags?include_archived=true (read; every tag at once, with contact counts)
|
|
9
|
+
* 2. createContactTag POST /contacts/tags { name, color, description } (write; a duplicate name is refused by the API)
|
|
10
|
+
* 3. updateContactTag PATCH /contacts/tags/{tag_id} { name, color, description, archived } (write; omitted fields stay)
|
|
11
|
+
* 4. deleteContactTag DELETE /contacts/tags/{tag_id}?reassign_to=... (destructive, ConfirmSpendDialog)
|
|
12
|
+
* Without `reassign_to` the API drops the assignments together with the tag; the contacts themselves stay.
|
|
13
|
+
* 5. mergeContactTags POST /contacts/tags/merge { source_tag_ids, target_tag_id } (destructive: the sources are
|
|
14
|
+
* removed and their contacts get the target, ConfirmSpendDialog)
|
|
15
|
+
*
|
|
16
|
+
* Edit sends only what changed. A tag's description cannot be cleared (the API leaves a null field untouched), so
|
|
17
|
+
* the description is not offered as an editable field here; only name, colour and archive state are.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useMemo, useState } from "react";
|
|
21
|
+
import { Button, cn } from "@graph8/sdk/ui";
|
|
22
|
+
import { Archive, GitMerge, Pencil, Plus, Trash2 } from "lucide-react";
|
|
23
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
24
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
25
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
26
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
27
|
+
import { g8Delete, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
28
|
+
import { plural } from "@/g8/lib/plural";
|
|
29
|
+
import { useContactTags } from "../hooks/use-contact-tags";
|
|
30
|
+
import { HEX_COLOR, TAG_COLORS, isArchivedTag } from "../lib/record-actions";
|
|
31
|
+
import type { ContactTag } from "../lib/record-actions";
|
|
32
|
+
import { TagChip } from "./contact-tag-chips";
|
|
33
|
+
|
|
34
|
+
export interface TagManagerProps {
|
|
35
|
+
open: boolean;
|
|
36
|
+
onOpenChange: (open: boolean) => void;
|
|
37
|
+
/** Called after any change that can alter what the contact list shows. */
|
|
38
|
+
onChanged?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function Swatches({ value, onChange, label }: { value: string; onChange: (c: string) => void; label: string }) {
|
|
42
|
+
return (
|
|
43
|
+
<span className="flex gap-1" role="radiogroup" aria-label={label}>
|
|
44
|
+
{TAG_COLORS.map((c) => <button key={c} type="button" role="radio" aria-checked={value === c} aria-label={`Colour ${c}`} onClick={() => onChange(c)} className={cn("size-5 rounded-full border-2", value === c ? "border-foreground" : "border-transparent")} style={{ backgroundColor: c }} />)}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function TagManager({ open, onOpenChange, onChanged }: TagManagerProps) {
|
|
50
|
+
const all = useContactTags({ enabled: open, includeArchived: true });
|
|
51
|
+
const [name, setName] = useState("");
|
|
52
|
+
const [color, setColor] = useState<string>(TAG_COLORS[0]);
|
|
53
|
+
const [editing, setEditing] = useState<{ id: string; name: string; color: string } | null>(null);
|
|
54
|
+
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set());
|
|
55
|
+
const [mergeInto, setMergeInto] = useState("");
|
|
56
|
+
const [confirmMerge, setConfirmMerge] = useState(false);
|
|
57
|
+
const [deleting, setDeleting] = useState<ContactTag | null>(null);
|
|
58
|
+
const [moveTo, setMoveTo] = useState("");
|
|
59
|
+
const done = () => { void all.refetch(); onChanged?.(); };
|
|
60
|
+
const keys = [["contact-tags"], ["contacts", "tags"], ["contacts", "list"]] as const;
|
|
61
|
+
|
|
62
|
+
const create = useG8Mutation((v: { name: string; color: string }) => g8Post("/contacts/tags", v), { invalidates: keys, onSuccess: () => { setName(""); done(); } });
|
|
63
|
+
const update = useG8Mutation((v: { id: string; body: Record<string, unknown> }) => g8Patch(`/contacts/tags/${v.id}`, v.body), { invalidates: keys, onSuccess: () => { setEditing(null); done(); } });
|
|
64
|
+
const remove = useG8Mutation((v: { id: string; reassignTo: string }) => g8Delete(`/contacts/tags/${v.id}`, v.reassignTo ? { query: { reassign_to: v.reassignTo } } : {}), {
|
|
65
|
+
invalidates: keys,
|
|
66
|
+
onSuccess: (_r, v) => { setDeleting(null); setMoveTo(""); setPicked((s) => { const n = new Set(s); n.delete(v.id); return n; }); done(); },
|
|
67
|
+
});
|
|
68
|
+
const merge = useG8Mutation((v: { sources: string[]; target: string }) => g8Post("/contacts/tags/merge", { source_tag_ids: v.sources, target_tag_id: v.target }), {
|
|
69
|
+
invalidates: keys,
|
|
70
|
+
onSuccess: () => { setConfirmMerge(false); setPicked(new Set()); setMergeInto(""); done(); },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const byId = useMemo(() => new Map(all.tags.map((t) => [t.id, t])), [all.tags]);
|
|
74
|
+
const sources = [...picked].filter((id) => byId.has(id));
|
|
75
|
+
const targets = all.tags.filter((t) => !picked.has(t.id) && !isArchivedTag(t));
|
|
76
|
+
const sourceContacts = sources.reduce((n, id) => n + (byId.get(id)?.contact_count ?? 0), 0);
|
|
77
|
+
const togglePick = (id: string) => setPicked((s) => { const n = new Set(s); if (!n.delete(id)) n.add(id); return n; });
|
|
78
|
+
const saveEdit = (t: ContactTag) => {
|
|
79
|
+
if (!editing) return;
|
|
80
|
+
const body: Record<string, unknown> = {};
|
|
81
|
+
if (editing.name.trim() && editing.name.trim() !== t.name) body.name = editing.name.trim();
|
|
82
|
+
if (HEX_COLOR.test(editing.color) && editing.color !== t.color) body.color = editing.color;
|
|
83
|
+
if (Object.keys(body).length === 0) { setEditing(null); return; }
|
|
84
|
+
void update.mutate({ id: t.id, body });
|
|
85
|
+
};
|
|
86
|
+
const deleteTargets = all.tags.filter((t) => t.id !== deleting?.id && !isArchivedTag(t));
|
|
87
|
+
const allCountsKnown = sources.every((id) => byId.get(id)?.contact_count != null);
|
|
88
|
+
const targetName = byId.get(mergeInto)?.name ?? "";
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<FormSheet open={open} onOpenChange={onOpenChange} title="Manage tags" description="Tags group contacts outside lists." width="lg" footer={<Button variant="ghost" onClick={() => onOpenChange(false)}>Close</Button>}>
|
|
92
|
+
<form className="flex flex-wrap items-center gap-2 border-b pb-3" onSubmit={(e) => { e.preventDefault(); if (name.trim()) void create.mutate({ name: name.trim(), color }); }}>
|
|
93
|
+
<input aria-label="Tag name" maxLength={50} placeholder="New tag" value={name} onChange={(e) => setName(e.target.value)} className="h-9 w-44 rounded-md border bg-background px-3 text-sm" />
|
|
94
|
+
<Swatches value={color} onChange={setColor} label="Tag colour" />
|
|
95
|
+
<Button type="submit" size="sm" loading={create.loading} disabled={!name.trim()}><Plus className="size-4" aria-hidden />Create tag</Button>
|
|
96
|
+
</form>
|
|
97
|
+
{create.error ? <ApiError error={create.error} title="Could not create the tag" /> : null}
|
|
98
|
+
{update.error ? <ApiError error={update.error} title="Could not save the tag" /> : null}
|
|
99
|
+
{all.error ? <ApiError error={all.error} title="Could not load tags" onRetry={() => void all.refetch()} /> : null}
|
|
100
|
+
{all.loading ? <p className="text-sm text-muted-foreground" role="status">Loading tags</p> : null}
|
|
101
|
+
{!all.loading && !all.error && all.tags.length === 0 ? <p className="py-6 text-center text-sm text-muted-foreground">No tags yet. Create the first one above.</p> : null}
|
|
102
|
+
|
|
103
|
+
{sources.length > 0 ? (
|
|
104
|
+
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 p-2 text-sm" role="status">
|
|
105
|
+
<span>{plural(sources.length, "tag")} selected</span>
|
|
106
|
+
<select aria-label="Merge into" className="h-8 rounded-md border bg-background px-2" value={mergeInto} onChange={(e) => setMergeInto(e.target.value)}>
|
|
107
|
+
<option value="">Merge into...</option>
|
|
108
|
+
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
|
109
|
+
</select>
|
|
110
|
+
<Button size="sm" variant="outline" disabled={!mergeInto} onClick={() => setConfirmMerge(true)}><GitMerge className="size-4" aria-hidden />Merge</Button>
|
|
111
|
+
<Button size="sm" variant="ghost" onClick={() => setPicked(new Set())}>Clear</Button>
|
|
112
|
+
</div>
|
|
113
|
+
) : null}
|
|
114
|
+
{merge.error ? <ApiError error={merge.error} title="Could not merge the tags" /> : null}
|
|
115
|
+
{remove.error ? <ApiError error={remove.error} title="Could not delete the tag" /> : null}
|
|
116
|
+
|
|
117
|
+
<ul className="flex flex-col divide-y">
|
|
118
|
+
{all.tags.map((t) => (
|
|
119
|
+
<li key={t.id} className="flex flex-wrap items-center gap-2 py-2">
|
|
120
|
+
<input type="checkbox" aria-label={`Select ${t.name}`} checked={picked.has(t.id)} onChange={() => togglePick(t.id)} />
|
|
121
|
+
{editing?.id === t.id ? (
|
|
122
|
+
<form className="flex flex-wrap items-center gap-2" onSubmit={(e) => { e.preventDefault(); saveEdit(t); }}>
|
|
123
|
+
<input aria-label="Rename tag" maxLength={50} value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} className="h-8 w-40 rounded-md border bg-background px-2 text-sm" />
|
|
124
|
+
<Swatches value={editing.color} onChange={(c) => setEditing({ ...editing, color: c })} label="Edit colour" />
|
|
125
|
+
<Button type="submit" size="sm" loading={update.loading} disabled={!editing.name.trim()}>Save</Button>
|
|
126
|
+
<Button type="button" size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
|
127
|
+
</form>
|
|
128
|
+
) : (
|
|
129
|
+
<>
|
|
130
|
+
<span className={cn(isArchivedTag(t) && "opacity-60")}><TagChip tag={t} /></span>
|
|
131
|
+
{isArchivedTag(t) ? <span className="text-xs text-muted-foreground">Archived</span> : null}
|
|
132
|
+
<span className="text-xs tabular-nums text-muted-foreground">{t.contact_count == null ? "-" : plural(t.contact_count, "contact")}</span>
|
|
133
|
+
<span className="ml-auto flex gap-1">
|
|
134
|
+
<Button size="sm" variant="ghost" aria-label={`Edit ${t.name}`} onClick={() => setEditing({ id: t.id, name: t.name, color: t.color && HEX_COLOR.test(t.color) ? t.color : TAG_COLORS[0] })}><Pencil className="size-4" aria-hidden /></Button>
|
|
135
|
+
<Button size="sm" variant="ghost" aria-label={`${isArchivedTag(t) ? "Restore" : "Archive"} ${t.name}`} onClick={() => void update.mutate({ id: t.id, body: { archived: !isArchivedTag(t) } })}><Archive className="size-4" aria-hidden /></Button>
|
|
136
|
+
<Button size="sm" variant="ghost" aria-label={`Delete ${t.name}`} onClick={() => { setDeleting(t); setMoveTo(""); }}><Trash2 className="size-4" aria-hidden /></Button>
|
|
137
|
+
</span>
|
|
138
|
+
</>
|
|
139
|
+
)}
|
|
140
|
+
</li>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
|
|
144
|
+
<ConfirmSpendDialog
|
|
145
|
+
open={confirmMerge}
|
|
146
|
+
tier="destructive"
|
|
147
|
+
title={`Merge ${plural(sources.length, "tag")} into ${targetName}`}
|
|
148
|
+
description={`${sources.map((id) => byId.get(id)?.name).filter(Boolean).join(", ")} will be removed. ${allCountsKnown ? `Their ${plural(sourceContacts, "contact")} get` : "Their contacts get"} ${targetName} instead; contacts that already have it keep one copy.`}
|
|
149
|
+
affected={allCountsKnown ? sourceContacts : undefined}
|
|
150
|
+
confirmLabel="Merge tags"
|
|
151
|
+
confirming={merge.loading}
|
|
152
|
+
onConfirm={() => void merge.mutate({ sources, target: mergeInto })}
|
|
153
|
+
onCancel={() => setConfirmMerge(false)}
|
|
154
|
+
/>
|
|
155
|
+
<ConfirmSpendDialog
|
|
156
|
+
open={deleting != null}
|
|
157
|
+
tier="destructive"
|
|
158
|
+
title={`Delete the tag ${deleting?.name ?? ""}`}
|
|
159
|
+
description={
|
|
160
|
+
<div className="flex flex-col gap-2">
|
|
161
|
+
<p>{moveTo ? `${deleting?.contact_count == null ? "Its contacts" : `Its ${plural(deleting.contact_count, "contact")}`} get ${byId.get(moveTo)?.name ?? "the other tag"} first.` : `It is removed from ${deleting?.contact_count == null ? "its contacts" : plural(deleting.contact_count, "contact")}. The contacts themselves stay.`}</p>
|
|
162
|
+
<label className="flex items-center gap-2 text-sm">Move its contacts to
|
|
163
|
+
<select aria-label="Move contacts to" className="h-8 rounded-md border bg-background px-2" value={moveTo} onChange={(e) => setMoveTo(e.target.value)}>
|
|
164
|
+
<option value="">No other tag</option>
|
|
165
|
+
{deleteTargets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
|
166
|
+
</select>
|
|
167
|
+
</label>
|
|
168
|
+
</div>
|
|
169
|
+
}
|
|
170
|
+
affected={deleting?.contact_count ?? undefined}
|
|
171
|
+
confirmLabel="Delete tag"
|
|
172
|
+
confirming={remove.loading}
|
|
173
|
+
onConfirm={() => deleting && void remove.mutate({ id: deleting.id, reassignTo: moveTo })}
|
|
174
|
+
onCancel={() => setDeleting(null)}
|
|
175
|
+
/>
|
|
176
|
+
</FormSheet>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -37,8 +37,9 @@ import { RecordLink } from "@/g8/components/screen/record-link";
|
|
|
37
37
|
import { plural } from "@/g8/lib/plural";
|
|
38
38
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
39
39
|
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
40
|
+
import { useUrlFilters } from "@/g8/hooks/use-url-filters";
|
|
41
|
+
import { MoreFilters, TextFilter } from "@/g8/components/screen/filter-controls";
|
|
40
42
|
import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
41
|
-
import { useDebouncedValue } from "../hooks/use-debounced";
|
|
42
43
|
import { usePageQuery } from "../hooks/use-page-query";
|
|
43
44
|
import { errorStatus } from "../lib/crm-types";
|
|
44
45
|
import { Pager, formatDay, useBulkDelete, useRowSelection } from "../lib/screen-parts";
|
|
@@ -54,6 +55,8 @@ export interface WorkspaceTask extends TaskItem {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export interface TasksScreenProps {
|
|
58
|
+
/** The signed-in person's user id. With it the toolbar gets a "My tasks" toggle (assignee filter); without it the toggle is hidden. */
|
|
59
|
+
currentUserId?: string;
|
|
57
60
|
pageSize?: number;
|
|
58
61
|
/** Which tab opens first. Default "open". */
|
|
59
62
|
initialStatus?: "open" | "completed" | "all";
|
|
@@ -85,6 +88,10 @@ function PriorityPill({ priority }: { priority?: number | null }) {
|
|
|
85
88
|
return <StatusPill tone={priority === 1 ? "negative" : priority === 2 ? "warning" : "neutral"}>{PRIORITY[priority] ?? "Low"}</StatusPill>;
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
/** Every filter the tasks API takes besides the status tab: search, priority, type, linked record type, assignee, creator, source meeting; `mine` = assigned to the signed-in person. */
|
|
92
|
+
const FILTER_KEYS = ["search", "priority", "task_type", "entity_type", "assignee_id", "created_by", "source_meeting_id", "mine"] as const;
|
|
93
|
+
type FilterKey = (typeof FILTER_KEYS)[number];
|
|
94
|
+
|
|
88
95
|
export function TasksScreen(props: TasksScreenProps) {
|
|
89
96
|
const { pageSize = 25 } = props;
|
|
90
97
|
const [idParam, setIdParamRaw] = useUrlState("id");
|
|
@@ -94,28 +101,35 @@ export function TasksScreen(props: TasksScreenProps) {
|
|
|
94
101
|
const setIdParam = (v: string | null): void => { if (v == null) setView(null); setIdParamRaw(v); };
|
|
95
102
|
const openId = idParam || null;
|
|
96
103
|
const [tab, setTab] = useState<Tab>(props.initialStatus ?? "open");
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
104
|
+
// Filters live in the URL (`t_priority=1`, `t_mine=1`), so a filtered view survives reload, Back and sharing.
|
|
105
|
+
const urlFilters = useUrlFilters(FILTER_KEYS, { prefix: "t_" });
|
|
106
|
+
const { draft, filters } = urlFilters;
|
|
107
|
+
const search = draft.search;
|
|
108
|
+
const priority = draft.priority;
|
|
109
|
+
const taskType = draft.task_type;
|
|
110
|
+
const entityType = props.defaultRecord?.entity_type ?? draft.entity_type;
|
|
111
|
+
const q = filters.search;
|
|
112
|
+
// "My tasks" needs the signed-in person's user id: the app passes it as `currentUserId` (the pickers block's
|
|
113
|
+
// useCurrentMember gives it), so this screen holds no team-members scope of its own.
|
|
114
|
+
const assigneeId = draft.mine && props.currentUserId ? props.currentUserId : filters.assignee_id || undefined;
|
|
102
115
|
const [sheet, setSheet] = useState<"new" | "edit" | null>(null);
|
|
103
116
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
104
117
|
const [note, setNote] = useState<string | null>(null);
|
|
105
118
|
|
|
106
|
-
const scope = { status: tab === "all" ? undefined : tab, search: q || undefined, priority: priority || undefined, task_type:
|
|
119
|
+
const scope = { status: tab === "all" ? undefined : tab, search: q || undefined, priority: filters.priority || undefined, task_type: filters.task_type || undefined, entity_type: (props.defaultRecord?.entity_type ?? filters.entity_type) || undefined, assignee_id: assigneeId, created_by: filters.created_by || undefined, source_meeting_id: filters.source_meeting_id || undefined, entity_id: props.defaultRecord?.entity_id };
|
|
107
120
|
const list = usePageQuery<WorkspaceTask>(
|
|
108
121
|
(page, signal) => g8Get<WorkspaceTask[]>("/tasks", { limit: pageSize, offset: (page - 1) * pageSize, ...scope }, { signal }),
|
|
109
|
-
[scope.status, scope.search, scope.priority, scope.task_type, scope.entity_type, scope.entity_id],
|
|
122
|
+
[scope.status, scope.search, scope.priority, scope.task_type, scope.entity_type, scope.entity_id, scope.assignee_id, scope.created_by, scope.source_meeting_id],
|
|
110
123
|
pageSize,
|
|
111
124
|
["tasks", "list", "screen"],
|
|
125
|
+
urlFilters.ready,
|
|
112
126
|
);
|
|
113
127
|
const recordScope = { entity_type: props.defaultRecord?.entity_type, entity_id: props.defaultRecord?.entity_id };
|
|
114
128
|
const openCount = useG8Query((signal) => g8Get<WorkspaceTask[]>("/tasks", { status: "open", limit: 1, ...recordScope }, { signal }), [recordScope.entity_type, recordScope.entity_id], { key: ["tasks", "count", "open", recordScope] });
|
|
115
129
|
const doneCount = useG8Query((signal) => g8Get<WorkspaceTask[]>("/tasks", { status: "completed", limit: 1, ...recordScope }, { signal }), [recordScope.entity_type, recordScope.entity_id], { key: ["tasks", "count", "completed", recordScope] });
|
|
116
130
|
const selection = useRowSelection<WorkspaceTask>(JSON.stringify(scope));
|
|
117
131
|
const reload = () => { void list.refetch(); void openCount.refetch(); void doneCount.refetch(); };
|
|
118
|
-
const hasFilters = Boolean(search || priority || taskType ||
|
|
132
|
+
const hasFilters = Boolean(search || priority || taskType || draft.entity_type || draft.assignee_id || draft.created_by || draft.source_meeting_id || draft.mine);
|
|
119
133
|
const overdue = list.rows.filter(isOverdue).length;
|
|
120
134
|
|
|
121
135
|
const toggle = useG8Mutation((t: WorkspaceTask) => g8Patch(`/tasks/${t.id}`, { status: t.status === "completed" ? "open" : "completed" }), { invalidates: ["tasks"], onSuccess: reload });
|
|
@@ -130,7 +144,7 @@ export function TasksScreen(props: TasksScreenProps) {
|
|
|
130
144
|
if (openId && gone.includes(openId)) setIdParam(null);
|
|
131
145
|
reload();
|
|
132
146
|
});
|
|
133
|
-
useEffect(() => { setNote(null); }, [tab,
|
|
147
|
+
useEffect(() => { setNote(null); }, [tab, filters]);
|
|
134
148
|
const allOnPage = list.rows.length > 0 && list.rows.every((r) => selection.selected.has(r.id));
|
|
135
149
|
|
|
136
150
|
return (
|
|
@@ -162,21 +176,27 @@ export function TasksScreen(props: TasksScreenProps) {
|
|
|
162
176
|
list={
|
|
163
177
|
<div className="flex flex-col gap-3">
|
|
164
178
|
<Toolbar
|
|
165
|
-
search={{ value: search, onChange:
|
|
179
|
+
search={{ value: search, onChange: (v) => urlFilters.set("search", v), debounceMs: 0, placeholder: "Search tasks by title" }}
|
|
166
180
|
filters={
|
|
167
181
|
<>
|
|
168
|
-
<select aria-label="Filter by priority" className="h-9 rounded-md border bg-background px-2 text-sm" value={priority} onChange={(e) =>
|
|
182
|
+
<select aria-label="Filter by priority" className="h-9 rounded-md border bg-background px-2 text-sm" value={priority} onChange={(e) => urlFilters.set("priority", e.target.value)}>
|
|
169
183
|
<option value="">Any priority</option><option value="1">High</option><option value="2">Medium</option><option value="3">Low</option>
|
|
170
184
|
</select>
|
|
171
|
-
<select aria-label="Filter by type" className="h-9 rounded-md border bg-background px-2 text-sm" value={taskType} onChange={(e) =>
|
|
185
|
+
<select aria-label="Filter by type" className="h-9 rounded-md border bg-background px-2 text-sm" value={taskType} onChange={(e) => urlFilters.set("task_type", e.target.value)}>
|
|
172
186
|
<option value="">Any type</option>{TASK_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
|
173
187
|
</select>
|
|
174
188
|
{props.defaultRecord ? null : (
|
|
175
|
-
<select aria-label="Filter by linked record" className="h-9 rounded-md border bg-background px-2 text-sm" value={entityType} onChange={(e) =>
|
|
189
|
+
<select aria-label="Filter by linked record" className="h-9 rounded-md border bg-background px-2 text-sm" value={entityType} onChange={(e) => urlFilters.set("entity_type", e.target.value)}>
|
|
176
190
|
<option value="">Any record</option>{ENTITY_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
|
177
191
|
</select>
|
|
178
192
|
)}
|
|
179
|
-
{
|
|
193
|
+
{props.currentUserId ? <Button variant={draft.mine ? "default" : "outline"} size="sm" aria-pressed={Boolean(draft.mine)} onClick={() => urlFilters.set("mine", draft.mine ? "" : "1")}>My tasks</Button> : null}
|
|
194
|
+
<MoreFilters activeCount={["assignee_id", "created_by", "source_meeting_id"].filter((k) => draft[k as FilterKey]).length}>
|
|
195
|
+
<TextFilter label="assignee ID" placeholder="Assignee user ID" value={draft.assignee_id} onChange={(v) => urlFilters.set("assignee_id", v)} className="w-48" />
|
|
196
|
+
<TextFilter label="creator ID" placeholder="Created by user ID" value={draft.created_by} onChange={(v) => urlFilters.set("created_by", v)} className="w-48" />
|
|
197
|
+
<TextFilter label="meeting ID" placeholder="Source meeting ID" value={draft.source_meeting_id} onChange={(v) => urlFilters.set("source_meeting_id", v)} className="w-48" />
|
|
198
|
+
</MoreFilters>
|
|
199
|
+
{hasFilters ? <Button variant="ghost" size="sm" onClick={urlFilters.clear}>Clear filters</Button> : null}
|
|
180
200
|
</>
|
|
181
201
|
}
|
|
182
202
|
/>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UnlockInfoButton: unlock the locked contacts of a selection. Counts what it will charge for, shows that count and
|
|
5
|
+
* cost in ConfirmSpendDialog, and sends exactly that set.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, behind ConfirmSpendDialog (billable, scope enrichment:run):
|
|
8
|
+
* 1. unlockContactInfo POST /contacts/unlock-info { contact_ids } 1 credit per newly unlocked contact
|
|
9
|
+
* Only locked contacts are counted and sent (a masked email or phone). The answer's `credits_charged` is shown.
|
|
10
|
+
* At most 1000 per call, so a larger selection unlocks the first 1000 and says so before the confirm.
|
|
11
|
+
*
|
|
12
|
+
* The confirm is disabled while its request runs, and a failed call keeps the selection so the person can retry.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState } from "react";
|
|
16
|
+
import { Button } from "@graph8/sdk/ui";
|
|
17
|
+
import { Unlock } from "lucide-react";
|
|
18
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
19
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
20
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
21
|
+
import { g8Post } from "@/g8/lib/g8-client";
|
|
22
|
+
import { plural } from "@/g8/lib/plural";
|
|
23
|
+
import type { ContactRecord } from "../lib/crm-types";
|
|
24
|
+
import { MAX_PAID_BATCH, UNLOCK_CREDITS_PER_CONTACT, isLockedContact, unlockTargets } from "../lib/record-actions";
|
|
25
|
+
|
|
26
|
+
export interface PaidActionProps {
|
|
27
|
+
rows: ReadonlyArray<ContactRecord>;
|
|
28
|
+
onDone?: () => void;
|
|
29
|
+
variant?: "default" | "outline" | "secondary" | "ghost";
|
|
30
|
+
size?: "default" | "sm";
|
|
31
|
+
className?: string;
|
|
32
|
+
/** Start with the confirm open and render no trigger (the row actions dialog opens it). */
|
|
33
|
+
defaultOpen?: boolean;
|
|
34
|
+
onClosed?: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function UnlockInfoButton({ rows, onDone, variant = "outline", size = "sm", className, defaultOpen = false, onClosed }: PaidActionProps) {
|
|
38
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
39
|
+
const [charged, setCharged] = useState<number | null | undefined>(undefined);
|
|
40
|
+
const targets = unlockTargets(rows);
|
|
41
|
+
const lockedTotal = rows.filter(isLockedContact).length;
|
|
42
|
+
const already = rows.length - lockedTotal;
|
|
43
|
+
|
|
44
|
+
const unlock = useG8Mutation(async (ids: number[]) => {
|
|
45
|
+
const res = await g8Post<{ credits_charged?: number }>("/contacts/unlock-info", { contact_ids: ids });
|
|
46
|
+
return typeof res.data?.credits_charged === "number" ? res.data.credits_charged : null;
|
|
47
|
+
}, {
|
|
48
|
+
invalidates: [["contacts", "list"], ["contacts", "detail"], ["contacts", "record"]],
|
|
49
|
+
onSuccess: (n) => { setOpen(false); setCharged(n); onDone?.(); onClosed?.(); },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
{defaultOpen ? null : (
|
|
55
|
+
<Button className={className} variant={variant} size={size} disabled={targets.length === 0} title={targets.length === 0 ? "None of the selected contacts is locked" : undefined} onClick={() => { setCharged(undefined); unlock.reset(); setOpen(true); }}>
|
|
56
|
+
<Unlock className="size-4" aria-hidden />Unlock info
|
|
57
|
+
</Button>
|
|
58
|
+
)}
|
|
59
|
+
{charged !== undefined ? <span role="status" className="text-xs text-muted-foreground">{charged == null ? "Unlocked." : `Unlocked. ${charged.toLocaleString()} ${charged === 1 ? "credit" : "credits"} charged.`}</span> : null}
|
|
60
|
+
{unlock.error && !open ? <ApiError compact error={unlock.error} title="Could not unlock the contacts" /> : null}
|
|
61
|
+
<ConfirmSpendDialog
|
|
62
|
+
open={open}
|
|
63
|
+
tier="billable"
|
|
64
|
+
title={`Unlock ${plural(targets.length, "contact")}`}
|
|
65
|
+
description={
|
|
66
|
+
<>
|
|
67
|
+
Reveals the withheld email and phone numbers. Each newly unlocked contact costs {UNLOCK_CREDITS_PER_CONTACT} credit; contacts that are already unlocked are not charged again.
|
|
68
|
+
{already > 0 ? ` ${plural(already, "selected contact")} ${already === 1 ? "is" : "are"} already unlocked and left out.` : ""}
|
|
69
|
+
{lockedTotal > MAX_PAID_BATCH ? ` Only the first ${MAX_PAID_BATCH.toLocaleString()} locked contacts are unlocked in one go.` : ""}
|
|
70
|
+
{unlock.error ? <span role="alert" className="mt-2 block text-destructive">The unlock failed. You can try again.</span> : null}
|
|
71
|
+
</>
|
|
72
|
+
}
|
|
73
|
+
estimatedCredits={targets.length * UNLOCK_CREDITS_PER_CONTACT}
|
|
74
|
+
affected={targets.length}
|
|
75
|
+
confirmLabel="Unlock"
|
|
76
|
+
confirming={unlock.loading}
|
|
77
|
+
onConfirm={() => void unlock.mutate(targets.map((t) => t.id))}
|
|
78
|
+
onCancel={() => { setOpen(false); onClosed?.(); }}
|
|
79
|
+
/>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
}
|