@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
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Composes: ScreenLayout (title, tabs, stats), Toolbar, SplitView + DetailPanel (selected issue, deep link
|
|
7
7
|
* `?id=`), FormSheet (new newsletter), IssueForm (write, AI, test, delete), IssueScheduler (send, schedule,
|
|
8
|
-
* pause, resume, cancel), SubscriberTable (add, import a list, verify, unsubscribe, erase), NewsletterStats
|
|
8
|
+
* pause, resume, cancel), SubscriberTable (add, import a list, verify, unsubscribe, erase), NewsletterStats,
|
|
9
|
+
* NewsletterSettings (name, sender, opt-in, sending domain, running state), NewsletterListSyncs, NewsletterTemplates,
|
|
10
|
+
* IssueVersions (history, restore, duplicate) and SubscriberTimeline.
|
|
9
11
|
*
|
|
10
12
|
* Every record has a full detail view: `?tab=` picks Issues, Subscribers, Performance or Details (the newsletter itself);
|
|
11
|
-
* `?id=` opens an issue (Write, Send, Results, Details) or a subscriber (all fields, unsubscribe, erase);
|
|
13
|
+
* `?id=` opens an issue (Write, Send, Results, History, Details) or a subscriber (all fields, activity, unsubscribe, erase);
|
|
14
|
+
* the Settings, Lists and Templates tabs manage the newsletter itself, its synced lists and its saved templates;
|
|
12
15
|
* `&view=full` shows the open record as a full page.
|
|
13
16
|
*
|
|
14
17
|
* User flows: pick or create a newsletter (`?newsletter=`), activate or pause it, read the headline numbers,
|
|
@@ -39,13 +42,20 @@ import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
|
39
42
|
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
40
43
|
import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
41
44
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
45
|
+
import { useAllPages } from "@/g8/hooks/use-all-pages";
|
|
42
46
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
43
47
|
import { isDraft, issueTitle, percent } from "../lib/newsletter-types";
|
|
44
48
|
import type { Issue, IssueKpis, Newsletter, Subscriber } from "../lib/newsletter-types";
|
|
45
49
|
import { IssueForm } from "./issue-form";
|
|
46
50
|
import { IssueScheduler } from "./issue-scheduler";
|
|
51
|
+
import { IssueVersions } from "./issue-versions";
|
|
52
|
+
import { NewsletterListSyncs } from "./newsletter-list-syncs";
|
|
53
|
+
import { NewsletterSettings } from "./newsletter-settings";
|
|
47
54
|
import { NewsletterStats } from "./newsletter-stats";
|
|
55
|
+
import { NewsletterTemplates } from "./newsletter-templates";
|
|
56
|
+
import { SubscriberTimeline } from "./subscriber-timeline";
|
|
48
57
|
import { SubscriberTable } from "./subscriber-table";
|
|
58
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
49
59
|
|
|
50
60
|
export interface NewsletterScreenProps {
|
|
51
61
|
/** Open this newsletter first when the URL does not name one. */
|
|
@@ -57,7 +67,9 @@ export interface NewsletterScreenProps {
|
|
|
57
67
|
className?: string;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
|
-
type Tab = "issues" | "subscribers" | "performance" | "details";
|
|
70
|
+
type Tab = "issues" | "subscribers" | "performance" | "details" | "settings" | "lists" | "templates";
|
|
71
|
+
/** Subscribers per read when looking one up for a deep link; every page is read. */
|
|
72
|
+
const SUBSCRIBER_PAGE = 100; // the API caps page_size at 100 (a larger value is a 422)
|
|
61
73
|
const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
|
|
62
74
|
|
|
63
75
|
function tone(status: string | undefined): Tone {
|
|
@@ -118,7 +130,7 @@ function IssueDetails({ issue }: { issue: Issue }) {
|
|
|
118
130
|
);
|
|
119
131
|
}
|
|
120
132
|
|
|
121
|
-
function SubscriberDetail({ newsletterId, subscriber, onChanged, onClose, expandProps }: { newsletterId: string; subscriber: Subscriber; onChanged: () => void; onClose: () => void; expandProps: { onExpand: () => void; onCollapse?: () => void } }) {
|
|
133
|
+
function SubscriberDetail({ newsletterId, subscriber, issues, onChanged, onClose, expandProps }: { newsletterId: string; subscriber: Subscriber; issues?: Issue[]; onChanged: () => void; onClose: () => void; expandProps: { onExpand: () => void; onCollapse?: () => void } }) {
|
|
122
134
|
const [confirm, setConfirm] = useState<"unsubscribe" | "erase" | null>(null);
|
|
123
135
|
const base = `/newsletters/${encodeURIComponent(newsletterId)}/subscribers/${subscriber.id}`;
|
|
124
136
|
const run = useG8Mutation((kind: "unsubscribe" | "erase") => (kind === "unsubscribe" ? g8Patch(`${base}/unsubscribe`, {}) : g8Delete(base)), {
|
|
@@ -154,6 +166,7 @@ function SubscriberDetail({ newsletterId, subscriber, onChanged, onClose, expand
|
|
|
154
166
|
...extra.map(([key, v]) => ({ label: key.replace(/_/g, " "), value: String(v) })),
|
|
155
167
|
]}
|
|
156
168
|
/>
|
|
169
|
+
{issues ? <SubscriberTimeline className="mt-4" newsletterId={newsletterId} subscriberId={subscriber.id} issues={issues} /> : null}
|
|
157
170
|
<ConfirmSpendDialog
|
|
158
171
|
open={confirm !== null}
|
|
159
172
|
tier={confirm === "erase" ? "destructive" : "external"}
|
|
@@ -170,12 +183,13 @@ function SubscriberDetail({ newsletterId, subscriber, onChanged, onClose, expand
|
|
|
170
183
|
}
|
|
171
184
|
|
|
172
185
|
export function NewsletterScreen({ initialNewsletterId, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, className }: NewsletterScreenProps) {
|
|
186
|
+
const brand = useBrand();
|
|
173
187
|
const [urlNewsletter, setUrlNewsletter] = useUrlState("newsletter", initialNewsletterId ?? "");
|
|
174
188
|
const [issueParam, setIssueParam] = useUrlState("id", "");
|
|
175
189
|
const [view, setView] = useUrlState("view", "");
|
|
176
190
|
const expandProps = { onExpand: () => setView("full"), onCollapse: view === "full" ? () => setView(null) : undefined };
|
|
177
191
|
const [tabParam, setTabParam] = useUrlState("tab", "issues");
|
|
178
|
-
const tab = (["issues", "subscribers", "performance", "details"].includes(tabParam) ? tabParam : "issues") as Tab;
|
|
192
|
+
const tab = (["issues", "subscribers", "performance", "details", "settings", "lists", "templates"].includes(tabParam) ? tabParam : "issues") as Tab;
|
|
179
193
|
const closeDetail = (): void => { setIssueParam(null); setView(null); };
|
|
180
194
|
const [subRefresh, setSubRefresh] = useState(0);
|
|
181
195
|
const [openedSubscriber, setOpenedSubscriber] = useState<Subscriber | null>(null);
|
|
@@ -185,6 +199,8 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
185
199
|
const [name, setName] = useState("");
|
|
186
200
|
const [goal, setGoal] = useState("");
|
|
187
201
|
const [pending, setPending] = useState<"create" | "activate" | null>(null);
|
|
202
|
+
// Bumped after a version restore so the editor reloads the draft instead of showing the old text.
|
|
203
|
+
const [formRev, setFormRev] = useState(0);
|
|
188
204
|
|
|
189
205
|
const newsletters = useG8Query((signal) => g8Get<Newsletter[]>("/newsletters", {}, { signal }).then((r) => r.data ?? []), []);
|
|
190
206
|
const list = newsletters.data ?? [];
|
|
@@ -207,12 +223,17 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
207
223
|
const pause = useG8Mutation(() => g8Post(`${base}/pause`), { onSuccess: () => void newsletters.refetch() });
|
|
208
224
|
|
|
209
225
|
const subscriberId = tab === "subscribers" && issueParam ? Number(issueParam) : undefined;
|
|
210
|
-
|
|
211
|
-
|
|
226
|
+
// A deep link to a subscriber reads every page (100 per read), so no subscriber is out of reach.
|
|
227
|
+
const lookup = useAllPages<Subscriber>(
|
|
228
|
+
async (page, signal) => {
|
|
229
|
+
const r = await g8Get<{ items?: Subscriber[]; total?: number }>(`${base}/subscribers`, { page, page_size: SUBSCRIBER_PAGE }, { signal });
|
|
230
|
+
const rows = r.data?.items ?? [];
|
|
231
|
+
return { rows, hasNext: r.data?.total != null ? page * SUBSCRIBER_PAGE < r.data.total : rows.length >= SUBSCRIBER_PAGE };
|
|
232
|
+
},
|
|
212
233
|
[base, subRefresh],
|
|
213
|
-
{ enabled: subscriberId !== undefined && openedSubscriber?.id !== subscriberId },
|
|
234
|
+
{ enabled: Boolean(newsletterId) && subscriberId !== undefined && openedSubscriber?.id !== subscriberId },
|
|
214
235
|
);
|
|
215
|
-
const openSubscriber = subscriberId === undefined ? null : openedSubscriber?.id === subscriberId ? openedSubscriber : (
|
|
236
|
+
const openSubscriber = subscriberId === undefined ? null : openedSubscriber?.id === subscriberId ? openedSubscriber : (lookup.items.find((x) => x.id === subscriberId) ?? null);
|
|
216
237
|
const all = issues.data ?? [];
|
|
217
238
|
const sent = all.filter((i) => i.status === "sent" || i.sent_at);
|
|
218
239
|
const totals = sent.reduce((a, i) => ({ delivered: a.delivered + (i.kpis?.delivered ?? 0), opened: a.opened + (i.kpis?.opened ?? 0), clicked: a.clicked + (i.kpis?.clicked ?? 0) }), { delivered: 0, opened: 0, clicked: 0 });
|
|
@@ -225,9 +246,10 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
225
246
|
const pickNewsletter = (id: string): void => { setIssueParam(null); setUrlNewsletter(id); };
|
|
226
247
|
const detailTabs = selected
|
|
227
248
|
? [
|
|
228
|
-
{ id: "write", label: isDraft(selected) ? "Write" : "Content", content: <IssueForm key={`f${selected.id}`} newsletterId={newsletterId} issueId={selected.id} onSaved={() => void issues.refetch()} onDeleted={() => { setIssueParam(null); void issues.refetch(); }} /> },
|
|
249
|
+
{ id: "write", label: isDraft(selected) ? "Write" : "Content", content: <IssueForm key={`f${selected.id}:${formRev}`} newsletterId={newsletterId} issueId={selected.id} onSaved={() => void issues.refetch()} onDeleted={() => { setIssueParam(null); void issues.refetch(); }} /> },
|
|
229
250
|
{ id: "send", label: "Send", content: <IssueScheduler key={`s${selected.id}`} newsletterId={newsletterId} issueId={selected.id} onSent={() => void issues.refetch()} /> },
|
|
230
251
|
{ id: "results", label: "Results", content: <IssueResults newsletterId={newsletterId} issue={selected} /> },
|
|
252
|
+
{ id: "history", label: "History", content: <IssueVersions key={`v${selected.id}:${formRev}`} newsletterId={newsletterId} issue={selected} onRestored={() => { setFormRev((n) => n + 1); void issues.refetch(); }} onDuplicated={(id) => { void issues.refetch(); setIssueParam(String(id)); }} /> },
|
|
231
253
|
{ id: "details", label: "Issue details", content: <IssueDetails issue={selected} /> },
|
|
232
254
|
...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
|
|
233
255
|
]
|
|
@@ -285,9 +307,9 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
285
307
|
detailWidth="lg"
|
|
286
308
|
list={<SubscriberTable key={newsletterId} newsletterId={newsletterId} refreshKey={subRefresh} selectedId={subscriberId} onOpen={(sub) => { setOpenedSubscriber(sub); setIssueParam(String(sub.id)); }} />}
|
|
287
309
|
detail={openSubscriber ? (
|
|
288
|
-
<SubscriberDetail key={openSubscriber.id} newsletterId={newsletterId} subscriber={openSubscriber} onChanged={() => { setSubRefresh((n) => n + 1); setOpenedSubscriber(null); }} onClose={() => { closeDetail(); setOpenedSubscriber(null); }} expandProps={expandProps} />
|
|
310
|
+
<SubscriberDetail key={openSubscriber.id} newsletterId={newsletterId} subscriber={openSubscriber} issues={all} onChanged={() => { setSubRefresh((n) => n + 1); setOpenedSubscriber(null); }} onClose={() => { closeDetail(); setOpenedSubscriber(null); }} expandProps={expandProps} />
|
|
289
311
|
) : lookup.loading ? <Skeleton className="h-40 w-full" aria-busy="true" /> : (
|
|
290
|
-
<EmptyState title="Subscriber not found" description="Open a subscriber from the list.
|
|
312
|
+
<EmptyState title="Subscriber not found" description="Open a subscriber from the list." />
|
|
291
313
|
)}
|
|
292
314
|
/>
|
|
293
315
|
)
|
|
@@ -318,6 +340,9 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
318
340
|
</DetailPanel>
|
|
319
341
|
)
|
|
320
342
|
: tab === "performance" ? <NewsletterStats key={newsletterId} newsletterId={newsletterId} />
|
|
343
|
+
: tab === "settings" ? <NewsletterSettings key={newsletterId} newsletterId={newsletterId} onChanged={() => void newsletters.refetch()} />
|
|
344
|
+
: tab === "lists" ? <NewsletterListSyncs key={newsletterId} newsletterId={newsletterId} onChanged={() => setSubRefresh((n) => n + 1)} />
|
|
345
|
+
: tab === "templates" ? <NewsletterTemplates key={newsletterId} newsletterId={newsletterId} onUsed={(id) => { void issues.refetch(); setTabParam(null); setIssueParam(String(id)); }} />
|
|
321
346
|
: (
|
|
322
347
|
<SplitView
|
|
323
348
|
detailOpen={detailOpen}
|
|
@@ -362,7 +387,7 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
362
387
|
<Button size="sm" onClick={() => setSheet(true)}><Plus className="size-4" />New newsletter</Button>
|
|
363
388
|
</>
|
|
364
389
|
)}
|
|
365
|
-
tabs={current ? [{ id: "issues", label: "Issues", count: all.length }, { id: "subscribers", label: "Subscribers", count: audience.data }, { id: "performance", label: "Performance" }, { id: "details", label: "Details" }] : undefined}
|
|
390
|
+
tabs={current ? [{ id: "issues", label: "Issues", count: all.length }, { id: "subscribers", label: "Subscribers", count: audience.data }, { id: "performance", label: "Performance" }, { id: "templates", label: "Templates" }, { id: "lists", label: "Lists" }, { id: "settings", label: "Settings" }, { id: "details", label: "Details" }] : undefined}
|
|
366
391
|
activeTab={tab}
|
|
367
392
|
onTabChange={(id) => { setTabParam(id === "issues" ? null : id); closeDetail(); setOpenedSubscriber(null); }}
|
|
368
393
|
stats={current ? [
|
|
@@ -391,7 +416,7 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
391
416
|
open={pending !== null}
|
|
392
417
|
tier="external"
|
|
393
418
|
title={pending === "activate" ? `Activate ${current?.name ?? "this newsletter"}?` : `Create the newsletter "${name.trim()}"?`}
|
|
394
|
-
description={<><p>{pending === "activate" ?
|
|
419
|
+
description={<><p>{pending === "activate" ? `Once active, the scheduler sends any issue whose scheduled time has passed to real subscribers. A verified sending domain is required (set it up in ${brand.linkLabel}).` : "This creates the newsletter container. Nothing is sent until you activate it and send an issue."}</p>{(create.error ?? activate.error) ? <ApiError error={create.error ?? activate.error} title="That did not work" /> : null}</>}
|
|
395
420
|
confirmLabel={pending === "activate" ? "Activate" : "Create"}
|
|
396
421
|
confirming={create.loading || activate.loading}
|
|
397
422
|
onCancel={() => setPending(null)}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NewsletterSettings: everything about the newsletter itself. Its name and goal, who it sends as, double opt-in and
|
|
5
|
+
* address-check policy, the sending domain (start verification, read the DNS records, check status), and whether it is
|
|
6
|
+
* running (resume a paused newsletter, mark it completed).
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in the order the screen uses them:
|
|
9
|
+
* 1. getNewsletter GET /newsletters/{id} (read)
|
|
10
|
+
* 2. updateNewsletter PATCH /newsletters/{id} { name?, goal? } (write; ONLY changed fields are sent)
|
|
11
|
+
* 3. getNewsletterSettings GET /newsletters/{id}/settings (read)
|
|
12
|
+
* 4. updateNewsletterSettings PUT /newsletters/{id}/settings (write; ONLY changed fields are sent)
|
|
13
|
+
* 5. verifyNewsletterDomain POST /newsletters/{id}/settings/verify-domain { domain } (EXTERNAL, confirmed: registers the domain with the email provider)
|
|
14
|
+
* 6. newsletterDomainStatus GET /newsletters/{id}/settings/domain-status?domain (read; may mark the domain verified)
|
|
15
|
+
* 7. resumeNewsletter POST /newsletters/{id}/resume (EXTERNAL, confirmed: scheduled issues can send again)
|
|
16
|
+
* 8. completeNewsletter POST /newsletters/{id}/complete (write; two-step button, can be re-activated)
|
|
17
|
+
*
|
|
18
|
+
* Each form is seeded from the loaded record only and stays read-only until it has loaded, so a blank form can never be
|
|
19
|
+
* saved over real values. Pausing and activating stay on the screen header.
|
|
20
|
+
*/
|
|
21
|
+
import { useState } from "react";
|
|
22
|
+
import { Button, Input, Label, Skeleton, Switch } from "@graph8/sdk/ui";
|
|
23
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
24
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
25
|
+
import { useConfirmAction } from "@/g8/components/confirmed-action";
|
|
26
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
27
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
28
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
29
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
30
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
31
|
+
import { g8Get, g8Patch, g8Post, g8Put } from "@/g8/lib/g8-client";
|
|
32
|
+
import type { Newsletter } from "../lib/newsletter-types";
|
|
33
|
+
import { asRec, changedFields, dnsRecordsOf, isDomain, str } from "../lib/newsletter-extra";
|
|
34
|
+
import type { Rec } from "../lib/newsletter-extra";
|
|
35
|
+
|
|
36
|
+
export interface NewsletterSettingsProps {
|
|
37
|
+
newsletterId: string;
|
|
38
|
+
/** Called after the newsletter or its status changed, so the screen refreshes its header. */
|
|
39
|
+
onChanged?: () => void;
|
|
40
|
+
className?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Basics({ newsletter, onSaved }: { newsletter: Newsletter; onSaved: () => void }) {
|
|
44
|
+
// Only what the person typed is held as `edits`, shown over the saved copy: a reload never wipes typing.
|
|
45
|
+
const saved = { name: newsletter.name ?? "", goal: newsletter.goal ?? "" };
|
|
46
|
+
const [edits, setEdits] = useState<Partial<typeof saved>>({});
|
|
47
|
+
const draft = { ...saved, ...edits };
|
|
48
|
+
const setDraft = (next: typeof saved): void => setEdits(Object.fromEntries(Object.entries(next).filter(([k, v]) => v !== saved[k as keyof typeof saved])) as Partial<typeof saved>);
|
|
49
|
+
const changes = changedFields(saved, draft);
|
|
50
|
+
const save = useG8Mutation(() => g8Patch(`/newsletters/${encodeURIComponent(newsletter.id)}`, changes), { onSuccess: () => { setEdits({}); onSaved(); } });
|
|
51
|
+
const bad = "name" in changes && String(changes.name).trim() === "";
|
|
52
|
+
return (
|
|
53
|
+
<form className="grid gap-3 sm:grid-cols-2" onSubmit={(e) => { e.preventDefault(); if (Object.keys(changes).length && !bad) void save.mutate(undefined); }}>
|
|
54
|
+
<div><Label htmlFor="ns-name" className="mb-1">Name</Label><Input id="ns-name" value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} aria-invalid={bad} /></div>
|
|
55
|
+
<div><Label htmlFor="ns-goal" className="mb-1">Goal</Label><Input id="ns-goal" value={draft.goal} placeholder="What this newsletter is for" onChange={(e) => setDraft({ ...draft, goal: e.target.value })} /></div>
|
|
56
|
+
<div className="sm:col-span-2"><Button type="submit" loading={save.loading} disabled={Object.keys(changes).length === 0 || bad}>Save name and goal</Button></div>
|
|
57
|
+
{save.error ? <div className="sm:col-span-2"><ApiError error={save.error} title="Could not save" /></div> : null}
|
|
58
|
+
</form>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function SenderForm({ saved, newsletterId, onSaved }: { saved: Rec; newsletterId: string; onSaved: () => void }) {
|
|
63
|
+
const initial = {
|
|
64
|
+
default_sender_name: str(saved.default_sender_name),
|
|
65
|
+
default_sender_email: str(saved.default_sender_email),
|
|
66
|
+
default_reply_to: str(saved.default_reply_to),
|
|
67
|
+
double_opt_in_intent: Boolean(saved.double_opt_in_intent ?? saved.double_opt_in),
|
|
68
|
+
skip_unverified_subscribers: Boolean(saved.skip_unverified_subscribers),
|
|
69
|
+
auto_verify_on_import: Boolean(saved.auto_verify_on_import),
|
|
70
|
+
};
|
|
71
|
+
const [edits, setEdits] = useState<Partial<typeof initial>>({});
|
|
72
|
+
const draft = { ...initial, ...edits };
|
|
73
|
+
const setDraft = (next: typeof initial): void => setEdits(Object.fromEntries(Object.entries(next).filter(([k, v]) => v !== initial[k as keyof typeof initial])) as Partial<typeof initial>);
|
|
74
|
+
const changes = changedFields(initial, draft);
|
|
75
|
+
const emailBad = ["default_sender_email", "default_reply_to"].some((k) => draft[k as keyof typeof draft] !== "" && !/^\S+@\S+\.\S+$/.test(String(draft[k as keyof typeof draft])));
|
|
76
|
+
const save = useG8Mutation(() => g8Put(`/newsletters/${encodeURIComponent(newsletterId)}/settings`, Object.fromEntries(Object.entries(changes).map(([k, v]) => [k, v === "" ? null : v]))), { onSuccess: () => { setEdits({}); onSaved(); } });
|
|
77
|
+
const toggle = (key: "double_opt_in_intent" | "skip_unverified_subscribers" | "auto_verify_on_import", label: string, help: string) => (
|
|
78
|
+
<div className="flex items-start gap-2">
|
|
79
|
+
<Switch id={`ns-${key}`} checked={draft[key]} onCheckedChange={(v) => setDraft({ ...draft, [key]: v })} aria-label={label} />
|
|
80
|
+
<div><Label htmlFor={`ns-${key}`}>{label}</Label><p className="text-muted-foreground text-xs">{help}</p></div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
return (
|
|
84
|
+
<form className="grid gap-3 sm:grid-cols-2" onSubmit={(e) => { e.preventDefault(); if (Object.keys(changes).length && !emailBad) void save.mutate(undefined); }}>
|
|
85
|
+
<div><Label htmlFor="ns-sname" className="mb-1">Sender name</Label><Input id="ns-sname" value={draft.default_sender_name} onChange={(e) => setDraft({ ...draft, default_sender_name: e.target.value })} /></div>
|
|
86
|
+
<div><Label htmlFor="ns-semail" className="mb-1">Sender email</Label><Input id="ns-semail" type="email" value={draft.default_sender_email} onChange={(e) => setDraft({ ...draft, default_sender_email: e.target.value })} /></div>
|
|
87
|
+
<div><Label htmlFor="ns-reply" className="mb-1">Reply to</Label><Input id="ns-reply" type="email" value={draft.default_reply_to} onChange={(e) => setDraft({ ...draft, default_reply_to: e.target.value })} /></div>
|
|
88
|
+
<div className="grid gap-3 sm:col-span-2">
|
|
89
|
+
{toggle("double_opt_in_intent", "Ask new subscribers to confirm (double opt-in)", "Turns on once the sending domain is verified and a sender email is set.")}
|
|
90
|
+
{toggle("auto_verify_on_import", "Check addresses when a list is imported", "Verifying addresses uses credits.")}
|
|
91
|
+
{toggle("skip_unverified_subscribers", "Skip addresses that failed the check", "They stay subscribed but are not mailed.")}
|
|
92
|
+
</div>
|
|
93
|
+
{emailBad ? <p role="alert" className="text-error-foreground text-xs sm:col-span-2">Enter a full email address, for example news@updates.acme.com.</p> : null}
|
|
94
|
+
<div className="sm:col-span-2"><Button type="submit" loading={save.loading} disabled={Object.keys(changes).length === 0 || emailBad}>Save sender settings</Button></div>
|
|
95
|
+
{save.error ? <div className="sm:col-span-2"><ApiError error={save.error} title="Could not save the settings" /></div> : null}
|
|
96
|
+
</form>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function SendingDomain({ saved, newsletterId, onChanged }: { saved: Rec; newsletterId: string; onChanged: () => void }) {
|
|
101
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
102
|
+
const base = `/newsletters/${encodeURIComponent(newsletterId)}/settings`;
|
|
103
|
+
const [domain, setDomain] = useState(str(saved.sending_domain));
|
|
104
|
+
const [records, setRecords] = useState<Rec[]>([]);
|
|
105
|
+
const [note, setNote] = useState("");
|
|
106
|
+
const verified = saved.domain_verified === true;
|
|
107
|
+
const status = useG8Mutation(async () => {
|
|
108
|
+
const d = asRec((await g8Get(`${base}/domain-status`, { domain: domain.trim() })).data);
|
|
109
|
+
setNote(d.verified === true ? "Verified. You can send from this domain." : "Not verified yet. DNS changes can take a while to spread; check again later.");
|
|
110
|
+
if (d.verified === true) onChanged();
|
|
111
|
+
});
|
|
112
|
+
const start = (): void => ask({
|
|
113
|
+
tier: "external",
|
|
114
|
+
title: `Start verifying ${domain.trim()}`,
|
|
115
|
+
confirmLabel: "Start verification",
|
|
116
|
+
description: "Registers this domain with the email provider and saves it as the sending domain. You then add the DNS records it returns at your DNS host. Nothing is sent.",
|
|
117
|
+
run: async () => {
|
|
118
|
+
const d = asRec((await g8Post(`${base}/verify-domain`, { domain: domain.trim() })).data);
|
|
119
|
+
setRecords(dnsRecordsOf(d));
|
|
120
|
+
setNote(str(d.instructions));
|
|
121
|
+
},
|
|
122
|
+
onDone: onChanged,
|
|
123
|
+
});
|
|
124
|
+
return (
|
|
125
|
+
<div className="grid gap-3">
|
|
126
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
127
|
+
<div className="min-w-60 flex-1"><Label htmlFor="ns-domain" className="mb-1">Sending domain</Label><Input id="ns-domain" placeholder="updates.acme.com" value={domain} onChange={(e) => setDomain(e.target.value)} aria-invalid={domain !== "" && !isDomain(domain)} /></div>
|
|
128
|
+
<Button variant="outline" disabled={!isDomain(domain)} onClick={start}>Start verification</Button>
|
|
129
|
+
<Button variant="outline" loading={status.loading} disabled={!isDomain(domain)} onClick={() => void status.mutate(undefined)}>Check status</Button>
|
|
130
|
+
{verified ? <StatusPill tone="positive">Verified</StatusPill> : saved.sending_domain ? <StatusPill tone="warning">Not verified</StatusPill> : null}
|
|
131
|
+
</div>
|
|
132
|
+
{domain !== "" && !isDomain(domain) ? <p role="alert" className="text-error-foreground text-xs">Enter a domain such as updates.acme.com, without https://.</p> : null}
|
|
133
|
+
{note ? <p role="status" className="text-sm">{note}</p> : null}
|
|
134
|
+
{records.length > 0 ? (
|
|
135
|
+
<ul className="grid gap-2" aria-label="DNS records to add">
|
|
136
|
+
{records.map((r, i) => (
|
|
137
|
+
<li key={i} className="rounded-md border p-2 text-xs"><span className="font-medium">{str(r.type) || str(r.record_type)}</span> <span className="font-mono">{str(r.name)}</span> <span className="font-mono break-all">{str(r.value)}</span></li>
|
|
138
|
+
))}
|
|
139
|
+
</ul>
|
|
140
|
+
) : null}
|
|
141
|
+
{status.error ? <ApiError error={status.error} title="Could not check the domain" /> : null}
|
|
142
|
+
{failure ? <ApiError error={failure} title="Could not start verification" /> : null}
|
|
143
|
+
<ConfirmSpendDialog {...dialog} />
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function Lifecycle({ newsletter, onChanged }: { newsletter: Newsletter; onChanged: () => void }) {
|
|
149
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
150
|
+
const [sure, setSure] = useState(false);
|
|
151
|
+
const base = `/newsletters/${encodeURIComponent(newsletter.id)}`;
|
|
152
|
+
const complete = useG8Mutation(() => g8Post(`${base}/complete`), { onSuccess: () => { setSure(false); onChanged(); } });
|
|
153
|
+
const status = newsletter.status ?? "draft";
|
|
154
|
+
return (
|
|
155
|
+
<div className="grid gap-3">
|
|
156
|
+
<p className="text-sm">Status: <StatusPill tone={status === "active" ? "positive" : "neutral"}>{status}</StatusPill></p>
|
|
157
|
+
<div className="flex flex-wrap gap-2">
|
|
158
|
+
{status === "paused" ? (
|
|
159
|
+
<Button variant="outline" onClick={() => ask({
|
|
160
|
+
tier: "external", title: `Resume ${newsletter.name}`, confirmLabel: "Resume newsletter",
|
|
161
|
+
description: "Issues scheduled for a time that has passed send to real subscribers again, and cannot be recalled.",
|
|
162
|
+
run: () => g8Post(`${base}/resume`), onDone: onChanged,
|
|
163
|
+
})}>Resume newsletter</Button>
|
|
164
|
+
) : null}
|
|
165
|
+
{status !== "completed" ? (
|
|
166
|
+
sure ? (
|
|
167
|
+
<>
|
|
168
|
+
<Button variant="outline" loading={complete.loading} onClick={() => void complete.mutate(undefined)}>Yes, stop this newsletter</Button>
|
|
169
|
+
<Button variant="ghost" onClick={() => setSure(false)}>Keep it running</Button>
|
|
170
|
+
</>
|
|
171
|
+
) : <Button variant="outline" onClick={() => setSure(true)}>Mark as completed</Button>
|
|
172
|
+
) : <p className="text-muted-foreground text-sm">Completed newsletters send nothing. Activate it from the header to start again.</p>}
|
|
173
|
+
</div>
|
|
174
|
+
{sure ? <p className="text-muted-foreground text-xs">No more issues are sent. You can activate the newsletter again later.</p> : null}
|
|
175
|
+
{complete.error ? <ApiError error={complete.error} title="Could not complete the newsletter" /> : null}
|
|
176
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
177
|
+
<ConfirmSpendDialog {...dialog} />
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function NewsletterSettings({ newsletterId, onChanged, className }: NewsletterSettingsProps) {
|
|
183
|
+
const base = `/newsletters/${encodeURIComponent(newsletterId)}`;
|
|
184
|
+
const nl = useG8Query((signal) => g8Get<Newsletter>(base, undefined, { signal }).then((r) => r.data), [newsletterId]);
|
|
185
|
+
const settings = useG8Query((signal) => g8Get(`${base}/settings`, undefined, { signal }), [newsletterId]);
|
|
186
|
+
const saved = asRec(settings.data?.data);
|
|
187
|
+
const changed = (): void => { void nl.refetch(); void settings.refetch(); onChanged?.(); };
|
|
188
|
+
return (
|
|
189
|
+
<div className={className}>
|
|
190
|
+
<div className="grid gap-4">
|
|
191
|
+
<SectionCard title="Name and goal">
|
|
192
|
+
{nl.error ? <ApiError error={nl.error} title="Could not load the newsletter" onRetry={() => void nl.refetch()} /> : !nl.data ? <Skeleton className="h-20 w-full" aria-busy="true" /> : (
|
|
193
|
+
<Basics key={nl.data.id} newsletter={nl.data} onSaved={changed} />
|
|
194
|
+
)}
|
|
195
|
+
</SectionCard>
|
|
196
|
+
<SectionCard title="Sender and opt-in" description="Who issues come from, and how people confirm.">
|
|
197
|
+
{settings.error ? <ApiError error={settings.error} title="Could not load the settings" onRetry={() => void settings.refetch()} /> : !settings.data ? <Skeleton className="h-32 w-full" aria-busy="true" /> : (
|
|
198
|
+
<SenderForm key={newsletterId} saved={saved} newsletterId={newsletterId} onSaved={changed} />
|
|
199
|
+
)}
|
|
200
|
+
</SectionCard>
|
|
201
|
+
<SectionCard title="Sending domain" description="Issues are only delivered from a verified domain.">
|
|
202
|
+
{settings.data ? <SendingDomain key={newsletterId} saved={saved} newsletterId={newsletterId} onChanged={changed} /> : <Skeleton className="h-16 w-full" aria-busy="true" />}
|
|
203
|
+
</SectionCard>
|
|
204
|
+
<SectionCard title="Running state">
|
|
205
|
+
{nl.data ? <Lifecycle newsletter={nl.data} onChanged={changed} /> : <Skeleton className="h-12 w-full" aria-busy="true" />}
|
|
206
|
+
</SectionCard>
|
|
207
|
+
{nl.data ? <KeyValueList columns={2} items={[{ label: "Newsletter id", value: nl.data.id, mono: true }, { label: "Slug", value: nl.data.slug }]} /> : null}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -30,7 +30,7 @@ function Tile({ label, value, sub }: { label: string; value: number | undefined;
|
|
|
30
30
|
return (
|
|
31
31
|
<div className="rounded-md border p-3">
|
|
32
32
|
<p className="text-muted-foreground text-xs">{label}</p>
|
|
33
|
-
<p className="text-xl font-
|
|
33
|
+
<p className="text-xl font-medium">{(value ?? 0).toLocaleString()}</p>
|
|
34
34
|
{sub ? <p className="text-muted-foreground text-xs">{sub}</p> : null}
|
|
35
35
|
</div>
|
|
36
36
|
);
|
|
@@ -69,7 +69,7 @@ export function NewsletterStats({ newsletterId }: NewsletterStatsProps) {
|
|
|
69
69
|
<CardHeader><CardTitle>Issue details</CardTitle></CardHeader>
|
|
70
70
|
<CardContent className="grid gap-3">
|
|
71
71
|
{detail.error ? <ApiError error={detail.error} title="Could not load the numbers" onRetry={() => void detail.refetch()} /> : detail.loading && !detail.data ? <Skeleton className="h-20 w-full" /> : detail.data?.stale ? (
|
|
72
|
-
<p role="alert" className="text-sm text-
|
|
72
|
+
<p role="alert" className="text-sm text-warning-foreground">The numbers could not be read just now. This is not zero engagement: try again in a minute.</p>
|
|
73
73
|
) : (
|
|
74
74
|
<>
|
|
75
75
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NewsletterTemplates: your saved issue templates. Start a new draft issue from one, approve or archive it, rename it,
|
|
5
|
+
* delete it, or have AI write a new one from an idea.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in the order the screen uses them:
|
|
8
|
+
* 1. listTemplates (newsletter) GET /newsletters/templates (read; the whole list, no cap)
|
|
9
|
+
* 2. useTemplate POST /newsletters/templates/{id}/use { newsletter_id } (write; creates a DRAFT issue, sends nothing)
|
|
10
|
+
* 3. setTemplateApproval POST /newsletters/templates/{id}/approval { status } (write)
|
|
11
|
+
* 4. updateNewsletterTemplate PATCH /newsletters/templates/{id} { name } (write; only a changed name is sent)
|
|
12
|
+
* 5. deleteNewsletterTemplate DELETE /newsletters/templates/{id} (destructive, confirmed)
|
|
13
|
+
* 6. createTemplateFromPrompt POST /newsletters/templates/custom { idea, tone?, length? } (BILLABLE, confirmed; builds in the background)
|
|
14
|
+
* 7. templateGenerationStatus GET /newsletters/templates/{id}/generation-status (read; polled every 3 s while a template is being written)
|
|
15
|
+
*
|
|
16
|
+
* The API quotes no price for AI templates in advance and the balance is not checked here, so the dialog says what runs.
|
|
17
|
+
*/
|
|
18
|
+
import { useEffect, useState } from "react";
|
|
19
|
+
import { Button, Input, Label, Skeleton, Textarea } 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 { SectionCard } from "@/g8/components/screen/section-card";
|
|
25
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
26
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
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/newsletter-extra";
|
|
30
|
+
import type { Rec } from "../lib/newsletter-extra";
|
|
31
|
+
|
|
32
|
+
export interface NewsletterTemplatesProps {
|
|
33
|
+
/** The newsletter a template is used in. */
|
|
34
|
+
newsletterId: string;
|
|
35
|
+
/** Called with the new draft issue's id after "Use for a new issue". */
|
|
36
|
+
onUsed?: (issueId: number) => void;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SELECT = "border-input bg-background h-8 rounded-md border px-2 text-sm";
|
|
41
|
+
const APPROVAL = ["draft", "approved", "archived"] as const;
|
|
42
|
+
const WORKING = /generating|pending|queued|running/i;
|
|
43
|
+
|
|
44
|
+
/** The API stores "archived" as "rejected" and returns the raw value: show it as Archived. */
|
|
45
|
+
export function approvalOf(t: Rec): string {
|
|
46
|
+
const v = str(t.approval_status, "draft");
|
|
47
|
+
return v === "rejected" ? "archived" : v;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function TemplateRow({ t, newsletterId, onChanged, onUsed }: { t: Rec; newsletterId: string; onChanged: () => void; onUsed?: (id: number) => void }) {
|
|
51
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
52
|
+
const id = num(t.id) as number;
|
|
53
|
+
const base = `/newsletters/templates/${id}`;
|
|
54
|
+
const [name, setName] = useState(str(t.name));
|
|
55
|
+
const generating = WORKING.test(str(t.generation_status));
|
|
56
|
+
const rename = useG8Mutation(() => g8Patch(base, { name: name.trim() }), { onSuccess: onChanged });
|
|
57
|
+
const approve = useG8Mutation((status: string) => g8Post(`${base}/approval`, { status }), { onSuccess: onChanged });
|
|
58
|
+
const use = useG8Mutation(() => g8Post(`${base}/use`, { newsletter_id: newsletterId }), { onSuccess: (res) => { const n = num(asRec(res.data).id); if (n !== undefined) onUsed?.(n); } });
|
|
59
|
+
const status = useG8Query((signal) => g8Get(`${base}/generation-status`, undefined, { signal }), [id, str(t.generation_status)], { enabled: generating });
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!generating) return;
|
|
62
|
+
const state = str(asRec(status.data?.data).status);
|
|
63
|
+
if (status.data && state !== "" && !WORKING.test(state)) { onChanged(); return; }
|
|
64
|
+
// A failed poll is retried too (a blip must not stop the "Writing" state for good).
|
|
65
|
+
const timer = setTimeout(() => void status.refetch(), 3000);
|
|
66
|
+
return () => clearTimeout(timer);
|
|
67
|
+
}, [generating, status.data, status.error]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
68
|
+
const dirty = name.trim() !== "" && name.trim() !== str(t.name);
|
|
69
|
+
return (
|
|
70
|
+
<li className="grid gap-2 rounded-md border p-3">
|
|
71
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
72
|
+
<Input aria-label={`Template name ${str(t.name)}`} className="max-w-xs" value={name} onChange={(e) => setName(e.target.value)} />
|
|
73
|
+
<Button size="sm" variant="outline" loading={rename.loading} disabled={!dirty} onClick={() => void rename.mutate(undefined)}>Rename</Button>
|
|
74
|
+
{generating ? <StatusPill tone="info">Writing</StatusPill> : null}
|
|
75
|
+
<select aria-label={`Approval of ${str(t.name)}`} className={SELECT} value={approvalOf(t)} disabled={approve.loading} onChange={(e) => void approve.mutate(e.target.value)}>
|
|
76
|
+
{APPROVAL.map((a) => <option key={a} value={a}>{a[0].toUpperCase() + a.slice(1)}</option>)}
|
|
77
|
+
</select>
|
|
78
|
+
<div className="ml-auto flex gap-1">
|
|
79
|
+
<Button size="sm" loading={use.loading} disabled={generating} aria-label={`Use ${str(t.name)} for a new issue`} onClick={() => void use.mutate(undefined)}>Use for a new issue</Button>
|
|
80
|
+
<Button size="sm" variant="ghost" aria-label={`Delete ${str(t.name)}`} onClick={() => ask({
|
|
81
|
+
tier: "destructive", title: `Delete the template "${str(t.name)}"`, confirmLabel: "Delete template", affected: 1,
|
|
82
|
+
description: "The template is removed. Issues already made from it are not changed.", tolerateStatus: [404],
|
|
83
|
+
run: () => g8Delete(base), onDone: onChanged,
|
|
84
|
+
})}>Delete</Button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
{str(t.subject) ? <p className="text-muted-foreground text-xs">Subject: {str(t.subject)}</p> : null}
|
|
88
|
+
{[rename.error, approve.error, use.error].filter(Boolean).map((e, i) => <ApiError key={i} error={e} title="That did not work" />)}
|
|
89
|
+
{failure ? <ApiError error={failure} title="Could not delete the template" /> : null}
|
|
90
|
+
<ConfirmSpendDialog {...dialog} />
|
|
91
|
+
</li>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function NewsletterTemplates({ newsletterId, onUsed, className }: NewsletterTemplatesProps) {
|
|
96
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
97
|
+
const list = useG8Query((signal) => g8Get("/newsletters/templates", undefined, { signal }), []);
|
|
98
|
+
const rows = rowsOf<Rec>(list.data?.data, "templates").filter((t) => num(t.id) !== undefined);
|
|
99
|
+
const [idea, setIdea] = useState("");
|
|
100
|
+
const [tone, setTone] = useState("");
|
|
101
|
+
const [length, setLength] = useState("");
|
|
102
|
+
const [note, setNote] = useState("");
|
|
103
|
+
const create = (): void => ask({
|
|
104
|
+
tier: "billable", title: "Write a template with AI", confirmLabel: "Write template",
|
|
105
|
+
description: "AI writes the copy for a new template from your idea. Uses credits; the API gives no price in advance and your balance is not checked here. It appears below while it is being written.",
|
|
106
|
+
run: async () => { await g8Post("/newsletters/templates/custom", { idea: idea.trim(), tone: tone.trim() || undefined, length: length.trim() || undefined }); setNote("The template is being written. It will finish by itself."); setIdea(""); },
|
|
107
|
+
onDone: () => void list.refetch(),
|
|
108
|
+
});
|
|
109
|
+
return (
|
|
110
|
+
<div className={className}>
|
|
111
|
+
<div className="grid gap-4">
|
|
112
|
+
<SectionCard title="Templates" description="Start a new draft issue from a saved layout.">
|
|
113
|
+
{list.error ? <ApiError error={list.error} title="Could not load templates" onRetry={() => void list.refetch()} /> : list.loading && !list.data ? <Skeleton className="h-24 w-full" aria-busy="true" /> : rows.length === 0 ? (
|
|
114
|
+
<EmptyState compact title="No templates yet" description="Write one with AI below, or save an issue's layout in the web app." />
|
|
115
|
+
) : (
|
|
116
|
+
<ul className="grid gap-2">{rows.map((t) => <TemplateRow key={String(t.id)} t={t} newsletterId={newsletterId} onChanged={() => void list.refetch()} onUsed={onUsed} />)}</ul>
|
|
117
|
+
)}
|
|
118
|
+
</SectionCard>
|
|
119
|
+
<SectionCard title="Write a template with AI" description="Describe the newsletter you want.">
|
|
120
|
+
<form className="grid gap-3 sm:grid-cols-2" onSubmit={(e) => { e.preventDefault(); if (idea.trim()) create(); }}>
|
|
121
|
+
<div className="sm:col-span-2"><Label htmlFor="nt-idea" className="mb-1">Idea</Label><Textarea id="nt-idea" rows={3} value={idea} onChange={(e) => setIdea(e.target.value)} placeholder="A monthly product digest with one big story and three quick links" /></div>
|
|
122
|
+
<div><Label htmlFor="nt-tone" className="mb-1">Tone (optional)</Label><Input id="nt-tone" value={tone} placeholder="Warm and direct" onChange={(e) => setTone(e.target.value)} /></div>
|
|
123
|
+
<div><Label htmlFor="nt-length" className="mb-1">Length (optional)</Label><Input id="nt-length" value={length} placeholder="Short" onChange={(e) => setLength(e.target.value)} /></div>
|
|
124
|
+
<div className="sm:col-span-2"><Button type="submit" disabled={!idea.trim()}>Write template with AI</Button></div>
|
|
125
|
+
</form>
|
|
126
|
+
{note ? <p role="status" className="mt-2 text-sm">{note}</p> : null}
|
|
127
|
+
</SectionCard>
|
|
128
|
+
{failure ? <ApiError error={failure} title="The template was not written" /> : null}
|
|
129
|
+
</div>
|
|
130
|
+
<ConfirmSpendDialog {...dialog} />
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
import { useState } from "react";
|
|
18
18
|
import { Button, Input, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
19
19
|
import { ApiError } from "@/g8/components/api-error";
|
|
20
|
+
import { useAllLists } from "@/g8/crm/hooks/use-all-lists";
|
|
21
|
+
import { ListSelect } from "@/g8/crm/components/list-select";
|
|
20
22
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
21
23
|
import { EmptyState } from "@/g8/components/empty-state";
|
|
22
24
|
import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
@@ -44,7 +46,6 @@ type Action =
|
|
|
44
46
|
| { kind: "delete"; subscriber: Subscriber };
|
|
45
47
|
|
|
46
48
|
interface Page { items: Subscriber[]; total: number }
|
|
47
|
-
interface ListOption { id: number; title: string; contact_count?: number }
|
|
48
49
|
|
|
49
50
|
function creditsOf(preview: unknown): number | undefined {
|
|
50
51
|
const p = (preview ?? {}) as Record<string, unknown>;
|
|
@@ -83,7 +84,7 @@ function SubscribersOf({ newsletterId, pageSize, onOpen, selectedId, refreshKey
|
|
|
83
84
|
(signal) => g8Get<Page>(base, { page, page_size: pageSize, search: query || undefined, status: status || undefined }, { signal }).then((r) => ({ items: r.data?.items ?? [], total: r.data?.total ?? 0 })),
|
|
84
85
|
[base, page, pageSize, query, status, refreshKey],
|
|
85
86
|
);
|
|
86
|
-
const lists =
|
|
87
|
+
const lists = useAllLists([]);
|
|
87
88
|
const preview = useG8Mutation(async () => (await g8Post(`${base}/verify/preview`, {})).data);
|
|
88
89
|
|
|
89
90
|
const run = useG8Mutation(async (a: Action) => {
|
|
@@ -116,11 +117,8 @@ function SubscribersOf({ newsletterId, pageSize, onOpen, selectedId, refreshKey
|
|
|
116
117
|
<Button type="submit" disabled={!draft.email.includes("@")}>Add subscriber</Button>
|
|
117
118
|
</form>
|
|
118
119
|
<div className="flex flex-wrap items-end gap-2">
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
{(lists.data ?? []).map((l) => <option key={l.id} value={l.id}>{l.title}</option>)}
|
|
122
|
-
</select>
|
|
123
|
-
<Button variant="outline" disabled={!listId} onClick={() => setAction({ kind: "import", listId: Number(listId), contacts: lists.data?.find((l) => l.id === Number(listId))?.contact_count })}>Import list</Button>
|
|
120
|
+
<ListSelect label="Import from list" emptyOption="Import from a list..." pageSize={200} value={listId} onChange={(l) => setListId(l ? String(l.id) : "")} />
|
|
121
|
+
<Button variant="outline" disabled={!listId} onClick={() => setAction({ kind: "import", listId: Number(listId), contacts: lists.items.find((l) => l.id === Number(listId))?.contact_count ?? undefined })}>Import list</Button>
|
|
124
122
|
<Button variant="outline" loading={preview.loading} onClick={() => void preview.mutate(undefined)}>Check verification cost</Button>
|
|
125
123
|
{preview.data ? <Button variant="outline" onClick={() => setAction({ kind: "verify" })}>Verify addresses</Button> : null}
|
|
126
124
|
</div>
|