@graph8/sdk 0.370.0 → 0.374.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -3
- package/blocks/_foundation/README.md +61 -0
- package/blocks/_foundation/block.json +57 -0
- package/blocks/_foundation/components/auth-gate.tsx +363 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
- package/blocks/_foundation/components/empty-state.tsx +5 -2
- package/blocks/_foundation/components/list-select.tsx +112 -0
- package/blocks/_foundation/components/paging-notices.tsx +55 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +2 -1
- package/blocks/_foundation/components/screen/filter-controls.tsx +92 -0
- package/blocks/_foundation/components/screen/index.ts +1 -0
- package/blocks/_foundation/components/screen/split-view.tsx +1 -1
- package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
- package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
- package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
- package/blocks/_foundation/lib/paging.ts +57 -0
- package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
- package/blocks/_foundation/server/auth.ts +815 -0
- package/blocks/agents/README.md +81 -4
- package/blocks/agents/block.json +311 -2
- package/blocks/agents/components/agent-activity.tsx +112 -0
- package/blocks/agents/components/agent-approvals.tsx +135 -0
- package/blocks/agents/components/agent-builder.tsx +17 -3
- package/blocks/agents/components/agent-conversations.tsx +143 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -2
- package/blocks/agents/components/agent-operators.tsx +164 -0
- package/blocks/agents/components/agent-run-detail.tsx +20 -3
- package/blocks/agents/components/agent-runs.tsx +3 -3
- package/blocks/agents/components/agent-skills-panel.tsx +7 -7
- package/blocks/agents/components/agent-test-history.tsx +121 -0
- package/blocks/agents/components/agents-screen.tsx +53 -11
- package/blocks/agents/components/assistant-sessions.tsx +150 -0
- package/blocks/agents/components/duplicate-agent-button.tsx +42 -0
- package/blocks/agents/components/from-template-button.tsx +65 -0
- package/blocks/agents/components/knowledge-browser.tsx +107 -0
- package/blocks/agents/components/knowledge-detail.tsx +156 -0
- package/blocks/agents/components/knowledge-form.tsx +69 -0
- package/blocks/agents/components/name-dialog.tsx +32 -0
- package/blocks/agents/components/receptionist-button.tsx +46 -0
- package/blocks/agents/components/save-as-template-button.tsx +32 -0
- package/blocks/agents/components/skill-editor.tsx +147 -0
- package/blocks/agents/components/skill-library.tsx +111 -0
- package/blocks/agents/lib/agent-types.ts +7 -1
- package/blocks/agents/lib/facts.tsx +13 -0
- package/blocks/agents/lib/knowledge-utils.ts +49 -0
- package/blocks/agents/lib/load-more.tsx +141 -0
- package/blocks/agents/lib/skill-utils.ts +91 -0
- package/blocks/agents/recipes/create-agent-and-attach-skill.ts +1 -1
- package/blocks/analytics/components/analytics-screen.tsx +16 -3
- package/blocks/analytics/components/kpi-tiles.tsx +1 -1
- package/blocks/analytics/components/report-builder.tsx +15 -2
- package/blocks/appointments/README.md +6 -0
- package/blocks/appointments/block.json +27 -0
- package/blocks/appointments/components/booking-page.tsx +2 -2
- package/blocks/appointments/components/meeting-contact-links.tsx +49 -0
- package/blocks/appointments/components/meeting-detail.tsx +2 -2
- package/blocks/appointments/components/meeting-list.tsx +29 -13
- package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
- package/blocks/campaigns/README.md +1 -1
- package/blocks/campaigns/block.json +34 -1
- package/blocks/campaigns/components/campaign-assets.tsx +1 -1
- package/blocks/campaigns/components/campaign-launch-step.tsx +16 -11
- package/blocks/campaigns/components/campaign-links.tsx +180 -0
- package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
- package/blocks/campaigns/components/campaign-wizard.tsx +10 -5
- package/blocks/campaigns/components/icp-detail.tsx +11 -2
- package/blocks/campaigns/components/icp-editor.tsx +8 -3
- package/blocks/campaigns/components/persona-detail.tsx +11 -2
- package/blocks/campaigns/components/persona-editor.tsx +6 -1
- package/blocks/campaigns/lib/campaigns-utils.ts +4 -4
- package/blocks/coach/components/call-scorecard.tsx +7 -7
- package/blocks/company-intelligence/README.md +132 -0
- package/blocks/company-intelligence/block.json +318 -0
- package/blocks/company-intelligence/components/company-intelligence-screen.tsx +114 -0
- package/blocks/company-intelligence/components/company-intelligence-tab.tsx +88 -0
- package/blocks/company-intelligence/components/company-profile.tsx +68 -0
- package/blocks/company-intelligence/components/intelligence-sources.tsx +63 -0
- package/blocks/company-intelligence/components/report-detail.tsx +189 -0
- package/blocks/company-intelligence/components/research-reports.tsx +136 -0
- package/blocks/company-intelligence/components/research-run.tsx +165 -0
- package/blocks/company-intelligence/components/source-detail.tsx +117 -0
- package/blocks/company-intelligence/components/source-view.tsx +274 -0
- package/blocks/company-intelligence/components/wiki-document-detail.tsx +116 -0
- package/blocks/company-intelligence/components/wiki-documents.tsx +66 -0
- package/blocks/company-intelligence/hooks/use-intelligence.ts +173 -0
- package/blocks/company-intelligence/lib/intelligence-types.ts +230 -0
- package/blocks/company-intelligence/lib/safe-markdown.tsx +150 -0
- package/blocks/company-intelligence/recipes/read-competitors-and-news.ts +33 -0
- package/blocks/company-intelligence/recipes/research-your-website.ts +43 -0
- package/blocks/company-intelligence/recipes/write-the-research-reports.ts +54 -0
- package/blocks/copilot/components/copilot-memory.tsx +6 -2
- package/blocks/copilot/components/copilot-panel.tsx +1 -1
- package/blocks/copilot/components/copilot-team-chat.tsx +6 -1
- package/blocks/crm/README.md +25 -0
- package/blocks/crm/block.json +257 -9
- package/blocks/crm/components/bulk-update.tsx +119 -0
- package/blocks/crm/components/companies-screen.tsx +23 -9
- package/blocks/crm/components/company-detail.tsx +2 -2
- package/blocks/crm/components/contact-detail.tsx +4 -4
- package/blocks/crm/components/contact-documents-tab.tsx +144 -0
- package/blocks/crm/components/contact-insights-tab.tsx +118 -0
- package/blocks/crm/components/contact-lookup-link.tsx +85 -0
- package/blocks/crm/components/contact-row-actions.tsx +43 -0
- package/blocks/crm/components/contact-tag-chips.tsx +113 -0
- package/blocks/crm/components/contacts-screen.tsx +77 -18
- package/blocks/crm/components/dedupe-review.tsx +6 -1
- package/blocks/crm/components/import-wizard.tsx +3 -7
- package/blocks/crm/components/list-select.tsx +20 -0
- package/blocks/crm/components/owner-assign.tsx +132 -0
- package/blocks/crm/components/record-engagement-tab.tsx +103 -0
- package/blocks/crm/components/record-form.tsx +1 -1
- package/blocks/crm/components/record-open-links.tsx +79 -0
- package/blocks/crm/components/tag-assign.tsx +112 -0
- package/blocks/crm/components/tag-manager.tsx +178 -0
- package/blocks/crm/components/tasks-screen.tsx +35 -15
- package/blocks/crm/components/unlock-info-button.tsx +82 -0
- package/blocks/crm/components/verify-email-button.tsx +93 -0
- package/blocks/crm/hooks/use-all-lists.ts +18 -0
- package/blocks/crm/hooks/use-contact-tags.ts +29 -0
- package/blocks/crm/hooks/use-page-query.ts +3 -1
- package/blocks/crm/lib/crm-types.ts +14 -0
- package/blocks/crm/lib/read-lists.ts +16 -0
- package/blocks/crm/lib/record-actions.ts +102 -0
- package/blocks/deals/README.md +25 -6
- package/blocks/deals/block.json +168 -2
- package/blocks/deals/components/close-deal-dialog.tsx +169 -0
- package/blocks/deals/components/create-deal-button.tsx +109 -0
- package/blocks/deals/components/deal-contacts-editor.tsx +134 -0
- package/blocks/deals/components/deal-detail.tsx +7 -5
- package/blocks/deals/components/deal-filters.tsx +65 -0
- package/blocks/deals/components/deal-line-items.tsx +120 -0
- package/blocks/deals/components/deal-notes-tab.tsx +97 -0
- package/blocks/deals/components/deal-related.tsx +30 -9
- package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
- package/blocks/deals/components/deals-screen.tsx +57 -22
- package/blocks/deals/components/forecast-view.tsx +4 -4
- package/blocks/deals/components/pipeline-board.tsx +8 -6
- package/blocks/deals/components/pipeline-editor.tsx +132 -0
- package/blocks/deals/components/record-deals-tab.tsx +62 -0
- package/blocks/deals/components/stage-editor.tsx +1 -1
- package/blocks/deals/hooks/use-pipeline-deals.ts +17 -4
- package/blocks/deals/lib/deal-extras.ts +156 -0
- package/blocks/dialer/README.md +14 -5
- package/blocks/dialer/block.json +17 -2
- package/blocks/dialer/components/call-contact-button.tsx +26 -9
- package/blocks/dialer/components/call-log-screen.tsx +10 -2
- package/blocks/dialer/components/call-log.tsx +16 -4
- package/blocks/dialer/components/call-queue.tsx +3 -3
- package/blocks/dialer/components/dialer-screen.tsx +69 -27
- package/blocks/dialer/components/disposition-panel.tsx +1 -1
- package/blocks/dialer/components/record-calls-tab.tsx +101 -0
- package/blocks/dialer/components/session-start-form.tsx +58 -11
- package/blocks/dialer/components/softphone.tsx +1 -1
- package/blocks/documents/README.md +86 -0
- package/blocks/documents/block.json +378 -0
- package/blocks/documents/components/account-bar.tsx +48 -0
- package/blocks/documents/components/correspondence-form.tsx +95 -0
- package/blocks/documents/components/correspondence-import.tsx +106 -0
- package/blocks/documents/components/correspondence-panel.tsx +191 -0
- package/blocks/documents/components/documents-screen.tsx +76 -0
- package/blocks/documents/components/library-document-detail.tsx +175 -0
- package/blocks/documents/components/library-document-form.tsx +105 -0
- package/blocks/documents/components/library-folder-form.tsx +74 -0
- package/blocks/documents/components/library-panel.tsx +235 -0
- package/blocks/documents/components/library-upload.tsx +160 -0
- package/blocks/documents/components/question-row.tsx +130 -0
- package/blocks/documents/components/questionnaire-detail.tsx +197 -0
- package/blocks/documents/components/questionnaires-panel.tsx +173 -0
- package/blocks/documents/components/record-documents-tab.tsx +109 -0
- package/blocks/documents/lib/documents-types.ts +252 -0
- package/blocks/documents/lib/download.ts +64 -0
- package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
- package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
- package/blocks/documents/recipes/organize-the-library.ts +25 -0
- package/blocks/email-signatures/README.md +123 -0
- package/blocks/email-signatures/block.json +341 -0
- package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
- package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
- package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
- package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
- package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
- package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
- package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
- package/blocks/email-signatures/components/signature-policy.tsx +130 -0
- package/blocks/email-signatures/components/signature-preview.tsx +119 -0
- package/blocks/email-signatures/components/signature-report.tsx +77 -0
- package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
- package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
- package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
- package/blocks/email-signatures/lib/signatures-types.ts +392 -0
- package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
- package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
- package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
- package/blocks/enrichment/README.md +38 -10
- package/blocks/enrichment/block.json +164 -12
- package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
- package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +79 -30
- package/blocks/enrichment/components/email-verify-badge.tsx +3 -3
- package/blocks/enrichment/components/enrich-button.tsx +1 -1
- package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
- package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +103 -19
- package/blocks/enrichment/components/list-pipelines.tsx +97 -0
- package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
- package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
- package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
- package/blocks/enrichment/components/waterfall-config.tsx +27 -7
- package/blocks/enrichment/lib/enrichment-types.ts +51 -0
- package/blocks/enrichment/lib/pipeline-types.ts +234 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
- package/blocks/forms/README.md +10 -0
- package/blocks/forms/block.json +107 -0
- package/blocks/forms/components/form-activity.tsx +71 -0
- package/blocks/forms/components/form-backfill.tsx +108 -0
- package/blocks/forms/components/form-builder.tsx +1 -1
- package/blocks/forms/components/form-embed.tsx +1 -1
- package/blocks/forms/components/form-groups.tsx +151 -0
- package/blocks/forms/components/form-mapping-editor.tsx +6 -9
- package/blocks/forms/components/form-route-history.tsx +72 -0
- package/blocks/forms/components/form-saved-views.tsx +81 -0
- package/blocks/forms/components/forms-screen.tsx +57 -20
- package/blocks/forms/components/more-rows.tsx +30 -0
- package/blocks/forms/components/submissions-export.tsx +67 -0
- package/blocks/forms/components/submissions-table.tsx +15 -6
- package/blocks/forms/lib/forms-extra.ts +67 -0
- package/blocks/inbox/README.md +45 -4
- package/blocks/inbox/block.json +145 -8
- package/blocks/inbox/components/blocked-senders.tsx +103 -0
- package/blocks/inbox/components/compose-message.tsx +193 -0
- package/blocks/inbox/components/inbox-screen.tsx +54 -6
- package/blocks/inbox/components/inbox-search.tsx +106 -0
- package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
- package/blocks/inbox/components/tag-manager.tsx +136 -0
- package/blocks/inbox/components/thread-actions.tsx +133 -0
- package/blocks/inbox/components/thread-badge.tsx +2 -2
- package/blocks/inbox/components/thread-view.tsx +3 -1
- package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
- package/blocks/inbox/lib/inbox-manage.ts +227 -0
- package/blocks/integrations/README.md +54 -1
- package/blocks/integrations/block.json +189 -15
- package/blocks/integrations/components/ad-accounts.tsx +82 -0
- package/blocks/integrations/components/connection-health.tsx +59 -0
- package/blocks/integrations/components/crm-detail.tsx +20 -1
- package/blocks/integrations/components/crm-records.tsx +103 -0
- package/blocks/integrations/components/field-mappings.tsx +61 -0
- package/blocks/integrations/components/integration-list.tsx +4 -4
- package/blocks/integrations/components/integrations-screen.tsx +20 -3
- package/blocks/integrations/components/migration-panel.tsx +133 -0
- package/blocks/integrations/components/sugarcrm-connect.tsx +95 -0
- package/blocks/integrations/components/sync-status.tsx +6 -6
- package/blocks/integrations/components/sync-triggers.tsx +134 -0
- package/blocks/integrations/components/webhook-detail.tsx +26 -7
- package/blocks/integrations/components/webhook-manager.tsx +30 -17
- package/blocks/integrations/components/workspace-tools.tsx +127 -0
- package/blocks/integrations/lib/crm-utils.ts +39 -0
- package/blocks/integrations/lib/facts.tsx +13 -0
- package/blocks/integrations/lib/integrations-utils.ts +5 -0
- package/blocks/integrations/lib/load-more.tsx +141 -0
- package/blocks/intent/components/account-intent-score.tsx +5 -5
- package/blocks/intent/components/intent-feed.tsx +8 -8
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -8
- package/blocks/intent/lib/intent-utils.ts +4 -4
- package/blocks/lifecycle/components/account-related.tsx +8 -1
- package/blocks/lifecycle/components/churn-list.tsx +5 -2
- package/blocks/lifecycle/components/customer-table.tsx +1 -1
- package/blocks/lists/README.md +7 -1
- package/blocks/lists/block.json +31 -3
- package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
- package/blocks/lists/components/add-to-list-button.tsx +17 -7
- package/blocks/lists/components/list-picker.tsx +13 -3
- package/blocks/lists/components/list-table.tsx +1 -1
- package/blocks/lists/components/lists-screen.tsx +4 -2
- package/blocks/lists/components/record-lists-tab.tsx +99 -0
- package/blocks/lists/components/suppression-manager.tsx +27 -6
- package/blocks/lists/components/suppressions-screen.tsx +21 -12
- package/blocks/mailboxes/block.json +4 -0
- package/blocks/mailboxes/components/connect-mailbox.tsx +9 -8
- package/blocks/mailboxes/components/domain-detail.tsx +10 -4
- package/blocks/mailboxes/components/domain-purchase.tsx +14 -11
- package/blocks/mailboxes/components/mailbox-detail.tsx +3 -2
- package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
- package/blocks/mailboxes/components/mailboxes-screen.tsx +8 -3
- package/blocks/mailboxes/components/warmup-health.tsx +20 -20
- package/blocks/mailboxes/lib/mailboxes-utils.ts +4 -4
- package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
- package/blocks/newsletter/README.md +12 -0
- package/blocks/newsletter/block.json +117 -0
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -2
- package/blocks/newsletter/components/issue-versions.tsx +93 -0
- package/blocks/newsletter/components/more-rows.tsx +30 -0
- package/blocks/newsletter/components/newsletter-editor.tsx +1 -1
- package/blocks/newsletter/components/newsletter-list-syncs.tsx +121 -0
- package/blocks/newsletter/components/newsletter-screen.tsx +38 -13
- package/blocks/newsletter/components/newsletter-settings.tsx +211 -0
- package/blocks/newsletter/components/newsletter-stats.tsx +2 -2
- package/blocks/newsletter/components/newsletter-templates.tsx +133 -0
- package/blocks/newsletter/components/subscriber-table.tsx +5 -7
- package/blocks/newsletter/components/subscriber-timeline.tsx +65 -0
- package/blocks/newsletter/lib/newsletter-extra.ts +60 -0
- package/blocks/nurture/components/nurture-builder.tsx +10 -2
- package/blocks/nurture/components/nurture-enroll.tsx +6 -7
- package/blocks/nurture/components/nurture-form.tsx +5 -8
- package/blocks/nurture/components/nurture-screen.tsx +16 -5
- package/blocks/nurture/components/nurture-stats.tsx +5 -4
- package/blocks/objects/README.md +1 -1
- package/blocks/objects/block.json +13 -5
- package/blocks/objects/components/attribute-field-input.tsx +14 -1
- package/blocks/objects/components/object-record-detail.tsx +8 -1
- package/blocks/objects/components/object-schema-editor.tsx +1 -1
- package/blocks/objects/components/record-roles-panel.tsx +15 -5
- package/blocks/pages/README.md +14 -0
- package/blocks/pages/block.json +123 -2
- package/blocks/pages/components/more-rows.tsx +30 -0
- package/blocks/pages/components/page-ai-tools.tsx +196 -0
- package/blocks/pages/components/page-analytics.tsx +176 -0
- package/blocks/pages/components/page-clone.tsx +99 -0
- package/blocks/pages/components/page-domains.tsx +167 -0
- package/blocks/pages/components/page-editor.tsx +1 -1
- package/blocks/pages/components/page-generation-status.tsx +48 -0
- package/blocks/pages/components/page-tracking.tsx +137 -0
- package/blocks/pages/components/pages-screen.tsx +57 -16
- package/blocks/pages/components/seo-report.tsx +6 -1
- package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
- package/blocks/pages/lib/studio.ts +7 -0
- package/blocks/phone-numbers/README.md +74 -0
- package/blocks/phone-numbers/block.json +262 -0
- package/blocks/phone-numbers/components/a2p-registration.tsx +230 -0
- package/blocks/phone-numbers/components/assign-number.tsx +104 -0
- package/blocks/phone-numbers/components/number-search.tsx +239 -0
- package/blocks/phone-numbers/components/number-settings.tsx +134 -0
- package/blocks/phone-numbers/components/number-usage.tsx +97 -0
- package/blocks/phone-numbers/components/phone-number-detail.tsx +84 -0
- package/blocks/phone-numbers/components/phone-number-list.tsx +110 -0
- package/blocks/phone-numbers/components/phone-numbers-screen.tsx +201 -0
- package/blocks/phone-numbers/components/register-owned-number.tsx +98 -0
- package/blocks/phone-numbers/components/release-number.tsx +126 -0
- package/blocks/phone-numbers/hooks/use-phone-numbers.ts +58 -0
- package/blocks/phone-numbers/lib/phone-numbers-utils.ts +202 -0
- package/blocks/phone-numbers/recipes/01-search-and-buy-number.ts +49 -0
- package/blocks/phone-numbers/recipes/02-assign-number-to-rep.ts +25 -0
- package/blocks/phone-numbers/recipes/03-audit-numbers.ts +36 -0
- package/blocks/phone-numbers/recipes/04-register-text-messaging.ts +39 -0
- package/blocks/pickers/README.md +23 -0
- package/blocks/pickers/block.json +103 -0
- package/blocks/pickers/components/company-picker.tsx +90 -0
- package/blocks/pickers/components/contact-picker.tsx +131 -0
- package/blocks/pickers/components/member-picker.tsx +99 -0
- package/blocks/pickers/components/search-picker.tsx +98 -0
- package/blocks/pickers/hooks/use-current-member.ts +104 -0
- package/blocks/pickers/hooks/use-picker-pages.ts +73 -0
- package/blocks/pickers/lib/use-debounced.ts +13 -0
- package/blocks/programmatic-pages/README.md +82 -0
- package/blocks/programmatic-pages/block.json +292 -0
- package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
- package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
- package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
- package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
- package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
- package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
- package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
- package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
- package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
- package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
- package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
- package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
- package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
- package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
- package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
- package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
- package/blocks/quotes/README.md +38 -1
- package/blocks/quotes/block.json +170 -6
- package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
- package/blocks/quotes/components/manual-products.tsx +144 -0
- package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
- package/blocks/quotes/components/quote-builder.tsx +1 -1
- package/blocks/quotes/components/quote-documents.tsx +94 -0
- package/blocks/quotes/components/quote-evidence.tsx +95 -0
- package/blocks/quotes/components/quote-lifecycle-actions.tsx +146 -0
- package/blocks/quotes/components/quote-list.tsx +28 -8
- package/blocks/quotes/components/quote-related.tsx +3 -2
- package/blocks/quotes/components/quote-settings.tsx +184 -0
- package/blocks/quotes/components/quote-templates.tsx +189 -0
- package/blocks/quotes/components/quote-viewer.tsx +23 -5
- package/blocks/quotes/components/quotes-screen.tsx +15 -5
- package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
- package/blocks/quotes/lib/quote-admin.ts +264 -0
- package/blocks/radar/components/competitor-battle-card.tsx +1 -1
- package/blocks/radar/components/radar-overview.tsx +4 -4
- package/blocks/radar/components/radar-screen.tsx +2 -2
- package/blocks/sequencer/README.md +61 -0
- package/blocks/sequencer/block.json +200 -6
- package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +2 -2
- package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
- package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
- package/blocks/sequencer/components/sequence-builder.tsx +32 -23
- package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
- package/blocks/sequencer/components/sequence-detail.tsx +10 -2
- package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +5 -2
- package/blocks/sequencer/components/sequence-list.tsx +1 -1
- package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
- package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
- package/blocks/sequencer/components/sequence-stats.tsx +8 -8
- package/blocks/sequencer/components/sequence-templates.tsx +160 -0
- package/blocks/sequencer/components/sequences-screen.tsx +39 -6
- package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
- package/blocks/sequencer/components/step-editor.tsx +21 -12
- package/blocks/sequencer/components/step-templates.tsx +224 -0
- package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
- package/blocks/sequencer/lib/sequencer-utils.ts +9 -5
- package/blocks/signals/README.md +39 -0
- package/blocks/signals/block.json +287 -0
- package/blocks/signals/components/auto-seed-panel.tsx +111 -0
- package/blocks/signals/components/listener-form.tsx +184 -0
- package/blocks/signals/components/matched-posts.tsx +195 -0
- package/blocks/signals/components/post-engagements.tsx +114 -0
- package/blocks/signals/components/record-social-tab.tsx +49 -0
- package/blocks/signals/components/social-listeners-screen.tsx +250 -0
- package/blocks/signals/components/social-settings.tsx +128 -0
- package/blocks/signals/components/source-lists.tsx +83 -0
- package/blocks/signals/components/top-engagers.tsx +68 -0
- package/blocks/signals/components/x-integration.tsx +129 -0
- package/blocks/signals/lib/signals-types.ts +228 -0
- package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
- package/blocks/signals/recipes/spend-against-caps.ts +14 -0
- package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
- package/blocks/social-ads/README.md +8 -0
- package/blocks/social-ads/block.json +137 -4
- package/blocks/social-ads/components/audience-sync-detail.tsx +9 -3
- package/blocks/social-ads/components/audience-sync.tsx +6 -2
- package/blocks/social-ads/components/campaign-ads.tsx +166 -0
- package/blocks/social-ads/components/campaign-detail.tsx +6 -1
- package/blocks/social-ads/components/campaign-editor.tsx +134 -0
- package/blocks/social-ads/components/creative-concepts.tsx +74 -0
- package/blocks/social-ads/components/creative-detail.tsx +6 -1
- package/blocks/social-ads/components/creative-gallery.tsx +20 -2
- package/blocks/social-ads/components/creative-revisions.tsx +57 -0
- package/blocks/social-ads/components/creative-tools.tsx +176 -0
- package/blocks/social-ads/components/post-composer.tsx +4 -2
- package/blocks/social-ads/components/post-detail.tsx +3 -0
- package/blocks/social-ads/components/social-ideas.tsx +64 -0
- package/blocks/social-ads/components/social-performance.tsx +80 -0
- package/blocks/social-ads/components/social-screen.tsx +33 -6
- package/blocks/social-ads/lib/ads-extra.ts +67 -0
- package/blocks/surveys/README.md +91 -0
- package/blocks/surveys/block.json +299 -0
- package/blocks/surveys/components/account-nps.tsx +69 -0
- package/blocks/surveys/components/contact-survey-history.tsx +52 -0
- package/blocks/surveys/components/org-summary.tsx +82 -0
- package/blocks/surveys/components/sending-domains.tsx +113 -0
- package/blocks/surveys/components/survey-form.tsx +126 -0
- package/blocks/surveys/components/survey-responses.tsx +145 -0
- package/blocks/surveys/components/survey-results.tsx +72 -0
- package/blocks/surveys/components/survey-send-history.tsx +58 -0
- package/blocks/surveys/components/survey-send.tsx +162 -0
- package/blocks/surveys/components/survey-triggers.tsx +166 -0
- package/blocks/surveys/components/surveys-screen.tsx +234 -0
- package/blocks/surveys/lib/surveys-types.ts +228 -0
- package/blocks/surveys/recipes/create-an-nps-survey.ts +22 -0
- package/blocks/surveys/recipes/send-a-survey-and-follow-it.ts +27 -0
- package/blocks/surveys/recipes/survey-score-report.ts +27 -0
- package/blocks/visitors/components/snippet-install.tsx +18 -9
- package/blocks/visitors/components/traffic-chart.tsx +7 -6
- package/blocks/visitors/components/visitor-company-card.tsx +24 -14
- package/blocks/visitors/components/visitor-feed.tsx +5 -5
- package/blocks/web-analytics/README.md +102 -0
- package/blocks/web-analytics/block.json +292 -0
- package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
- package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
- package/blocks/web-analytics/components/audience-tab.tsx +135 -0
- package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
- package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
- package/blocks/web-analytics/components/overview-tab.tsx +102 -0
- package/blocks/web-analytics/components/pages-tab.tsx +148 -0
- package/blocks/web-analytics/components/ranked-table.tsx +103 -0
- package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
- package/blocks/web-analytics/components/report-card.tsx +71 -0
- package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
- package/blocks/web-analytics/hooks/use-report.ts +48 -0
- package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
- package/blocks/web-analytics/lib/page-path.ts +14 -0
- package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
- package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
- package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
- package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
- package/blocks/webchat/components/chat-widget-config.tsx +1 -1
- package/blocks/webchat/components/conversation-list.tsx +1 -1
- package/blocks/workbench/README.md +54 -2
- package/blocks/workbench/block.json +234 -5
- package/blocks/workbench/components/column-recipe-editor.tsx +1 -1
- package/blocks/workbench/components/run-progress.tsx +1 -1
- package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
- package/blocks/workbench/components/workbench-automation.tsx +36 -0
- package/blocks/workbench/components/workbench-connections.tsx +136 -0
- package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
- package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
- package/blocks/workbench/components/workbench-grid.tsx +71 -8
- package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
- package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
- package/blocks/workbench/components/workbench-schedule.tsx +191 -0
- package/blocks/workbench/components/workbench-screen.tsx +65 -22
- package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
- package/blocks/workbench/components/workbench-table-list.tsx +14 -2
- package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
- package/blocks/workbench/components/workbench-triggers.tsx +178 -0
- package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
- package/blocks/workbench/lib/api-status.ts +8 -0
- package/blocks/workbench/lib/automation-types.ts +257 -0
- package/blocks/workflows/components/workflow-editor.tsx +1 -1
- package/dist/cli.js +744 -394
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/auth.d.ts +45 -0
- package/dist/types/blocks/brand.d.ts +11 -2
- package/dist/types/blocks/create.d.ts +11 -4
- package/dist/types/blocks/doctor.d.ts +1 -0
- package/dist/types/blocks/flows.d.ts +7 -1
- package/dist/types/blocks/orgBrand.d.ts +34 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/version.d.ts +2 -2
- package/docs/api/desk.d.ts +2 -2
- package/docs/api/desk.md +3 -3
- package/docs/api/forms.md +1 -1
- package/package.json +1 -1
- package/stacks/6sense/pages/companies.tsx.tmpl +21 -0
- package/stacks/6sense/pages/contacts.tsx.tmpl +21 -0
- package/stacks/6sense/pages/tasks.tsx.tmpl +19 -0
- package/stacks/6sense.json +37 -4
- package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
- package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/apollo/pages/companies.tsx.tmpl +19 -36
- package/stacks/apollo/pages/contacts.tsx.tmpl +35 -95
- package/stacks/apollo/pages/deals.tsx.tmpl +17 -0
- package/stacks/apollo/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/apollo/pages/lists.tsx.tmpl +24 -0
- package/stacks/apollo/pages/meetings.tsx.tmpl +13 -0
- package/stacks/apollo/pages/sequences.tsx.tmpl +18 -0
- package/stacks/apollo/pages/tasks.tsx.tmpl +20 -0
- package/stacks/apollo.json +68 -9
- package/stacks/calendly/pages/companies.tsx.tmpl +19 -0
- package/stacks/calendly/pages/contacts.tsx.tmpl +21 -0
- package/stacks/calendly/pages/meetings.tsx.tmpl +13 -0
- package/stacks/calendly/pages/tasks.tsx.tmpl +19 -0
- package/stacks/calendly.json +8 -4
- package/stacks/clay/pages/companies.tsx.tmpl +17 -1
- package/stacks/clay/pages/contacts.tsx.tmpl +14 -0
- package/stacks/clay/pages/lists.tsx.tmpl +24 -0
- package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
- package/stacks/clay.json +16 -3
- package/stacks/gong/pages/calls.tsx.tmpl +23 -0
- package/stacks/gong/pages/companies.tsx.tmpl +22 -0
- package/stacks/gong/pages/contacts.tsx.tmpl +34 -0
- package/stacks/gong/pages/lists.tsx.tmpl +12 -0
- package/stacks/gong/pages/meetings.tsx.tmpl +13 -0
- package/stacks/gong/pages/tasks.tsx.tmpl +19 -0
- package/stacks/gong.json +23 -6
- package/stacks/hootsuite/pages/companies.tsx.tmpl +22 -0
- package/stacks/hootsuite/pages/contacts.tsx.tmpl +30 -0
- package/stacks/hootsuite/pages/lists.tsx.tmpl +12 -0
- package/stacks/hootsuite/pages/tasks.tsx.tmpl +19 -0
- package/stacks/hootsuite.json +19 -4
- package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +16 -35
- package/stacks/hubspot/pages/contacts.tsx.tmpl +35 -95
- package/stacks/hubspot/pages/deals.tsx.tmpl +14 -1
- package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +12 -1
- package/stacks/hubspot/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/hubspot/pages/lists.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/meetings.tsx.tmpl +13 -0
- package/stacks/hubspot/pages/sequences.tsx.tmpl +18 -0
- package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
- package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
- package/stacks/hubspot.json +66 -7
- package/stacks/intercom/pages/companies.tsx.tmpl +19 -0
- package/stacks/intercom/pages/contacts.tsx.tmpl +21 -0
- package/stacks/intercom/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/intercom/pages/tasks.tsx.tmpl +19 -0
- package/stacks/intercom.json +18 -4
- package/stacks/mailchimp/pages/companies.tsx.tmpl +22 -0
- package/stacks/mailchimp/pages/contacts.tsx.tmpl +30 -0
- package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
- package/stacks/mailchimp/pages/lists.tsx.tmpl +12 -0
- package/stacks/mailchimp/pages/tasks.tsx.tmpl +19 -0
- package/stacks/mailchimp.json +22 -6
- package/stacks/outreach/pages/calls.tsx.tmpl +23 -0
- package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/outreach/pages/companies.tsx.tmpl +22 -0
- package/stacks/outreach/pages/contacts.tsx.tmpl +29 -66
- package/stacks/outreach/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/outreach/pages/lists.tsx.tmpl +12 -0
- package/stacks/outreach/pages/meetings.tsx.tmpl +13 -0
- package/stacks/outreach/pages/sequences.tsx.tmpl +18 -0
- package/stacks/outreach/pages/tasks.tsx.tmpl +19 -0
- package/stacks/outreach.json +38 -9
- package/stacks/pipedrive/pages/companies.tsx.tmpl +11 -35
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +17 -35
- package/stacks/pipedrive/pages/deals.tsx.tmpl +10 -1
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +12 -1
- package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
- package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
- package/stacks/pipedrive.json +14 -2
- package/stacks/salesforce/pages/companies.tsx.tmpl +16 -35
- package/stacks/salesforce/pages/contacts.tsx.tmpl +26 -35
- package/stacks/salesforce/pages/deals.tsx.tmpl +14 -1
- package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
- package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
- package/stacks/salesforce.json +17 -3
- package/stacks/zoominfo/pages/companies.tsx.tmpl +17 -1
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +16 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +24 -0
- package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +36 -3
- package/styles/styles.css +1 -1
- package/templates/auth/auth-route.ts.tmpl +16 -0
- package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
- package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
- package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
- package/templates/auth/g8.auth.password.ts.tmpl +18 -0
- package/templates/auth/g8.auth.session.ts.tmpl +23 -0
- package/templates/auth/middleware.ts.tmpl +14 -0
- package/templates/next-app/README.md.tmpl +5 -0
- package/templates/next-app/app/globals.css.tmpl +349 -64
- package/templates/next-app/components/app-shell.tsx.tmpl +6 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
|
@@ -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,12 +42,18 @@ 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";
|
|
49
58
|
import { useBrand } from "@/g8/hooks/use-brand";
|
|
50
59
|
|
|
@@ -58,7 +67,9 @@ export interface NewsletterScreenProps {
|
|
|
58
67
|
className?: string;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
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)
|
|
62
73
|
const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
|
|
63
74
|
|
|
64
75
|
function tone(status: string | undefined): Tone {
|
|
@@ -119,7 +130,7 @@ function IssueDetails({ issue }: { issue: Issue }) {
|
|
|
119
130
|
);
|
|
120
131
|
}
|
|
121
132
|
|
|
122
|
-
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 } }) {
|
|
123
134
|
const [confirm, setConfirm] = useState<"unsubscribe" | "erase" | null>(null);
|
|
124
135
|
const base = `/newsletters/${encodeURIComponent(newsletterId)}/subscribers/${subscriber.id}`;
|
|
125
136
|
const run = useG8Mutation((kind: "unsubscribe" | "erase") => (kind === "unsubscribe" ? g8Patch(`${base}/unsubscribe`, {}) : g8Delete(base)), {
|
|
@@ -155,6 +166,7 @@ function SubscriberDetail({ newsletterId, subscriber, onChanged, onClose, expand
|
|
|
155
166
|
...extra.map(([key, v]) => ({ label: key.replace(/_/g, " "), value: String(v) })),
|
|
156
167
|
]}
|
|
157
168
|
/>
|
|
169
|
+
{issues ? <SubscriberTimeline className="mt-4" newsletterId={newsletterId} subscriberId={subscriber.id} issues={issues} /> : null}
|
|
158
170
|
<ConfirmSpendDialog
|
|
159
171
|
open={confirm !== null}
|
|
160
172
|
tier={confirm === "erase" ? "destructive" : "external"}
|
|
@@ -177,7 +189,7 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
177
189
|
const [view, setView] = useUrlState("view", "");
|
|
178
190
|
const expandProps = { onExpand: () => setView("full"), onCollapse: view === "full" ? () => setView(null) : undefined };
|
|
179
191
|
const [tabParam, setTabParam] = useUrlState("tab", "issues");
|
|
180
|
-
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;
|
|
181
193
|
const closeDetail = (): void => { setIssueParam(null); setView(null); };
|
|
182
194
|
const [subRefresh, setSubRefresh] = useState(0);
|
|
183
195
|
const [openedSubscriber, setOpenedSubscriber] = useState<Subscriber | null>(null);
|
|
@@ -187,6 +199,10 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
187
199
|
const [name, setName] = useState("");
|
|
188
200
|
const [goal, setGoal] = useState("");
|
|
189
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
|
+
// Bumped after a version restore so the Write tab remounts and reloads the draft. The History tab is NOT keyed on it:
|
|
204
|
+
// remounting it would drop a Duplicate confirm opened right after the restore (it refetches its own versions).
|
|
205
|
+
const [formRev, setFormRev] = useState(0);
|
|
190
206
|
|
|
191
207
|
const newsletters = useG8Query((signal) => g8Get<Newsletter[]>("/newsletters", {}, { signal }).then((r) => r.data ?? []), []);
|
|
192
208
|
const list = newsletters.data ?? [];
|
|
@@ -209,12 +225,17 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
209
225
|
const pause = useG8Mutation(() => g8Post(`${base}/pause`), { onSuccess: () => void newsletters.refetch() });
|
|
210
226
|
|
|
211
227
|
const subscriberId = tab === "subscribers" && issueParam ? Number(issueParam) : undefined;
|
|
212
|
-
|
|
213
|
-
|
|
228
|
+
// A deep link to a subscriber reads every page (100 per read), so no subscriber is out of reach.
|
|
229
|
+
const lookup = useAllPages<Subscriber>(
|
|
230
|
+
async (page, signal) => {
|
|
231
|
+
const r = await g8Get<{ items?: Subscriber[]; total?: number }>(`${base}/subscribers`, { page, page_size: SUBSCRIBER_PAGE }, { signal });
|
|
232
|
+
const rows = r.data?.items ?? [];
|
|
233
|
+
return { rows, hasNext: r.data?.total != null ? page * SUBSCRIBER_PAGE < r.data.total : rows.length >= SUBSCRIBER_PAGE };
|
|
234
|
+
},
|
|
214
235
|
[base, subRefresh],
|
|
215
|
-
{ enabled: subscriberId !== undefined && openedSubscriber?.id !== subscriberId },
|
|
236
|
+
{ enabled: Boolean(newsletterId) && subscriberId !== undefined && openedSubscriber?.id !== subscriberId },
|
|
216
237
|
);
|
|
217
|
-
const openSubscriber = subscriberId === undefined ? null : openedSubscriber?.id === subscriberId ? openedSubscriber : (
|
|
238
|
+
const openSubscriber = subscriberId === undefined ? null : openedSubscriber?.id === subscriberId ? openedSubscriber : (lookup.items.find((x) => x.id === subscriberId) ?? null);
|
|
218
239
|
const all = issues.data ?? [];
|
|
219
240
|
const sent = all.filter((i) => i.status === "sent" || i.sent_at);
|
|
220
241
|
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 });
|
|
@@ -227,9 +248,10 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
227
248
|
const pickNewsletter = (id: string): void => { setIssueParam(null); setUrlNewsletter(id); };
|
|
228
249
|
const detailTabs = selected
|
|
229
250
|
? [
|
|
230
|
-
{ 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(); }} /> },
|
|
251
|
+
{ 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(); }} /> },
|
|
231
252
|
{ id: "send", label: "Send", content: <IssueScheduler key={`s${selected.id}`} newsletterId={newsletterId} issueId={selected.id} onSent={() => void issues.refetch()} /> },
|
|
232
253
|
{ id: "results", label: "Results", content: <IssueResults newsletterId={newsletterId} issue={selected} /> },
|
|
254
|
+
{ id: "history", label: "History", content: <IssueVersions key={`v${selected.id}`} newsletterId={newsletterId} issue={selected} onRestored={() => { setFormRev((n) => n + 1); void issues.refetch(); }} onDuplicated={(id) => { void issues.refetch(); setIssueParam(String(id)); }} /> },
|
|
233
255
|
{ id: "details", label: "Issue details", content: <IssueDetails issue={selected} /> },
|
|
234
256
|
...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
|
|
235
257
|
]
|
|
@@ -287,9 +309,9 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
287
309
|
detailWidth="lg"
|
|
288
310
|
list={<SubscriberTable key={newsletterId} newsletterId={newsletterId} refreshKey={subRefresh} selectedId={subscriberId} onOpen={(sub) => { setOpenedSubscriber(sub); setIssueParam(String(sub.id)); }} />}
|
|
289
311
|
detail={openSubscriber ? (
|
|
290
|
-
<SubscriberDetail key={openSubscriber.id} newsletterId={newsletterId} subscriber={openSubscriber} onChanged={() => { setSubRefresh((n) => n + 1); setOpenedSubscriber(null); }} onClose={() => { closeDetail(); setOpenedSubscriber(null); }} expandProps={expandProps} />
|
|
312
|
+
<SubscriberDetail key={openSubscriber.id} newsletterId={newsletterId} subscriber={openSubscriber} issues={all} onChanged={() => { setSubRefresh((n) => n + 1); setOpenedSubscriber(null); }} onClose={() => { closeDetail(); setOpenedSubscriber(null); }} expandProps={expandProps} />
|
|
291
313
|
) : lookup.loading ? <Skeleton className="h-40 w-full" aria-busy="true" /> : (
|
|
292
|
-
<EmptyState title="Subscriber not found" description="Open a subscriber from the list.
|
|
314
|
+
<EmptyState title="Subscriber not found" description="Open a subscriber from the list." />
|
|
293
315
|
)}
|
|
294
316
|
/>
|
|
295
317
|
)
|
|
@@ -320,6 +342,9 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
320
342
|
</DetailPanel>
|
|
321
343
|
)
|
|
322
344
|
: tab === "performance" ? <NewsletterStats key={newsletterId} newsletterId={newsletterId} />
|
|
345
|
+
: tab === "settings" ? <NewsletterSettings key={newsletterId} newsletterId={newsletterId} onChanged={() => void newsletters.refetch()} />
|
|
346
|
+
: tab === "lists" ? <NewsletterListSyncs key={newsletterId} newsletterId={newsletterId} onChanged={() => setSubRefresh((n) => n + 1)} />
|
|
347
|
+
: tab === "templates" ? <NewsletterTemplates key={newsletterId} newsletterId={newsletterId} onUsed={(id) => { void issues.refetch(); setTabParam(null); setIssueParam(String(id)); }} />
|
|
323
348
|
: (
|
|
324
349
|
<SplitView
|
|
325
350
|
detailOpen={detailOpen}
|
|
@@ -364,7 +389,7 @@ export function NewsletterScreen({ initialNewsletterId, renderRowActions, render
|
|
|
364
389
|
<Button size="sm" onClick={() => setSheet(true)}><Plus className="size-4" />New newsletter</Button>
|
|
365
390
|
</>
|
|
366
391
|
)}
|
|
367
|
-
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}
|
|
392
|
+
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}
|
|
368
393
|
activeTab={tab}
|
|
369
394
|
onTabChange={(id) => { setTabParam(id === "issues" ? null : id); closeDetail(); setOpenedSubscriber(null); }}
|
|
370
395
|
stats={current ? [
|
|
@@ -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>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SubscriberTimeline: what one subscriber did with one sent issue (delivered, opened, clicked...), in order.
|
|
5
|
+
* Pick the issue from the ones already sent.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations:
|
|
8
|
+
* 1. getSubscriberTimeline GET /newsletters/{id}/issues/{issue}/analytics/subscribers/{subscriber}/timeline (read)
|
|
9
|
+
*
|
|
10
|
+
* The payload is open-shaped: events are read from `events` (or `timeline`, or a bare list) and shown with whatever
|
|
11
|
+
* of `type`, `event`, `occurred_at`, `url` is present.
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useState } from "react";
|
|
14
|
+
import { Skeleton } from "@graph8/sdk/ui";
|
|
15
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
16
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
17
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
18
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
19
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
20
|
+
import { issueTitle } from "../lib/newsletter-types";
|
|
21
|
+
import type { Issue } from "../lib/newsletter-types";
|
|
22
|
+
import { rowsOf, str } from "../lib/newsletter-extra";
|
|
23
|
+
import type { Rec } from "../lib/newsletter-extra";
|
|
24
|
+
|
|
25
|
+
export interface SubscriberTimelineProps {
|
|
26
|
+
newsletterId: string;
|
|
27
|
+
subscriberId: number;
|
|
28
|
+
/** The issues to choose from (only sent ones are offered). */
|
|
29
|
+
issues: Issue[];
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const when = (v: unknown): string => {
|
|
34
|
+
const d = new Date(str(v));
|
|
35
|
+
return Number.isNaN(d.getTime()) ? "" : d.toLocaleString();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function SubscriberTimeline({ newsletterId, subscriberId, issues, className }: SubscriberTimelineProps) {
|
|
39
|
+
const sent = issues.filter((i) => i.status === "sent" || i.sent_at);
|
|
40
|
+
const [issueId, setIssueId] = useState<string>(sent[0] ? String(sent[0].id) : "");
|
|
41
|
+
// On a deep link the issues arrive after the first render: pick the newest sent one once they do.
|
|
42
|
+
useEffect(() => { if (issueId === "" && sent[0]) setIssueId(String(sent[0].id)); }, [issueId, sent.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
43
|
+
const res = useG8Query(
|
|
44
|
+
(signal) => g8Get(`/newsletters/${encodeURIComponent(newsletterId)}/issues/${issueId}/analytics/subscribers/${subscriberId}/timeline`, undefined, { signal }),
|
|
45
|
+
[newsletterId, issueId, subscriberId],
|
|
46
|
+
{ enabled: issueId !== "" },
|
|
47
|
+
);
|
|
48
|
+
const events = rowsOf<Rec>(res.data?.data, "events", "timeline");
|
|
49
|
+
return (
|
|
50
|
+
<SectionCard className={className} title="Activity on an issue" description="What this person did with one sent issue."
|
|
51
|
+
actions={sent.length ? <select aria-label="Issue" className="border-input bg-background h-8 rounded-md border px-2 text-sm" value={issueId} onChange={(e) => setIssueId(e.target.value)}>{sent.map((i) => <option key={i.id} value={String(i.id)}>{issueTitle(i)}</option>)}</select> : undefined}>
|
|
52
|
+
{sent.length === 0 ? <EmptyState compact title="No sent issues yet" description="Activity appears after an issue is sent." />
|
|
53
|
+
: res.error ? <ApiError error={res.error} title="Could not load the activity" onRetry={() => void res.refetch()} />
|
|
54
|
+
: res.loading && !res.data ? <Skeleton className="h-16 w-full" aria-busy="true" />
|
|
55
|
+
: events.length === 0 ? <EmptyState compact title="No activity recorded" description="This person has not opened or clicked this issue." />
|
|
56
|
+
: (
|
|
57
|
+
<ol className="grid gap-2">
|
|
58
|
+
{events.map((e, i) => (
|
|
59
|
+
<li key={i} className="rounded-md border p-2 text-sm"><span className="font-medium">{str(e.type) || str(e.event) || "Event"}</span> <span className="text-muted-foreground text-xs">{when(e.occurred_at) || when(e.timestamp) || when(e.created_at)}</span>{str(e.url) ? <span className="text-muted-foreground block truncate text-xs">{str(e.url)}</span> : null}</li>
|
|
60
|
+
))}
|
|
61
|
+
</ol>
|
|
62
|
+
)}
|
|
63
|
+
</SectionCard>
|
|
64
|
+
);
|
|
65
|
+
}
|