@graph8/sdk 0.362.0 → 0.373.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -10
- package/blocks/_foundation/README.md +78 -1
- package/blocks/_foundation/block.json +123 -6
- package/blocks/_foundation/components/api-error.tsx +9 -7
- package/blocks/_foundation/components/brand-provider.tsx +23 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
- package/blocks/_foundation/components/confirmed-action.tsx +90 -0
- package/blocks/_foundation/components/connect-graph8.tsx +280 -0
- package/blocks/_foundation/components/empty-state.tsx +5 -2
- package/blocks/_foundation/components/list-select.tsx +112 -0
- package/blocks/_foundation/components/paging-notices.tsx +55 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +2 -1
- package/blocks/_foundation/components/screen/filter-controls.tsx +92 -0
- package/blocks/_foundation/components/screen/index.ts +2 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/split-view.tsx +1 -1
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +15 -97
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
- package/blocks/_foundation/hooks/use-brand.ts +52 -0
- package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
- package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
- package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
- package/blocks/_foundation/hooks/use-paginated.ts +120 -0
- package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
- package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
- package/blocks/_foundation/lib/g8-cache.ts +454 -0
- package/blocks/_foundation/lib/g8-client.ts +1 -1
- package/blocks/_foundation/lib/paging.ts +57 -0
- package/blocks/_foundation/lib/plural.ts +23 -0
- package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
- package/blocks/_foundation/server/connect-route.ts +15 -0
- package/blocks/_foundation/server/connect.ts +127 -0
- package/blocks/_foundation/server/local-key.ts +70 -0
- package/blocks/_foundation/server/proxy.ts +103 -3
- package/blocks/_foundation/server/route.ts +16 -4
- package/blocks/agents/README.md +93 -2
- package/blocks/agents/block.json +343 -2
- package/blocks/agents/components/agent-activity.tsx +112 -0
- package/blocks/agents/components/agent-approvals.tsx +135 -0
- package/blocks/agents/components/agent-builder.tsx +29 -10
- package/blocks/agents/components/agent-conversations.tsx +143 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +4 -3
- package/blocks/agents/components/agent-operators.tsx +164 -0
- package/blocks/agents/components/agent-run-detail.tsx +119 -0
- package/blocks/agents/components/agent-runs.tsx +19 -10
- package/blocks/agents/components/agent-skills-panel.tsx +7 -7
- package/blocks/agents/components/agent-test-history.tsx +121 -0
- package/blocks/agents/components/agents-screen.tsx +226 -0
- package/blocks/agents/components/assistant-sessions.tsx +150 -0
- package/blocks/agents/components/duplicate-agent-button.tsx +42 -0
- package/blocks/agents/components/from-template-button.tsx +65 -0
- package/blocks/agents/components/knowledge-browser.tsx +107 -0
- package/blocks/agents/components/knowledge-detail.tsx +156 -0
- package/blocks/agents/components/knowledge-form.tsx +69 -0
- package/blocks/agents/components/name-dialog.tsx +32 -0
- package/blocks/agents/components/receptionist-button.tsx +46 -0
- package/blocks/agents/components/save-as-template-button.tsx +32 -0
- package/blocks/agents/components/skill-editor.tsx +147 -0
- package/blocks/agents/components/skill-library.tsx +111 -0
- package/blocks/agents/lib/agent-types.ts +7 -1
- package/blocks/agents/lib/facts.tsx +13 -0
- package/blocks/agents/lib/knowledge-utils.ts +49 -0
- package/blocks/agents/lib/load-more.tsx +141 -0
- package/blocks/agents/lib/skill-utils.ts +91 -0
- package/blocks/agents/recipes/create-agent-and-attach-skill.ts +1 -1
- package/blocks/analytics/README.md +13 -0
- package/blocks/analytics/block.json +45 -1
- package/blocks/analytics/components/analytics-screen.tsx +222 -0
- package/blocks/analytics/components/kpi-tiles.tsx +1 -1
- package/blocks/analytics/components/leaderboard.tsx +14 -7
- package/blocks/analytics/components/report-builder.tsx +38 -11
- package/blocks/analytics/components/report-detail.tsx +84 -0
- package/blocks/analytics/components/trend-chart.tsx +62 -0
- package/blocks/appointments/README.md +6 -0
- package/blocks/appointments/block.json +33 -2
- package/blocks/appointments/components/availability-editor.tsx +17 -5
- package/blocks/appointments/components/booking-page.tsx +2 -2
- package/blocks/appointments/components/event-type-editor.tsx +2 -1
- package/blocks/appointments/components/event-type-related.tsx +4 -2
- package/blocks/appointments/components/meeting-contact-links.tsx +49 -0
- package/blocks/appointments/components/meeting-detail.tsx +2 -2
- package/blocks/appointments/components/meeting-list.tsx +38 -18
- package/blocks/appointments/components/meetings-screen.tsx +7 -4
- package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
- package/blocks/appointments/components/scheduling-screen.tsx +2 -1
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +17 -0
- package/blocks/campaigns/README.md +41 -11
- package/blocks/campaigns/block.json +105 -42
- package/blocks/campaigns/components/campaign-assets.tsx +17 -6
- package/blocks/campaigns/components/campaign-launch-step.tsx +114 -26
- package/blocks/campaigns/components/campaign-list.tsx +7 -3
- package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
- package/blocks/campaigns/components/campaign-wizard.tsx +52 -14
- package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
- package/blocks/campaigns/components/icp-detail.tsx +163 -0
- package/blocks/campaigns/components/icp-editor.tsx +22 -17
- package/blocks/campaigns/components/persona-detail.tsx +181 -0
- package/blocks/campaigns/components/persona-editor.tsx +19 -11
- package/blocks/campaigns/lib/campaigns-utils.ts +138 -5
- package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
- package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
- package/blocks/coach/README.md +10 -0
- package/blocks/coach/block.json +16 -1
- package/blocks/coach/components/call-scorecard.tsx +7 -7
- package/blocks/coach/components/coach-screen.tsx +222 -0
- package/blocks/company-intelligence/README.md +132 -0
- package/blocks/company-intelligence/block.json +318 -0
- package/blocks/company-intelligence/components/company-intelligence-screen.tsx +114 -0
- package/blocks/company-intelligence/components/company-intelligence-tab.tsx +88 -0
- package/blocks/company-intelligence/components/company-profile.tsx +68 -0
- package/blocks/company-intelligence/components/intelligence-sources.tsx +63 -0
- package/blocks/company-intelligence/components/report-detail.tsx +189 -0
- package/blocks/company-intelligence/components/research-reports.tsx +136 -0
- package/blocks/company-intelligence/components/research-run.tsx +161 -0
- package/blocks/company-intelligence/components/source-detail.tsx +117 -0
- package/blocks/company-intelligence/components/source-view.tsx +274 -0
- package/blocks/company-intelligence/components/wiki-document-detail.tsx +116 -0
- package/blocks/company-intelligence/components/wiki-documents.tsx +66 -0
- package/blocks/company-intelligence/hooks/use-intelligence.ts +173 -0
- package/blocks/company-intelligence/lib/intelligence-types.ts +230 -0
- package/blocks/company-intelligence/lib/safe-markdown.tsx +150 -0
- package/blocks/company-intelligence/recipes/read-competitors-and-news.ts +33 -0
- package/blocks/company-intelligence/recipes/research-your-website.ts +43 -0
- package/blocks/company-intelligence/recipes/write-the-research-reports.ts +54 -0
- package/blocks/copilot/README.md +13 -0
- package/blocks/copilot/block.json +14 -1
- package/blocks/copilot/components/copilot-memory.tsx +6 -2
- package/blocks/copilot/components/copilot-panel.tsx +14 -5
- package/blocks/copilot/components/copilot-screen.tsx +148 -0
- package/blocks/copilot/components/copilot-team-chat.tsx +13 -6
- package/blocks/crm/README.md +34 -0
- package/blocks/crm/block.json +107 -2
- package/blocks/crm/components/companies-screen.tsx +359 -0
- package/blocks/crm/components/company-detail.tsx +10 -8
- package/blocks/crm/components/company-table.tsx +11 -4
- package/blocks/crm/components/contact-detail.tsx +10 -7
- package/blocks/crm/components/contact-lookup-link.tsx +44 -0
- package/blocks/crm/components/contact-table.tsx +10 -3
- package/blocks/crm/components/contacts-screen.tsx +377 -0
- package/blocks/crm/components/dedupe-review.tsx +13 -6
- package/blocks/crm/components/import-wizard.tsx +7 -9
- package/blocks/crm/components/list-select.tsx +20 -0
- package/blocks/crm/components/notes-panel.tsx +4 -4
- package/blocks/crm/components/record-engagement-tab.tsx +103 -0
- package/blocks/crm/components/record-form.tsx +6 -4
- package/blocks/crm/components/task-list.tsx +4 -4
- package/blocks/crm/components/tasks-screen.tsx +399 -0
- package/blocks/crm/hooks/use-all-lists.ts +18 -0
- package/blocks/crm/hooks/use-custom-fields.ts +6 -1
- package/blocks/crm/hooks/use-page-query.ts +9 -1
- package/blocks/crm/lib/crm-types.ts +26 -2
- package/blocks/crm/lib/read-lists.ts +16 -0
- package/blocks/crm/lib/screen-parts.tsx +105 -0
- package/blocks/deals/README.md +24 -6
- package/blocks/deals/block.json +160 -5
- package/blocks/deals/components/close-deal-dialog.tsx +169 -0
- package/blocks/deals/components/deal-contacts-editor.tsx +134 -0
- package/blocks/deals/components/deal-detail.tsx +25 -13
- package/blocks/deals/components/deal-filters.tsx +65 -0
- package/blocks/deals/components/deal-line-items.tsx +120 -0
- package/blocks/deals/components/deal-notes-tab.tsx +97 -0
- package/blocks/deals/components/deal-related.tsx +31 -10
- package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
- package/blocks/deals/components/deals-screen.tsx +66 -26
- package/blocks/deals/components/forecast-view.tsx +4 -4
- package/blocks/deals/components/pipeline-board.tsx +8 -6
- package/blocks/deals/components/pipeline-editor.tsx +132 -0
- package/blocks/deals/components/record-deals-tab.tsx +62 -0
- package/blocks/deals/components/stage-editor.tsx +5 -5
- package/blocks/deals/hooks/use-pipeline-deals.ts +21 -4
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/deals/lib/deal-extras.ts +156 -0
- package/blocks/dialer/README.md +45 -4
- package/blocks/dialer/block.json +84 -3
- package/blocks/dialer/components/call-contact-button.tsx +269 -0
- package/blocks/dialer/components/call-log-screen.tsx +208 -0
- package/blocks/dialer/components/call-log.tsx +23 -7
- package/blocks/dialer/components/call-queue.tsx +4 -4
- package/blocks/dialer/components/dialer-screen.tsx +361 -0
- package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
- package/blocks/dialer/components/disposition-panel.tsx +1 -1
- package/blocks/dialer/components/live-calls-table.tsx +7 -3
- package/blocks/dialer/components/record-calls-tab.tsx +101 -0
- package/blocks/dialer/components/session-start-form.tsx +61 -12
- package/blocks/dialer/components/softphone.tsx +4 -2
- package/blocks/enrichment/README.md +29 -7
- package/blocks/enrichment/block.json +49 -9
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +83 -29
- package/blocks/enrichment/components/email-verify-badge.tsx +31 -11
- package/blocks/enrichment/components/enrich-button.tsx +32 -9
- package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +341 -0
- package/blocks/enrichment/components/waterfall-config.tsx +66 -17
- package/blocks/enrichment/lib/enrichment-types.ts +39 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
- package/blocks/forms/README.md +8 -0
- package/blocks/forms/block.json +139 -18
- package/blocks/forms/components/form-activity.tsx +71 -0
- package/blocks/forms/components/form-backfill.tsx +108 -0
- package/blocks/forms/components/form-builder.tsx +4 -2
- package/blocks/forms/components/form-embed.tsx +4 -2
- package/blocks/forms/components/form-groups.tsx +151 -0
- package/blocks/forms/components/form-mapping-editor.tsx +6 -9
- package/blocks/forms/components/form-route-history.tsx +72 -0
- package/blocks/forms/components/form-saved-views.tsx +81 -0
- package/blocks/forms/components/forms-screen.tsx +56 -19
- package/blocks/forms/components/more-rows.tsx +30 -0
- package/blocks/forms/components/submissions-export.tsx +67 -0
- package/blocks/forms/components/submissions-table.tsx +17 -6
- package/blocks/forms/lib/forms-extra.ts +67 -0
- package/blocks/inbox/README.md +32 -3
- package/blocks/inbox/block.json +29 -2
- package/blocks/inbox/components/inbox-list.tsx +7 -3
- package/blocks/inbox/components/inbox-screen.tsx +294 -27
- package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
- package/blocks/inbox/components/reply-composer.tsx +65 -9
- package/blocks/inbox/components/tag-and-assign.tsx +4 -2
- package/blocks/inbox/components/thread-badge.tsx +2 -2
- package/blocks/inbox/components/thread-view.tsx +1 -1
- package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
- package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
- package/blocks/inbox/lib/inbox-types.ts +47 -1
- package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
- package/blocks/integrations/README.md +63 -1
- package/blocks/integrations/block.json +234 -15
- package/blocks/integrations/components/ad-accounts.tsx +82 -0
- package/blocks/integrations/components/connection-health.tsx +59 -0
- package/blocks/integrations/components/crm-detail.tsx +119 -0
- package/blocks/integrations/components/crm-records.tsx +103 -0
- package/blocks/integrations/components/field-mappings.tsx +61 -0
- package/blocks/integrations/components/integration-list.tsx +13 -8
- package/blocks/integrations/components/integrations-screen.tsx +152 -0
- package/blocks/integrations/components/migration-panel.tsx +133 -0
- package/blocks/integrations/components/sugarcrm-connect.tsx +95 -0
- package/blocks/integrations/components/sync-status.tsx +11 -9
- package/blocks/integrations/components/sync-triggers.tsx +134 -0
- package/blocks/integrations/components/webhook-detail.tsx +179 -0
- package/blocks/integrations/components/webhook-manager.tsx +43 -25
- package/blocks/integrations/components/workspace-tools.tsx +127 -0
- package/blocks/integrations/lib/crm-utils.ts +39 -0
- package/blocks/integrations/lib/facts.tsx +13 -0
- package/blocks/integrations/lib/integrations-utils.ts +11 -1
- package/blocks/integrations/lib/load-more.tsx +141 -0
- package/blocks/intent/README.md +14 -0
- package/blocks/intent/block.json +27 -1
- package/blocks/intent/components/account-intent-score.tsx +5 -5
- package/blocks/intent/components/intent-feed.tsx +13 -10
- package/blocks/intent/components/intent-keyword-manager.tsx +16 -12
- package/blocks/intent/components/intent-screen.tsx +265 -0
- package/blocks/intent/components/keyword-detail.tsx +124 -0
- package/blocks/intent/lib/intent-utils.ts +4 -4
- package/blocks/lifecycle/block.json +13 -8
- package/blocks/lifecycle/components/account-related.tsx +8 -1
- package/blocks/lifecycle/components/churn-list.tsx +5 -2
- package/blocks/lifecycle/components/customer-table.tsx +1 -1
- package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +3 -1
- package/blocks/lifecycle/lib/lifecycle.ts +1 -1
- package/blocks/lists/README.md +20 -1
- package/blocks/lists/block.json +69 -3
- package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
- package/blocks/lists/components/add-to-list-button.tsx +20 -9
- package/blocks/lists/components/list-picker.tsx +17 -3
- package/blocks/lists/components/list-table.tsx +12 -6
- package/blocks/lists/components/lists-screen.tsx +346 -0
- package/blocks/lists/components/record-lists-tab.tsx +99 -0
- package/blocks/lists/components/suppression-manager.tsx +33 -8
- package/blocks/lists/components/suppressions-screen.tsx +264 -0
- package/blocks/mailboxes/README.md +17 -0
- package/blocks/mailboxes/block.json +69 -17
- package/blocks/mailboxes/components/connect-mailbox.tsx +32 -21
- package/blocks/mailboxes/components/domain-detail.tsx +148 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +20 -14
- package/blocks/mailboxes/components/mailbox-detail.tsx +157 -0
- package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
- package/blocks/mailboxes/components/mailboxes-screen.tsx +228 -0
- package/blocks/mailboxes/components/warmup-health.tsx +24 -22
- package/blocks/mailboxes/lib/mailboxes-utils.ts +26 -5
- package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
- package/blocks/newsletter/README.md +12 -0
- package/blocks/newsletter/block.json +157 -24
- package/blocks/newsletter/components/issue-scheduler.tsx +4 -3
- package/blocks/newsletter/components/issue-versions.tsx +93 -0
- package/blocks/newsletter/components/more-rows.tsx +30 -0
- package/blocks/newsletter/components/newsletter-editor.tsx +1 -1
- package/blocks/newsletter/components/newsletter-list-syncs.tsx +121 -0
- package/blocks/newsletter/components/newsletter-screen.tsx +39 -14
- package/blocks/newsletter/components/newsletter-settings.tsx +211 -0
- package/blocks/newsletter/components/newsletter-stats.tsx +2 -2
- package/blocks/newsletter/components/newsletter-templates.tsx +133 -0
- package/blocks/newsletter/components/subscriber-table.tsx +5 -7
- package/blocks/newsletter/components/subscriber-timeline.tsx +65 -0
- package/blocks/newsletter/lib/newsletter-extra.ts +60 -0
- package/blocks/nurture/block.json +28 -14
- package/blocks/nurture/components/nurture-builder.tsx +13 -3
- package/blocks/nurture/components/nurture-enroll.tsx +12 -10
- package/blocks/nurture/components/nurture-form.tsx +27 -10
- package/blocks/nurture/components/nurture-screen.tsx +26 -11
- package/blocks/nurture/components/nurture-stats.tsx +5 -4
- package/blocks/nurture/lib/nurture-types.ts +1 -1
- package/blocks/objects/README.md +18 -1
- package/blocks/objects/block.json +42 -21
- package/blocks/objects/components/attribute-field-input.tsx +14 -1
- package/blocks/objects/components/object-not-found.tsx +3 -1
- package/blocks/objects/components/object-record-detail.tsx +10 -2
- package/blocks/objects/components/object-schema-editor.tsx +4 -2
- package/blocks/objects/components/object-table.tsx +13 -3
- package/blocks/objects/components/objects-screen.tsx +147 -0
- package/blocks/objects/components/record-roles-panel.tsx +15 -5
- package/blocks/objects/lib/object-types.ts +1 -1
- package/blocks/pages/README.md +14 -0
- package/blocks/pages/block.json +140 -23
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +9 -4
- package/blocks/pages/components/more-rows.tsx +30 -0
- package/blocks/pages/components/page-ai-tools.tsx +196 -0
- package/blocks/pages/components/page-analytics.tsx +176 -0
- package/blocks/pages/components/page-clone.tsx +99 -0
- package/blocks/pages/components/page-domains.tsx +167 -0
- package/blocks/pages/components/page-editor.tsx +1 -1
- package/blocks/pages/components/page-generation-status.tsx +48 -0
- package/blocks/pages/components/page-list.tsx +2 -1
- package/blocks/pages/components/page-tracking.tsx +137 -0
- package/blocks/pages/components/pages-screen.tsx +57 -16
- package/blocks/pages/components/seo-report.tsx +6 -1
- package/blocks/pages/lib/studio.ts +7 -0
- package/blocks/phone-numbers/README.md +74 -0
- package/blocks/phone-numbers/block.json +262 -0
- package/blocks/phone-numbers/components/a2p-registration.tsx +230 -0
- package/blocks/phone-numbers/components/assign-number.tsx +104 -0
- package/blocks/phone-numbers/components/number-search.tsx +239 -0
- package/blocks/phone-numbers/components/number-settings.tsx +134 -0
- package/blocks/phone-numbers/components/number-usage.tsx +97 -0
- package/blocks/phone-numbers/components/phone-number-detail.tsx +84 -0
- package/blocks/phone-numbers/components/phone-number-list.tsx +110 -0
- package/blocks/phone-numbers/components/phone-numbers-screen.tsx +201 -0
- package/blocks/phone-numbers/components/register-owned-number.tsx +98 -0
- package/blocks/phone-numbers/components/release-number.tsx +126 -0
- package/blocks/phone-numbers/hooks/use-phone-numbers.ts +58 -0
- package/blocks/phone-numbers/lib/phone-numbers-utils.ts +202 -0
- package/blocks/phone-numbers/recipes/01-search-and-buy-number.ts +49 -0
- package/blocks/phone-numbers/recipes/02-assign-number-to-rep.ts +25 -0
- package/blocks/phone-numbers/recipes/03-audit-numbers.ts +36 -0
- package/blocks/phone-numbers/recipes/04-register-text-messaging.ts +39 -0
- package/blocks/pickers/README.md +23 -0
- package/blocks/pickers/block.json +103 -0
- package/blocks/pickers/components/company-picker.tsx +90 -0
- package/blocks/pickers/components/contact-picker.tsx +131 -0
- package/blocks/pickers/components/member-picker.tsx +99 -0
- package/blocks/pickers/components/search-picker.tsx +98 -0
- package/blocks/pickers/hooks/use-current-member.ts +104 -0
- package/blocks/pickers/hooks/use-picker-pages.ts +73 -0
- package/blocks/pickers/lib/use-debounced.ts +13 -0
- package/blocks/quotes/README.md +13 -0
- package/blocks/quotes/block.json +65 -13
- package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
- package/blocks/quotes/components/product-picker.tsx +3 -1
- package/blocks/quotes/components/quote-builder.tsx +1 -1
- package/blocks/quotes/components/quote-lifecycle-actions.tsx +146 -0
- package/blocks/quotes/components/quote-list.tsx +5 -3
- package/blocks/quotes/components/quote-related.tsx +8 -4
- package/blocks/quotes/components/quote-viewer.tsx +7 -2
- package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
- package/blocks/radar/README.md +10 -0
- package/blocks/radar/block.json +41 -1
- package/blocks/radar/components/competitor-battle-card.tsx +1 -1
- package/blocks/radar/components/competitor-detail.tsx +117 -0
- package/blocks/radar/components/competitor-tracker.tsx +3 -1
- package/blocks/radar/components/radar-overview.tsx +4 -4
- package/blocks/radar/components/radar-report.tsx +4 -2
- package/blocks/radar/components/radar-screen.tsx +214 -0
- package/blocks/radar/components/report-detail.tsx +104 -0
- package/blocks/search/README.md +10 -0
- package/blocks/search/block.json +16 -1
- package/blocks/search/components/filter-builder.tsx +3 -3
- package/blocks/search/components/prospect-results.tsx +5 -3
- package/blocks/search/components/prospect-search-screen.tsx +235 -0
- package/blocks/search/components/saved-searches.tsx +4 -1
- package/blocks/search/hooks/use-filter-options.ts +20 -6
- package/blocks/search/lib/search-types.ts +2 -0
- package/blocks/sequencer/README.md +55 -9
- package/blocks/sequencer/block.json +126 -19
- package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +11 -5
- package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
- package/blocks/sequencer/components/sequence-builder.tsx +224 -60
- package/blocks/sequencer/components/sequence-detail.tsx +261 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +116 -0
- package/blocks/sequencer/components/sequence-list.tsx +34 -9
- package/blocks/sequencer/components/sequence-stats.tsx +11 -10
- package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
- package/blocks/sequencer/components/sequences-screen.tsx +162 -0
- package/blocks/sequencer/components/step-editor.tsx +14 -12
- package/blocks/sequencer/lib/sequencer-utils.ts +133 -10
- package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
- package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
- package/blocks/social-ads/README.md +19 -0
- package/blocks/social-ads/block.json +233 -1
- package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
- package/blocks/social-ads/components/ads-screen.tsx +152 -0
- package/blocks/social-ads/components/audience-sync-detail.tsx +194 -0
- package/blocks/social-ads/components/audience-sync.tsx +17 -9
- package/blocks/social-ads/components/campaign-ads.tsx +166 -0
- package/blocks/social-ads/components/campaign-detail.tsx +138 -0
- package/blocks/social-ads/components/campaign-editor.tsx +134 -0
- package/blocks/social-ads/components/creative-concepts.tsx +74 -0
- package/blocks/social-ads/components/creative-detail.tsx +155 -0
- package/blocks/social-ads/components/creative-gallery.tsx +35 -7
- package/blocks/social-ads/components/creative-revisions.tsx +57 -0
- package/blocks/social-ads/components/creative-tools.tsx +176 -0
- package/blocks/social-ads/components/post-composer.tsx +7 -3
- package/blocks/social-ads/components/post-detail.tsx +163 -0
- package/blocks/social-ads/components/social-ideas.tsx +64 -0
- package/blocks/social-ads/components/social-performance.tsx +80 -0
- package/blocks/social-ads/components/social-post-list.tsx +11 -3
- package/blocks/social-ads/components/social-screen.tsx +187 -0
- package/blocks/social-ads/lib/ads-extra.ts +67 -0
- package/blocks/surveys/README.md +91 -0
- package/blocks/surveys/block.json +299 -0
- package/blocks/surveys/components/account-nps.tsx +69 -0
- package/blocks/surveys/components/contact-survey-history.tsx +52 -0
- package/blocks/surveys/components/org-summary.tsx +82 -0
- package/blocks/surveys/components/sending-domains.tsx +113 -0
- package/blocks/surveys/components/survey-form.tsx +126 -0
- package/blocks/surveys/components/survey-responses.tsx +145 -0
- package/blocks/surveys/components/survey-results.tsx +72 -0
- package/blocks/surveys/components/survey-send-history.tsx +58 -0
- package/blocks/surveys/components/survey-send.tsx +162 -0
- package/blocks/surveys/components/survey-triggers.tsx +166 -0
- package/blocks/surveys/components/surveys-screen.tsx +234 -0
- package/blocks/surveys/lib/surveys-types.ts +228 -0
- package/blocks/surveys/recipes/create-an-nps-survey.ts +22 -0
- package/blocks/surveys/recipes/send-a-survey-and-follow-it.ts +27 -0
- package/blocks/surveys/recipes/survey-score-report.ts +27 -0
- package/blocks/visitors/README.md +10 -0
- package/blocks/visitors/block.json +25 -1
- package/blocks/visitors/components/snippet-install.tsx +21 -10
- package/blocks/visitors/components/traffic-chart.tsx +4 -4
- package/blocks/visitors/components/visitor-company-card.tsx +26 -16
- package/blocks/visitors/components/visitor-detail.tsx +105 -0
- package/blocks/visitors/components/visitor-feed.tsx +16 -7
- package/blocks/visitors/components/visitors-screen.tsx +166 -0
- package/blocks/webchat/README.md +10 -0
- package/blocks/webchat/block.json +21 -7
- package/blocks/webchat/components/chat-widget-config.tsx +8 -5
- package/blocks/webchat/components/conversation-list.tsx +8 -4
- package/blocks/webchat/components/webchat-screen.tsx +245 -0
- package/blocks/workbench/README.md +11 -0
- package/blocks/workbench/block.json +48 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +5 -3
- package/blocks/workbench/components/run-progress.tsx +1 -1
- package/blocks/workbench/components/workbench-grid.tsx +10 -4
- package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
- package/blocks/workbench/components/workbench-screen.tsx +202 -0
- package/blocks/workbench/components/workbench-sources.tsx +194 -0
- package/blocks/workbench/components/workbench-table-list.tsx +14 -2
- package/blocks/workflows/README.md +13 -0
- package/blocks/workflows/block.json +28 -9
- package/blocks/workflows/components/run-history.tsx +31 -9
- package/blocks/workflows/components/trigger-config.tsx +3 -1
- package/blocks/workflows/components/workflow-editor.tsx +4 -2
- package/blocks/workflows/components/workflow-list.tsx +13 -6
- package/blocks/workflows/components/workflows-screen.tsx +193 -0
- package/dist/cli.js +3109 -1977
- package/dist/errors.d.mts +34 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +46 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.mjs +21 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.browser.js +11 -5
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +11 -5
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +164 -25
- package/dist/index.d.ts +164 -25
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +10 -4
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +10 -4
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +142 -5
- package/dist/react.d.ts +142 -5
- package/dist/react.js +10 -4
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +10 -4
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/add.d.ts +11 -4
- package/dist/types/blocks/brand.d.ts +122 -0
- package/dist/types/blocks/compose.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +27 -20
- package/dist/types/blocks/doctor.d.ts +6 -1
- package/dist/types/blocks/flows.d.ts +7 -1
- package/dist/types/blocks/navfile.d.ts +35 -0
- package/dist/types/blocks/orgBrand.d.ts +34 -0
- package/dist/types/blocks/paths.d.ts +4 -0
- package/dist/types/blocks/prefixes.d.ts +37 -0
- package/dist/types/blocks/registry.d.ts +17 -0
- package/dist/types/blocks/screens.d.ts +53 -0
- package/dist/types/cli.d.ts +5 -0
- package/dist/types/errors.d.ts +33 -0
- package/dist/types/http.d.ts +2 -18
- package/dist/types/version.d.ts +2 -2
- package/docs/agents.md +15 -4
- package/docs/api/README.md +2 -2
- package/docs/api/by-path.md +1 -0
- package/docs/api/desk.d.ts +98 -2
- package/docs/api/desk.md +123 -1
- package/docs/api/inbox.d.ts +10 -2
- package/docs/api/inbox.md +10 -2
- package/docs/api/seo.d.ts +19 -1
- package/docs/api/seo.md +24 -1
- package/package.json +16 -2
- package/stacks/6sense/pages/companies.tsx.tmpl +21 -0
- package/stacks/6sense/pages/contacts.tsx.tmpl +19 -0
- package/stacks/6sense/pages/tasks.tsx.tmpl +19 -0
- package/stacks/6sense.json +55 -17
- package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
- package/stacks/apollo/pages/companies.tsx.tmpl +33 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +51 -0
- package/stacks/apollo/pages/deals.tsx.tmpl +17 -0
- package/stacks/apollo/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/apollo/pages/lists.tsx.tmpl +12 -0
- package/stacks/apollo/pages/meetings.tsx.tmpl +13 -0
- package/stacks/apollo/pages/sequences.tsx.tmpl +12 -0
- package/stacks/apollo/pages/tasks.tsx.tmpl +20 -0
- package/stacks/apollo.json +147 -37
- package/stacks/calendly/pages/companies.tsx.tmpl +19 -0
- package/stacks/calendly/pages/contacts.tsx.tmpl +21 -0
- package/stacks/calendly/pages/meetings.tsx.tmpl +13 -0
- package/stacks/calendly/pages/tasks.tsx.tmpl +19 -0
- package/stacks/calendly.json +64 -0
- package/stacks/clay/pages/companies.tsx.tmpl +31 -0
- package/stacks/clay/pages/contacts.tsx.tmpl +31 -0
- package/stacks/clay/pages/lists.tsx.tmpl +12 -0
- package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
- package/stacks/clay.json +73 -27
- package/stacks/gong/pages/calls.tsx.tmpl +23 -0
- package/stacks/gong/pages/companies.tsx.tmpl +22 -0
- package/stacks/gong/pages/contacts.tsx.tmpl +32 -0
- package/stacks/gong/pages/lists.tsx.tmpl +12 -0
- package/stacks/gong/pages/meetings.tsx.tmpl +13 -0
- package/stacks/gong/pages/tasks.tsx.tmpl +19 -0
- package/stacks/gong.json +98 -35
- package/stacks/hootsuite/pages/companies.tsx.tmpl +22 -0
- package/stacks/hootsuite/pages/contacts.tsx.tmpl +28 -0
- package/stacks/hootsuite/pages/lists.tsx.tmpl +12 -0
- package/stacks/hootsuite/pages/tasks.tsx.tmpl +19 -0
- package/stacks/hootsuite.json +104 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +26 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +45 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +20 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +103 -0
- package/stacks/hubspot/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/hubspot/pages/lists.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/meetings.tsx.tmpl +13 -0
- package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
- package/stacks/hubspot/pages/sequences.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
- package/stacks/hubspot.json +181 -64
- package/stacks/intercom/pages/companies.tsx.tmpl +19 -0
- package/stacks/intercom/pages/contacts.tsx.tmpl +21 -0
- package/stacks/intercom/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/intercom/pages/tasks.tsx.tmpl +19 -0
- package/stacks/intercom.json +52 -20
- package/stacks/mailchimp/pages/companies.tsx.tmpl +22 -0
- package/stacks/mailchimp/pages/contacts.tsx.tmpl +28 -0
- package/stacks/mailchimp/pages/lists.tsx.tmpl +12 -0
- package/stacks/mailchimp/pages/tasks.tsx.tmpl +19 -0
- package/stacks/mailchimp.json +68 -34
- package/stacks/outreach/pages/calls.tsx.tmpl +23 -0
- package/stacks/outreach/pages/companies.tsx.tmpl +22 -0
- package/stacks/outreach/pages/contacts.tsx.tmpl +46 -0
- package/stacks/outreach/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/outreach/pages/lists.tsx.tmpl +12 -0
- package/stacks/outreach/pages/meetings.tsx.tmpl +13 -0
- package/stacks/outreach/pages/sequences.tsx.tmpl +12 -0
- package/stacks/outreach/pages/tasks.tsx.tmpl +19 -0
- package/stacks/outreach.json +106 -25
- package/stacks/pipedrive/pages/companies.tsx.tmpl +23 -0
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +27 -0
- package/stacks/pipedrive/pages/deals.tsx.tmpl +20 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +103 -0
- package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
- package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
- package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
- package/stacks/pipedrive.json +117 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +26 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +32 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +20 -0
- package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
- package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
- package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
- package/stacks/salesforce.json +85 -31
- package/stacks/zoominfo/pages/companies.tsx.tmpl +31 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +31 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +12 -0
- package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +115 -0
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +40 -9
- package/templates/next-app/app/globals.css.tmpl +349 -64
- package/templates/next-app/app/layout.tsx.tmpl +7 -2
- package/templates/next-app/components/app-shell.tsx.tmpl +104 -88
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AudienceSyncDetail: the complete screen for one audience sync (a graph8 list sent to an ad platform). Finds it by id (so `?id=`
|
|
5
|
+
* deep links work) and shows status, cadence, last and next run, and the last error. Tabs: Overview, Runs, Errors, Launch campaign.
|
|
6
|
+
* Actions: sync now, pause or resume, launch a campaign that targets the audience, delete.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* 1. listAudienceSyncs GET /audience-syncs (read)
|
|
10
|
+
* 2. listSyncErrors GET /audience-syncs/{id}/errors (read)
|
|
11
|
+
* 3. listSyncRuns GET /audience-syncs/{id}/runs (billable tier, confirmed before loading)
|
|
12
|
+
* 4. triggerAudienceSync POST /audience-syncs/{id}/trigger (external, confirmed)
|
|
13
|
+
* 5. updateAudienceSync PATCH /audience-syncs/{id} (external, confirmed; pause or resume)
|
|
14
|
+
* 6. launchAdCampaign POST /audience-syncs/{id}/launch (external, confirmed with the daily budget; HTTP 200 may carry status "error")
|
|
15
|
+
* 7. deleteAudienceSync DELETE /audience-syncs/{id} (destructive, confirmed; 404 counts as done)
|
|
16
|
+
*/
|
|
17
|
+
import { useState } from "react";
|
|
18
|
+
import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
|
|
19
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
20
|
+
import { FirstNotice } from "@/g8/components/paging-notices";
|
|
21
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
22
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
23
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
24
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
25
|
+
import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
26
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
27
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
28
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
29
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
30
|
+
import { formatInstant, formatMoney, launchOutcome, listOf } from "../lib/social-ads-types";
|
|
31
|
+
import type { AudienceSyncConfig } from "../lib/social-ads-types";
|
|
32
|
+
import { AskButton, useConfirmAction } from "@/g8/components/confirmed-action";
|
|
33
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
34
|
+
|
|
35
|
+
export interface AudienceSyncDetailProps {
|
|
36
|
+
syncId: number;
|
|
37
|
+
onClose?: () => void;
|
|
38
|
+
onExpand?: () => void;
|
|
39
|
+
onCollapse?: () => void;
|
|
40
|
+
onChanged?: () => void;
|
|
41
|
+
/** Called after a campaign was launched. */
|
|
42
|
+
onLaunched?: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SyncRun {
|
|
46
|
+
id: number;
|
|
47
|
+
status?: string | null;
|
|
48
|
+
started_at?: string | null;
|
|
49
|
+
finished_at?: string | null;
|
|
50
|
+
members_added?: number | null;
|
|
51
|
+
members_removed?: number | null;
|
|
52
|
+
total_members?: number | null;
|
|
53
|
+
error_message?: string | null;
|
|
54
|
+
}
|
|
55
|
+
interface SyncError { id: number; error_message?: string; started_at?: string }
|
|
56
|
+
|
|
57
|
+
const EMPTY_LAUNCH = { name: "", objective: "", daily_budget: "", currency: "USD", destination_url: "", creative_image_url: "", headline: "", primary_text: "", start_date: "", end_date: "" };
|
|
58
|
+
|
|
59
|
+
/** The runs and errors APIs have no paging: a full read is announced under the list. */
|
|
60
|
+
const RUN_LIMIT = 20;
|
|
61
|
+
const ERROR_LIMIT = 10;
|
|
62
|
+
|
|
63
|
+
function Runs({ syncId }: { syncId: number }) {
|
|
64
|
+
const [confirm, setConfirm] = useState(false);
|
|
65
|
+
const [runs, setRuns] = useState<SyncRun[] | null>(null);
|
|
66
|
+
const load = useG8Mutation(async () => { setRuns(listOf<SyncRun>((await g8Get(`/audience-syncs/${syncId}/runs`, { limit: RUN_LIMIT })).data, "runs")); });
|
|
67
|
+
return (
|
|
68
|
+
<SectionCard title="Runs" description="Loading past runs is a billable read, so it asks first.">
|
|
69
|
+
{runs == null ? <Button size="sm" variant="outline" onClick={() => setConfirm(true)}>Load runs</Button> : runs.length === 0 ? <p className="text-sm text-muted-foreground">No runs yet.</p> : (
|
|
70
|
+
<ul className="space-y-1 text-sm">
|
|
71
|
+
{runs.map((r) => (
|
|
72
|
+
<li key={r.id} className="flex justify-between gap-3">
|
|
73
|
+
<span>{r.status ?? "unknown"}{r.error_message ? `: ${r.error_message}` : ""}</span>
|
|
74
|
+
<span className="text-xs text-muted-foreground tabular-nums">
|
|
75
|
+
{[r.total_members != null ? `${r.total_members.toLocaleString()} members` : null, r.members_added != null ? `+${r.members_added}` : null, r.members_removed != null ? `-${r.members_removed}` : null, r.started_at ? formatInstant(r.started_at) : null].filter(Boolean).join(" · ")}
|
|
76
|
+
</span>
|
|
77
|
+
</li>
|
|
78
|
+
))}
|
|
79
|
+
</ul>
|
|
80
|
+
)}
|
|
81
|
+
{runs ? <FirstNotice shown={runs.length} limit={RUN_LIMIT} noun="runs" /> : null}
|
|
82
|
+
{load.error ? <ApiError error={load.error} onRetry={load.reset} /> : null}
|
|
83
|
+
<ConfirmSpendDialog open={confirm} tier="billable" title="Load sync runs" description="Listing the runs of a sync is classified billable by the API." confirmLabel="Load runs" confirming={load.loading}
|
|
84
|
+
onCancel={() => setConfirm(false)} onConfirm={() => void load.mutate(undefined).then(() => setConfirm(false))} />
|
|
85
|
+
</SectionCard>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function AudienceSyncDetail({ syncId, onClose, onExpand, onCollapse, onChanged, onLaunched }: AudienceSyncDetailProps) {
|
|
90
|
+
const brand = useBrand();
|
|
91
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
92
|
+
const list = useG8Query(async (signal) => listOf<AudienceSyncConfig>((await g8Get("/audience-syncs", undefined, { signal })).data), []);
|
|
93
|
+
const errors = useG8Query(async (signal) => listOf<SyncError>((await g8Get(`/audience-syncs/${syncId}/errors`, { limit: ERROR_LIMIT }, { signal })).data), [syncId]);
|
|
94
|
+
const [launch, setLaunch] = useState(EMPTY_LAUNCH);
|
|
95
|
+
const [confirmLaunch, setConfirmLaunch] = useState(false);
|
|
96
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
97
|
+
const sync = (list.data ?? []).find((s) => s.id === syncId);
|
|
98
|
+
const changed = () => { void list.refetch(); onChanged?.(); };
|
|
99
|
+
const doLaunch = useG8Mutation(async () => {
|
|
100
|
+
const res = await g8Post(`/audience-syncs/${syncId}/launch`, {
|
|
101
|
+
name: launch.name.trim(), objective: launch.objective.trim() || undefined, daily_budget: Number(launch.daily_budget), currency: launch.currency,
|
|
102
|
+
destination_url: launch.destination_url.trim(), creative_image_url: launch.creative_image_url.trim() || undefined, headline: launch.headline.trim() || undefined,
|
|
103
|
+
primary_text: launch.primary_text.trim() || undefined, start_date: launch.start_date || undefined, end_date: launch.end_date || undefined,
|
|
104
|
+
});
|
|
105
|
+
const outcome = launchOutcome(res.data);
|
|
106
|
+
if (!outcome.ok) throw new Error(outcome.message);
|
|
107
|
+
setLaunch(EMPTY_LAUNCH);
|
|
108
|
+
setNotice(outcome.message ?? "Campaign sent to the platform.");
|
|
109
|
+
onLaunched?.();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (list.error) return <ApiError error={list.error} title="Could not load the sync" onRetry={() => void list.refetch()} />;
|
|
113
|
+
if (!list.data) return <Skeleton className="h-40 w-full" />;
|
|
114
|
+
if (!sync) return <EmptyState title="Sync not found" description="It may have been deleted." />;
|
|
115
|
+
|
|
116
|
+
const datesOk = !launch.start_date || !launch.end_date || launch.end_date >= launch.start_date;
|
|
117
|
+
const ready = launch.name.trim() && Number(launch.daily_budget) > 0 && /^https?:\/\//.test(launch.destination_url.trim()) && datesOk;
|
|
118
|
+
const name = sync.platform_audience_name || `List ${sync.audience_id}`;
|
|
119
|
+
|
|
120
|
+
const actions = (
|
|
121
|
+
<div className="flex flex-wrap gap-1">
|
|
122
|
+
<AskButton ask={ask} label="Sync now" tier="external" title={`Sync "${name}" to ${sync.platform} now`} confirmLabel="Sync now" disabled={!sync.is_active}
|
|
123
|
+
description={`Pushes the current members of the list to ${sync.platform}.`} run={() => g8Post(`/audience-syncs/${syncId}/trigger`, {})} onDone={() => { setNotice("Sync started."); changed(); }} />
|
|
124
|
+
<AskButton ask={ask} label={sync.is_active ? "Pause" : "Resume"} tier="external" title={`${sync.is_active ? "Pause" : "Resume"} the ${sync.platform} sync`} confirmLabel={sync.is_active ? "Pause" : "Resume"}
|
|
125
|
+
description={sync.is_active ? "The audience stops refreshing; people already there stay." : `Resumes refreshing the audience on ${sync.platform}.`}
|
|
126
|
+
run={() => g8Patch(`/audience-syncs/${syncId}`, { is_active: !sync.is_active })} onDone={changed} />
|
|
127
|
+
<AskButton ask={ask} label="Delete" variant="ghost" tier="destructive" title={`Delete the ${sync.platform} sync`} confirmLabel="Delete" tolerateStatus={[404]}
|
|
128
|
+
description={`${brand.linkLabel} stops syncing. The audience that already exists on the platform is not removed from here.`} run={() => g8Delete(`/audience-syncs/${syncId}`)} onDone={() => { changed(); onClose?.(); }} />
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const input = (key: keyof typeof EMPTY_LAUNCH, label: string, type = "text") => (
|
|
133
|
+
<div className="flex flex-col gap-1" key={key}>
|
|
134
|
+
<Label htmlFor={`sd-${key}`}>{label}</Label>
|
|
135
|
+
<Input id={`sd-${key}`} type={type} value={launch[key]} onChange={(e) => setLaunch({ ...launch, [key]: e.target.value })} />
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
const tabs = [
|
|
139
|
+
{
|
|
140
|
+
id: "overview",
|
|
141
|
+
label: "Overview",
|
|
142
|
+
content: (
|
|
143
|
+
<div className="space-y-3">
|
|
144
|
+
<KeyValueList columns={2} items={[
|
|
145
|
+
{ label: "Platform", value: sync.platform },
|
|
146
|
+
{ label: "List", value: `${sync.audience_id}` },
|
|
147
|
+
{ label: "Mode", value: sync.mode },
|
|
148
|
+
{ label: "Refresh every", value: `${sync.refresh_cadence_hours} h` },
|
|
149
|
+
{ label: "Last sync", value: sync.last_sync_at ? formatInstant(sync.last_sync_at) : "not synced yet" },
|
|
150
|
+
{ label: "Next sync", value: sync.next_sync_at ? formatInstant(sync.next_sync_at) : "-" },
|
|
151
|
+
]} />
|
|
152
|
+
{sync.status_detail ? <p className="text-sm text-muted-foreground">{sync.status_detail}</p> : null}
|
|
153
|
+
{sync.last_sync_error ? <p role="alert" className="text-sm text-error-foreground">{sync.last_sync_error}</p> : null}
|
|
154
|
+
{notice ? <p role="status" className="text-sm text-muted-foreground">{notice}</p> : null}
|
|
155
|
+
</div>
|
|
156
|
+
),
|
|
157
|
+
},
|
|
158
|
+
{ id: "runs", label: "Runs", content: <Runs syncId={syncId} /> },
|
|
159
|
+
{
|
|
160
|
+
id: "errors",
|
|
161
|
+
label: "Errors",
|
|
162
|
+
content: errors.loading ? <Skeleton className="h-8 w-full" /> : errors.error ? <ApiError error={errors.error} onRetry={() => void errors.refetch()} /> : (errors.data ?? []).length === 0 ? <p className="text-sm text-muted-foreground">No recent errors.</p> : (
|
|
163
|
+
<><ul className="space-y-1 text-sm">{(errors.data ?? []).map((e) => <li key={e.id}>{e.started_at ? `${formatInstant(e.started_at)}: ` : ""}{e.error_message}</li>)}</ul><FirstNotice shown={(errors.data ?? []).length} limit={ERROR_LIMIT} noun="errors" /></>
|
|
164
|
+
),
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "launch",
|
|
168
|
+
label: "Launch campaign",
|
|
169
|
+
content: (
|
|
170
|
+
<SectionCard title="Launch a campaign on this audience" description={`Creates the campaign on ${sync.platform}. Spend is billed by the platform.`}>
|
|
171
|
+
<form className="grid gap-2 sm:grid-cols-2" onSubmit={(e) => { e.preventDefault(); if (ready) setConfirmLaunch(true); }}>
|
|
172
|
+
{input("name", "Campaign name")}{input("objective", "Objective")}{input("daily_budget", "Daily budget", "number")}{input("destination_url", "Landing page URL")}
|
|
173
|
+
{input("creative_image_url", "Image URL (required on Google)")}{input("headline", "Headline")}{input("primary_text", "Primary text")}
|
|
174
|
+
<div className="flex flex-col gap-1"><Label htmlFor="sd-currency">Currency</Label>
|
|
175
|
+
<select id="sd-currency" className="h-9 rounded-md border bg-background px-2" value={launch.currency} onChange={(e) => setLaunch({ ...launch, currency: e.target.value })}>{["USD", "EUR", "GBP", "CAD", "AUD"].map((c) => <option key={c} value={c}>{c}</option>)}</select></div>
|
|
176
|
+
{input("start_date", "Start date", "date")}{input("end_date", "End date", "date")}
|
|
177
|
+
<div><Button type="submit" size="sm" disabled={!ready || doLaunch.loading}>Review launch</Button>{!datesOk ? <p role="alert" className="mt-1 text-xs text-error-foreground">The end date is before the start date.</p> : null}</div>
|
|
178
|
+
</form>
|
|
179
|
+
{doLaunch.error ? <ApiError error={doLaunch.error} title="Launch failed" /> : null}
|
|
180
|
+
<ConfirmSpendDialog open={confirmLaunch} tier="external" title={`Launch a campaign on ${sync.platform}`} confirmLabel="Launch" confirming={doLaunch.loading}
|
|
181
|
+
description={`Creates the campaign on ${sync.platform}, targeting the synced audience, at ${formatMoney(Number(launch.daily_budget), launch.currency)} per day. Once the platform approves it, it spends from your ad account; ${brand.linkLabel} credits are not used for that spend.`}
|
|
182
|
+
onCancel={() => setConfirmLaunch(false)} onConfirm={() => void doLaunch.mutate(undefined).then(() => setConfirmLaunch(false))} />
|
|
183
|
+
</SectionCard>
|
|
184
|
+
),
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
return (
|
|
188
|
+
<>
|
|
189
|
+
<DetailPanel title={name} subtitle={`on ${sync.platform}`} badges={<StatusPill tone={sync.is_active ? "positive" : "neutral"}>{sync.is_active ? sync.status ?? "active" : "paused"}</StatusPill>} actions={actions} tabs={tabs} onClose={onClose} onExpand={onExpand} onCollapse={onCollapse} />
|
|
190
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
191
|
+
<ConfirmSpendDialog {...dialog} />
|
|
192
|
+
</>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { useState } from "react";
|
|
24
24
|
import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
|
|
25
25
|
import { ApiError } from "@/g8/components/api-error";
|
|
26
|
+
import { FirstNotice } from "@/g8/components/paging-notices";
|
|
26
27
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
27
28
|
import type { ConfirmTier } from "@/g8/components/confirm-spend-dialog";
|
|
28
29
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
@@ -32,10 +33,13 @@ import { ListPicker } from "@/g8/lists/components/list-picker";
|
|
|
32
33
|
import type { ListSummary } from "@/g8/lists/components/list-picker";
|
|
33
34
|
import { AD_PLATFORMS, formatInstant, formatMoney, launchOutcome, listOf } from "../lib/social-ads-types";
|
|
34
35
|
import type { AudienceSyncConfig } from "../lib/social-ads-types";
|
|
36
|
+
import { DEFAULT_BRAND, useBrand } from "@/g8/hooks/use-brand";
|
|
35
37
|
|
|
36
38
|
export interface AudienceSyncProps {
|
|
37
39
|
/** Called after a campaign was launched, for example to refresh an AdCampaignList. */
|
|
38
40
|
onLaunched?: () => void;
|
|
41
|
+
/** Called when a person opens a sync (show an AudienceSyncDetail). */
|
|
42
|
+
onSelectSync?: (sync: AudienceSyncConfig) => void;
|
|
39
43
|
className?: string;
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -46,7 +50,11 @@ type Pending =
|
|
|
46
50
|
|
|
47
51
|
const listSize = (l: ListSummary) => l.total ?? l.contact_count ?? l.company_count ?? undefined;
|
|
48
52
|
|
|
49
|
-
|
|
53
|
+
/** The errors API has no paging: a full read is announced under the list. */
|
|
54
|
+
const ERROR_LIMIT = 5;
|
|
55
|
+
|
|
56
|
+
export function AudienceSync({ onLaunched, onSelectSync, className }: AudienceSyncProps) {
|
|
57
|
+
const brand = useBrand();
|
|
50
58
|
const [list, setList] = useState<ListSummary | null>(null);
|
|
51
59
|
const [platform, setPlatform] = useState<string>("meta");
|
|
52
60
|
const [mode, setMode] = useState("mirror");
|
|
@@ -59,7 +67,7 @@ export function AudienceSync({ onLaunched, className }: AudienceSyncProps) {
|
|
|
59
67
|
const [notice, setNotice] = useState<string | null>(null);
|
|
60
68
|
|
|
61
69
|
const syncs = useG8Query(async (signal) => listOf<AudienceSyncConfig>((await g8Get("/audience-syncs", undefined, { signal })).data), []);
|
|
62
|
-
const errors = useG8Query(async (signal) => listOf<{ id: number; error_message?: string; started_at?: string }>((await g8Get(`/audience-syncs/${errorsFor}/errors`, { limit:
|
|
70
|
+
const errors = useG8Query(async (signal) => listOf<{ id: number; error_message?: string; started_at?: string }>((await g8Get(`/audience-syncs/${errorsFor}/errors`, { limit: ERROR_LIMIT }, { signal })).data), [errorsFor], { enabled: errorsFor != null });
|
|
63
71
|
|
|
64
72
|
const work = async (id: number | "new", fn: () => Promise<unknown>, tolerate404 = false) => {
|
|
65
73
|
setBusy(id);
|
|
@@ -87,7 +95,7 @@ export function AudienceSync({ onLaunched, className }: AudienceSyncProps) {
|
|
|
87
95
|
|
|
88
96
|
const datesOk = !launch.start_date || !launch.end_date || launch.end_date >= launch.start_date; // YYYY-MM-DD compares as text
|
|
89
97
|
const readyToLaunch = launch.name.trim() && Number(launch.daily_budget) > 0 && /^https?:\/\//.test(launch.destination_url.trim()) && datesOk;
|
|
90
|
-
const meta = describe(pending, platform, formatMoney(Number(launch.daily_budget), launch.currency));
|
|
98
|
+
const meta = describe(pending, platform, formatMoney(Number(launch.daily_budget), launch.currency), brand.linkLabel);
|
|
91
99
|
return (
|
|
92
100
|
<div className={`flex flex-col gap-5 ${className ?? ""}`}>
|
|
93
101
|
<section className="flex flex-col gap-2 rounded-md border p-3" aria-label="New audience sync">
|
|
@@ -104,12 +112,12 @@ export function AudienceSync({ onLaunched, className }: AudienceSyncProps) {
|
|
|
104
112
|
{notice ? <p role="status" className="text-sm text-muted-foreground">{notice}</p> : null}
|
|
105
113
|
{syncs.error ? <ApiError error={syncs.error} title="Could not load syncs" onRetry={() => void syncs.refetch()} /> : null}
|
|
106
114
|
{syncs.loading && !syncs.data ? <Skeleton className="h-20 w-full" /> : null}
|
|
107
|
-
{!syncs.loading && !syncs.error && (syncs.data ?? []).length === 0 ? <EmptyState title="No audience syncs yet" description=
|
|
115
|
+
{!syncs.loading && !syncs.error && (syncs.data ?? []).length === 0 ? <EmptyState title="No audience syncs yet" description={`Pick a list above and the platform to send it to. The ad account must be connected in ${brand.linkLabel} first.`} /> : null}
|
|
108
116
|
<ul className="flex flex-col gap-2">
|
|
109
117
|
{(syncs.data ?? []).map((s) => (
|
|
110
118
|
<li key={s.id} className="flex flex-col gap-2 rounded-md border p-3" data-testid="sync" aria-busy={busy === s.id}>
|
|
111
119
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
112
|
-
<div
|
|
120
|
+
<div>{onSelectSync ? <button type="button" className="text-left text-sm font-medium hover:underline" onClick={() => onSelectSync(s)}>{s.platform_audience_name || `List ${s.audience_id}`} on {s.platform}</button> : <p className="text-sm font-medium">{s.platform_audience_name || `List ${s.audience_id}`} on {s.platform}</p>}
|
|
113
121
|
<p className="text-xs text-muted-foreground">{s.is_active ? s.status ?? "active" : "paused"} · {s.last_sync_at ? `last synced ${formatInstant(s.last_sync_at)}` : "not synced yet"}{s.last_sync_error ? ` · ${s.last_sync_error}` : ""}</p></div>
|
|
114
122
|
<div className="flex flex-wrap gap-1">
|
|
115
123
|
<Button size="sm" variant="outline" loading={busy === s.id} disabled={busy != null || !s.is_active} onClick={() => setPending({ kind: "trigger", sync: s })}>Sync now</Button>
|
|
@@ -119,7 +127,7 @@ export function AudienceSync({ onLaunched, className }: AudienceSyncProps) {
|
|
|
119
127
|
<Button size="sm" variant="ghost" disabled={busy != null} onClick={() => setPending({ kind: "delete", sync: s })}>Delete</Button>
|
|
120
128
|
</div>
|
|
121
129
|
</div>
|
|
122
|
-
{errorsFor === s.id ? <div className="text-xs">{errors.loading ? "Loading..." : (errors.data ?? []).length === 0 ? "No recent errors." :
|
|
130
|
+
{errorsFor === s.id ? <div className="text-xs">{errors.loading ? "Loading..." : (errors.data ?? []).length === 0 ? "No recent errors." : <><ul>{(errors.data ?? []).map((e) => <li key={e.id}>{formatInstant(e.started_at)}: {e.error_message}</li>)}</ul><FirstNotice shown={(errors.data ?? []).length} limit={ERROR_LIMIT} noun="errors" /></>}</div> : null}
|
|
123
131
|
{launch.syncId === s.id ? (
|
|
124
132
|
<form className="grid gap-2 sm:grid-cols-2" onSubmit={(e) => { e.preventDefault(); if (readyToLaunch) setPending({ kind: "launch", sync: s }); }}>
|
|
125
133
|
{([["name", "Campaign name"], ["objective", "Objective"], ["daily_budget", "Daily budget"], ["destination_url", "Landing page URL"], ["creative_image_url", "Image URL (required on Google)"], ["headline", "Headline"], ["primary_text", "Primary text"]] as const).map(([key, label]) => (
|
|
@@ -139,12 +147,12 @@ export function AudienceSync({ onLaunched, className }: AudienceSyncProps) {
|
|
|
139
147
|
);
|
|
140
148
|
}
|
|
141
149
|
|
|
142
|
-
function describe(p: Pending | null, platform: string, budget: string): { tier: ConfirmTier; title: string; text: string; label: string; recipients?: number; affected?: number } {
|
|
150
|
+
function describe(p: Pending | null, platform: string, budget: string, brandLabel: string = DEFAULT_BRAND.linkLabel): { tier: ConfirmTier; title: string; text: string; label: string; recipients?: number; affected?: number } {
|
|
143
151
|
if (!p) return { tier: "external", title: "", text: "", label: "Confirm" };
|
|
144
152
|
if (p.kind === "create") return { tier: "external", title: `Send "${p.list.title}" to ${platform}`, label: "Create sync", recipients: listSize(p.list), text: `Sends the contacts of this list to ${platform} as a custom audience (matched on hashed identifiers) and keeps it refreshed. Anyone matched becomes targetable by your ads there.` };
|
|
145
153
|
const s = p.sync;
|
|
146
154
|
if (p.kind === "trigger") return { tier: "external", title: `Sync "${s.platform_audience_name || s.audience_id}" to ${s.platform} now`, label: "Sync now", text: `Pushes the current members of the list to ${s.platform}.` };
|
|
147
155
|
if (p.kind === "toggle") return { tier: "external", title: `${s.is_active ? "Pause" : "Resume"} the ${s.platform} sync`, label: s.is_active ? "Pause" : "Resume", text: s.is_active ? "The audience stops refreshing; people already there stay." : `Resumes refreshing the audience on ${s.platform}.` };
|
|
148
|
-
if (p.kind === "delete") return { tier: "destructive", title: `Delete the ${s.platform} sync`, label: "Delete", text:
|
|
149
|
-
return { tier: "external", title: `Launch a campaign on ${s.platform}`, label: "Launch", text: `Creates the campaign on ${s.platform}, targeting the synced audience, at ${budget} per day. Once the platform approves it, it spends from your ad account;
|
|
156
|
+
if (p.kind === "delete") return { tier: "destructive", title: `Delete the ${s.platform} sync`, label: "Delete", text: `${brandLabel} stops syncing. The audience that already exists on the platform is not removed from here.` };
|
|
157
|
+
return { tier: "external", title: `Launch a campaign on ${s.platform}`, label: "Launch", text: `Creates the campaign on ${s.platform}, targeting the synced audience, at ${budget} per day. Once the platform approves it, it spends from your ad account; ${brandLabel} credits are not used for that spend.` };
|
|
150
158
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CampaignAds: the individual ads inside one campaign. See each ad's copy and state, edit an ad, pause or activate it,
|
|
5
|
+
* archive it, and add a new one.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in the order the screen uses them:
|
|
8
|
+
* 1. listCampaignAds GET /ad-campaigns/{id}/ads?include_archived (read)
|
|
9
|
+
* 2. updateCampaignAd PATCH /ad-campaigns/{id}/ads/{ad} (EXTERNAL, confirmed: changes the live ad; only changed fields are sent)
|
|
10
|
+
* 3. setCampaignAdStatus POST /ad-campaigns/{id}/ads/{ad}/status { status } (EXTERNAL, confirmed: starts or stops delivery and spend)
|
|
11
|
+
* 4. archiveCampaignAd DELETE /ad-campaigns/{id}/ads/{ad} (destructive, confirmed: takes the ad down)
|
|
12
|
+
* 5. createCampaignAd POST /ad-campaigns/{id}/ads (EXTERNAL, confirmed: adds an ad to the live campaign)
|
|
13
|
+
*
|
|
14
|
+
* Editing a live ad can make the platform replace it and send it to review again; the dialog says so, and the
|
|
15
|
+
* `consequences` the API returns after an edit are shown. The edit form starts from the loaded ad and sends only what
|
|
16
|
+
* changed.
|
|
17
|
+
*/
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
|
|
20
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
21
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
22
|
+
import { useConfirmAction } from "@/g8/components/confirmed-action";
|
|
23
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
24
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
25
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
26
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
27
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
28
|
+
import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
29
|
+
import { asRec, num, rowsOf, str } from "../lib/ads-extra";
|
|
30
|
+
import type { Rec } from "../lib/ads-extra";
|
|
31
|
+
|
|
32
|
+
export interface CampaignAdsProps {
|
|
33
|
+
campaignId: number;
|
|
34
|
+
/** The platform the campaign runs on, for the confirm text. */
|
|
35
|
+
platform?: string;
|
|
36
|
+
onChanged?: () => void;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const FIELDS = [
|
|
41
|
+
{ key: "name", label: "Ad name" },
|
|
42
|
+
{ key: "headline", label: "Headline" },
|
|
43
|
+
{ key: "primary_text", label: "Primary text" },
|
|
44
|
+
{ key: "description", label: "Description" },
|
|
45
|
+
{ key: "cta", label: "Call to action" },
|
|
46
|
+
{ key: "destination_url", label: "Destination URL" },
|
|
47
|
+
] as const;
|
|
48
|
+
type Key = (typeof FIELDS)[number]["key"];
|
|
49
|
+
|
|
50
|
+
const tone = (s: string) => (["active", "approved", "running"].includes(s) ? "positive" : ["failed", "rejected", "disapproved"].includes(s) ? "negative" : s === "paused" || s === "archived" ? "neutral" : "info") as "positive" | "negative" | "neutral" | "info";
|
|
51
|
+
|
|
52
|
+
/** The edit body: only fields whose text differs from the saved ad. A URL must be http(s) when changed. */
|
|
53
|
+
export function adChanges(saved: Rec, draft: Record<Key, string>): Partial<Record<Key, string>> {
|
|
54
|
+
const out: Partial<Record<Key, string>> = {};
|
|
55
|
+
for (const { key } of FIELDS) if (draft[key].trim() !== str(saved[key]).trim()) out[key] = draft[key].trim();
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const isHttpUrl = (raw: string): boolean => /^https?:\/\/\S+\.\S+$/i.test(raw.trim());
|
|
60
|
+
|
|
61
|
+
function AdEditor({ ad, base, onClose, onSaved }: { ad: Rec; base: string; onClose: () => void; onSaved: () => void }) {
|
|
62
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
63
|
+
const saved: Record<Key, string> = { name: str(ad.name), headline: str(ad.headline), primary_text: str(ad.primary_text), description: str(ad.description), cta: str(ad.cta), destination_url: str(ad.destination_url) };
|
|
64
|
+
const [edits, setEdits] = useState<Partial<Record<Key, string>>>({});
|
|
65
|
+
const draft = { ...saved, ...edits };
|
|
66
|
+
const changes = adChanges(ad, draft);
|
|
67
|
+
const urlBad = "destination_url" in changes && changes.destination_url !== "" && !isHttpUrl(changes.destination_url ?? "");
|
|
68
|
+
const [note, setNote] = useState("");
|
|
69
|
+
const save = (): void => ask({
|
|
70
|
+
tier: "external", title: `Save changes to "${str(ad.name) || "this ad"}"`, confirmLabel: "Save to the platform",
|
|
71
|
+
description: "This changes the ad on the platform. The platform may replace the ad and review it again, so delivery can pause for a while.",
|
|
72
|
+
run: async () => {
|
|
73
|
+
const d = asRec((await g8Patch(`${base}/ads/${encodeURIComponent(str(ad.id))}`, changes)).data);
|
|
74
|
+
const lines = rowsOf<string>(d.consequences).map(String);
|
|
75
|
+
setNote(`Saved.${d.replaced_platform_ad === true ? " The platform ad was replaced." : ""}${lines.length ? ` ${lines.join(" ")}` : ""}`);
|
|
76
|
+
setEdits({});
|
|
77
|
+
},
|
|
78
|
+
onDone: onSaved,
|
|
79
|
+
});
|
|
80
|
+
return (
|
|
81
|
+
<div className="grid gap-3">
|
|
82
|
+
{FIELDS.map(({ key, label }) => (
|
|
83
|
+
<div key={key}><Label htmlFor={`ca-${key}`} className="mb-1">{label}</Label><Input id={`ca-${key}`} value={draft[key]} aria-invalid={key === "destination_url" && urlBad} onChange={(e) => setEdits({ ...edits, [key]: e.target.value })} /></div>
|
|
84
|
+
))}
|
|
85
|
+
{urlBad ? <p role="alert" className="text-error-foreground text-xs">Enter a full address starting with https://.</p> : null}
|
|
86
|
+
{str(ad.approval_nuance) ? <p className="text-muted-foreground text-xs">{str(ad.approval_nuance)}</p> : null}
|
|
87
|
+
{note ? <p role="status" className="text-sm">{note}</p> : null}
|
|
88
|
+
{failure ? <ApiError error={failure} title="The platform did not accept the change" /> : null}
|
|
89
|
+
<div className="flex justify-end gap-2"><Button variant="outline" onClick={onClose}>Close</Button><Button disabled={Object.keys(changes).length === 0 || urlBad} onClick={save}>Save changes</Button></div>
|
|
90
|
+
<ConfirmSpendDialog {...dialog} />
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function CampaignAds({ campaignId, platform, onChanged, className }: CampaignAdsProps) {
|
|
96
|
+
const base = `/ad-campaigns/${encodeURIComponent(String(campaignId))}`;
|
|
97
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
98
|
+
const [archived, setArchived] = useState(false);
|
|
99
|
+
const [editing, setEditing] = useState<string | null>(null);
|
|
100
|
+
const [adding, setAdding] = useState(false);
|
|
101
|
+
const [fresh, setFresh] = useState<Record<Key, string>>({ name: "", headline: "", primary_text: "", description: "", cta: "", destination_url: "" });
|
|
102
|
+
const list = useG8Query((signal) => g8Get(`${base}/ads`, { include_archived: archived || undefined }, { signal }), [campaignId, archived]);
|
|
103
|
+
const ads = rowsOf<Rec>(asRec(list.data?.data).ads);
|
|
104
|
+
const opened = ads.find((a) => str(a.id) === editing);
|
|
105
|
+
const changed = (): void => { void list.refetch(); onChanged?.(); };
|
|
106
|
+
const where = platform || "the platform";
|
|
107
|
+
const freshBody = Object.fromEntries(Object.entries(fresh).filter(([, v]) => v.trim() !== "").map(([k, v]) => [k, v.trim()]));
|
|
108
|
+
const freshBad = fresh.destination_url.trim() !== "" && !isHttpUrl(fresh.destination_url);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={className}>
|
|
112
|
+
<SectionCard title="Ads in this campaign" description="Each ad is a version of the copy and image that is shown."
|
|
113
|
+
actions={<div className="flex items-center gap-2"><label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={archived} onChange={(e) => setArchived(e.target.checked)} />Show archived</label><Button size="sm" onClick={() => setAdding(true)}>Add an ad</Button></div>}>
|
|
114
|
+
{list.error ? <ApiError error={list.error} title="Could not load the ads" onRetry={() => void list.refetch()} /> : list.loading && !list.data ? <Skeleton className="h-24 w-full" aria-busy="true" /> : ads.length === 0 ? (
|
|
115
|
+
<EmptyState compact title="No ads yet" description="Add an ad to this campaign, or generate creatives in the Creatives tab." />
|
|
116
|
+
) : (
|
|
117
|
+
<ul className="grid gap-2">
|
|
118
|
+
{ads.map((a) => {
|
|
119
|
+
const status = str(a.status);
|
|
120
|
+
const live = status === "active";
|
|
121
|
+
return (
|
|
122
|
+
<li key={str(a.id)} className="grid gap-2 rounded-md border p-3">
|
|
123
|
+
<div className="flex items-center justify-between gap-2">
|
|
124
|
+
<div className="min-w-0"><span className="block truncate text-sm font-medium">{str(a.headline) || str(a.name) || `Ad ${str(a.slot_index)}`}</span><span className="text-muted-foreground line-clamp-2 text-xs">{str(a.primary_text)}</span></div>
|
|
125
|
+
<StatusPill tone={tone(status)}>{status || "unknown"}</StatusPill>
|
|
126
|
+
</div>
|
|
127
|
+
{str(a.error_message) ? <p role="alert" className="text-error-foreground text-xs">{str(a.error_message)}</p> : null}
|
|
128
|
+
{status !== "archived" ? (
|
|
129
|
+
<div className="flex flex-wrap gap-1">
|
|
130
|
+
<Button size="sm" variant="outline" aria-label={`Edit ${str(a.name) || str(a.headline) || "ad"}`} onClick={() => setEditing(str(a.id))}>Edit</Button>
|
|
131
|
+
{live || status === "paused" ? <Button size="sm" variant="outline" aria-label={`${live ? "Pause" : "Activate"} ${str(a.name) || str(a.headline) || "ad"}`} onClick={() => ask({
|
|
132
|
+
tier: "external", title: `${live ? "Pause" : "Activate"} "${str(a.name) || str(a.headline) || "this ad"}"`, confirmLabel: live ? "Pause ad" : "Activate ad",
|
|
133
|
+
description: live ? `The ad stops being shown on ${where}.` : `The ad starts being shown on ${where} and spends from the campaign budget once approved.`,
|
|
134
|
+
run: () => g8Post(`${base}/ads/${encodeURIComponent(str(a.id))}/status`, { status: live ? "paused" : "active" }), onDone: changed,
|
|
135
|
+
})}>{live ? "Pause" : "Activate"}</Button> : null}
|
|
136
|
+
<Button size="sm" variant="ghost" aria-label={`Archive ${str(a.name) || str(a.headline) || "ad"}`} onClick={() => ask({
|
|
137
|
+
tier: "destructive", title: `Archive "${str(a.name) || str(a.headline) || "this ad"}"`, confirmLabel: "Archive ad", affected: 1,
|
|
138
|
+
description: `The ad is taken down on ${where} and hidden here. It can be shown again with "Show archived".`, tolerateStatus: [404],
|
|
139
|
+
run: () => g8Delete(`${base}/ads/${encodeURIComponent(str(a.id))}`), onDone: changed,
|
|
140
|
+
})}>Archive</Button>
|
|
141
|
+
</div>
|
|
142
|
+
) : null}
|
|
143
|
+
</li>
|
|
144
|
+
);
|
|
145
|
+
})}
|
|
146
|
+
</ul>
|
|
147
|
+
)}
|
|
148
|
+
{num(asRec(list.data?.data).count) !== undefined && num(asRec(list.data?.data).count)! > ads.length ? <p role="note" className="text-muted-foreground mt-2 text-xs">Showing {ads.length} of {String(asRec(list.data?.data).count)} ads.</p> : null}
|
|
149
|
+
</SectionCard>
|
|
150
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
151
|
+
<FormSheet open={Boolean(opened)} onOpenChange={(o) => { if (!o) setEditing(null); }} title="Edit ad" description="Only the fields you change are sent." width="lg">
|
|
152
|
+
{opened ? <AdEditor key={str(opened.id)} ad={opened} base={base} onClose={() => setEditing(null)} onSaved={changed} /> : null}
|
|
153
|
+
</FormSheet>
|
|
154
|
+
<FormSheet open={adding} onOpenChange={setAdding} title="Add an ad" description={`Adds an ad to this live campaign on ${where}.`} width="lg"
|
|
155
|
+
footer={<><Button variant="outline" onClick={() => setAdding(false)}>Cancel</Button><Button disabled={Object.keys(freshBody).length === 0 || freshBad} onClick={() => ask({
|
|
156
|
+
tier: "external", title: "Add this ad to the campaign", confirmLabel: "Add ad",
|
|
157
|
+
description: `The ad is created on ${where} and goes to review. It may start spending once approved.`,
|
|
158
|
+
run: async () => { await g8Post(`${base}/ads`, freshBody); setFresh({ name: "", headline: "", primary_text: "", description: "", cta: "", destination_url: "" }); setAdding(false); }, onDone: changed,
|
|
159
|
+
})}>Add ad</Button></>}>
|
|
160
|
+
{FIELDS.map(({ key, label }) => <div key={key}><Label htmlFor={`cn-${key}`} className="mb-1">{label}</Label><Input id={`cn-${key}`} value={fresh[key]} onChange={(e) => setFresh({ ...fresh, [key]: e.target.value })} /></div>)}
|
|
161
|
+
{freshBad ? <p role="alert" className="text-error-foreground text-xs">Enter a full address starting with https://.</p> : null}
|
|
162
|
+
</FormSheet>
|
|
163
|
+
<ConfirmSpendDialog {...dialog} />
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CampaignDetail: the complete screen for one ad campaign. Finds the campaign by id (so `?id=` deep links work) and shows
|
|
5
|
+
* every field plus its results for a window, and the actions that fit it: refresh its review status, retry a failed launch,
|
|
6
|
+
* stop and delete. The Ads tab lists, edits, pauses, archives and adds the individual ads (CampaignAds); the Edit tab
|
|
7
|
+
* previews and applies changes to the live campaign (CampaignEditor). Date-only values are shown in UTC; spend is in the campaign's own currency.
|
|
8
|
+
*
|
|
9
|
+
* SDK operations, in order:
|
|
10
|
+
* 1. listAdCampaigns GET /ad-campaigns (read)
|
|
11
|
+
* 2. getPerCampaignMetrics GET /ad-campaigns/metrics?days (read)
|
|
12
|
+
* 3. refreshCampaignStatus POST /ad-campaigns/{id}/status-refresh (external, confirmed)
|
|
13
|
+
* 4. retryCampaignLaunch POST /ad-campaigns/{id}/retry (external, confirmed; HTTP 200 may carry status "error")
|
|
14
|
+
* 5. deleteAdCampaign DELETE /ad-campaigns/{id} (destructive, confirmed; 502 = still live on the platform)
|
|
15
|
+
*/
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { Skeleton } from "@graph8/sdk/ui";
|
|
18
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
19
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
20
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
21
|
+
import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
22
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
23
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
24
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
25
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
26
|
+
import { formatDateOnly, formatInstant, formatMoney, launchOutcome, listOf, metricsFor } from "../lib/social-ads-types";
|
|
27
|
+
import type { AdCampaign, CampaignMetric } from "../lib/social-ads-types";
|
|
28
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
29
|
+
import { AskButton, useConfirmAction } from "@/g8/components/confirmed-action";
|
|
30
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
31
|
+
import { CampaignAds } from "./campaign-ads";
|
|
32
|
+
import { CampaignEditor } from "./campaign-editor";
|
|
33
|
+
|
|
34
|
+
export interface CampaignDetailProps {
|
|
35
|
+
campaignId: number;
|
|
36
|
+
onClose?: () => void;
|
|
37
|
+
onExpand?: () => void;
|
|
38
|
+
onCollapse?: () => void;
|
|
39
|
+
onChanged?: () => void;
|
|
40
|
+
/** Open the audience sync this campaign targets. */
|
|
41
|
+
onOpenSync?: (syncId: number) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tone = (s: string) => (["active", "approved", "running"].includes(s) ? "positive" : s === "failed" || s === "rejected" ? "negative" : s === "paused" ? "neutral" : "info") as "positive" | "negative" | "neutral" | "info";
|
|
45
|
+
|
|
46
|
+
export function CampaignDetail({ campaignId, onClose, onExpand, onCollapse, onChanged, onOpenSync }: CampaignDetailProps) {
|
|
47
|
+
const brand = useBrand();
|
|
48
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
49
|
+
const [days, setDays] = useState(30);
|
|
50
|
+
const list = useG8Query(async (signal) => listOf<AdCampaign>((await g8Get("/ad-campaigns", undefined, { signal })).data), []);
|
|
51
|
+
const metrics = useG8Query(async (signal) => listOf<CampaignMetric>((await g8Get("/ad-campaigns/metrics", { days }, { signal })).data, "metrics"), [days]);
|
|
52
|
+
const campaign = (list.data ?? []).find((c) => c.id === campaignId);
|
|
53
|
+
const reload = () => {
|
|
54
|
+
void list.refetch();
|
|
55
|
+
void metrics.refetch();
|
|
56
|
+
onChanged?.();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (list.error) return <ApiError error={list.error} title="Could not load the campaign" onRetry={() => void list.refetch()} />;
|
|
60
|
+
if (!list.data) return <Skeleton className="h-40 w-full" />;
|
|
61
|
+
if (!campaign) return <EmptyState title="Campaign not found" description="It may have been deleted." />;
|
|
62
|
+
const m = metricsFor(campaign, metrics.data ?? []);
|
|
63
|
+
|
|
64
|
+
const actions = (
|
|
65
|
+
<div className="flex flex-wrap gap-1">
|
|
66
|
+
<AskButton ask={ask} label="Refresh status" tier="external" title={`Refresh "${campaign.name}"`} confirmLabel="Refresh"
|
|
67
|
+
description={`Asks ${campaign.platform} for this campaign's current review state.`} run={() => g8Post(`/ad-campaigns/${campaign.id}/status-refresh`, {})} onDone={reload} />
|
|
68
|
+
{campaign.status === "failed" ? (
|
|
69
|
+
<AskButton ask={ask} label="Retry launch" tier="external" title={`Retry launching "${campaign.name}"`} confirmLabel="Retry launch"
|
|
70
|
+
description={`Sends the campaign to ${campaign.platform} again. It may start spending on that platform once approved.`}
|
|
71
|
+
run={async () => {
|
|
72
|
+
const outcome = launchOutcome((await g8Post(`/ad-campaigns/${campaign.id}/retry`, {})).data);
|
|
73
|
+
if (!outcome.ok) throw new Error(outcome.message);
|
|
74
|
+
}}
|
|
75
|
+
onDone={reload} />
|
|
76
|
+
) : null}
|
|
77
|
+
<AskButton ask={ask} label="Stop and delete" variant="ghost" tier="destructive" title={`Stop and delete "${campaign.name}"`} confirmLabel="Stop and delete"
|
|
78
|
+
description={`Stops the campaign on ${campaign.platform} first, then removes it from ${brand.linkLabel} together with its history. This cannot be undone. If ${campaign.platform} refuses to stop it, the campaign stays live and keeps spending.`}
|
|
79
|
+
tolerateStatus={[404]}
|
|
80
|
+
mapError={(err) => ((err as { status?: number })?.status === 502 ? new Error(`${campaign.platform} refused to stop "${campaign.name}". It is STILL LIVE and may keep spending: stop it in the platform's own ads manager.`) : err)}
|
|
81
|
+
run={() => g8Delete(`/ad-campaigns/${campaign.id}`)} onDone={() => { reload(); onClose?.(); }} />
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const tabs = [
|
|
86
|
+
{
|
|
87
|
+
id: "overview",
|
|
88
|
+
label: "Overview",
|
|
89
|
+
content: (
|
|
90
|
+
<div className="space-y-4">
|
|
91
|
+
<KeyValueList columns={2} items={[
|
|
92
|
+
{ label: "Platform", value: campaign.platform },
|
|
93
|
+
{ label: "Objective", value: campaign.objective ?? "-" },
|
|
94
|
+
{ label: "Daily budget", value: formatMoney(campaign.daily_budget, campaign.currency) },
|
|
95
|
+
{ label: "Ads", value: campaign.ad_count ?? "-" },
|
|
96
|
+
{ label: "Starts", value: formatDateOnly(campaign.start_date) || "-" },
|
|
97
|
+
{ label: "Ends", value: formatDateOnly(campaign.end_date) || "-" },
|
|
98
|
+
{ label: "Launched", value: campaign.launched_at ? formatInstant(campaign.launched_at) : "-" },
|
|
99
|
+
{ label: "Platform campaign id", value: campaign.platform_campaign_id ?? "-" },
|
|
100
|
+
]} />
|
|
101
|
+
{onOpenSync ? <button type="button" className="text-sm underline" onClick={() => onOpenSync(campaign.sync_config_id)}>Open its audience sync</button> : null}
|
|
102
|
+
</div>
|
|
103
|
+
),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "results",
|
|
107
|
+
label: "Results",
|
|
108
|
+
content: (
|
|
109
|
+
<SectionCard title="Results" description={`Last ${days} days`} actions={
|
|
110
|
+
<select aria-label="Results window" className="h-8 rounded-md border bg-background px-2 text-sm" value={days} onChange={(e) => setDays(Number(e.target.value))}>
|
|
111
|
+
{[7, 30, 90].map((d) => <option key={d} value={d}>Last {d} days</option>)}
|
|
112
|
+
</select>
|
|
113
|
+
}>
|
|
114
|
+
{metrics.error ? <p className="text-sm text-muted-foreground">Results are unavailable right now.</p> : m ? (
|
|
115
|
+
<KeyValueList columns={2} items={[
|
|
116
|
+
{ label: "Spend", value: formatMoney(m.spend, m.currency ?? campaign.currency) },
|
|
117
|
+
{ label: "Impressions", value: m.impressions.toLocaleString() },
|
|
118
|
+
{ label: "Clicks", value: m.clicks.toLocaleString() },
|
|
119
|
+
{ label: "Conversions", value: m.conversions != null ? m.conversions.toLocaleString() : "-" },
|
|
120
|
+
{ label: "CTR", value: m.impressions ? `${((m.clicks / m.impressions) * 100).toFixed(2)}%` : "-" },
|
|
121
|
+
{ label: "CPC", value: m.clicks ? formatMoney(m.spend / m.clicks, m.currency ?? campaign.currency) : "-" },
|
|
122
|
+
]} />
|
|
123
|
+
) : <p className="text-sm text-muted-foreground">No results yet for this window.</p>}
|
|
124
|
+
</SectionCard>
|
|
125
|
+
),
|
|
126
|
+
},
|
|
127
|
+
{ id: "ads", label: "Ads", content: <CampaignAds campaignId={campaign.id} platform={campaign.platform} onChanged={reload} /> },
|
|
128
|
+
{ id: "edit", label: "Edit", content: <CampaignEditor campaignId={campaign.id} onChanged={reload} /> },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<>
|
|
133
|
+
<DetailPanel title={campaign.name} subtitle={campaign.platform} badges={<StatusPill tone={tone(campaign.status)}>{campaign.status}</StatusPill>} actions={actions} tabs={tabs} onClose={onClose} onExpand={onExpand} onCollapse={onCollapse} />
|
|
134
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
135
|
+
<ConfirmSpendDialog {...dialog} />
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|