@graph8/sdk 0.308.1 → 0.357.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 +81 -0
- package/blocks/README.md +32 -0
- package/blocks/_foundation/README.md +78 -0
- package/blocks/_foundation/block.json +79 -0
- package/blocks/_foundation/components/api-error.tsx +62 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +142 -0
- package/blocks/_foundation/components/empty-state.tsx +24 -0
- package/blocks/_foundation/hooks/use-g8-mutation.ts +88 -0
- package/blocks/_foundation/hooks/use-g8-query.ts +87 -0
- package/blocks/_foundation/hooks/use-job-poll.ts +130 -0
- package/blocks/_foundation/hooks/use-paginated.ts +105 -0
- package/blocks/_foundation/lib/g8-client.ts +128 -0
- package/blocks/_foundation/recipes/verify-proxy-key.ts +18 -0
- package/blocks/_foundation/server/proxy.ts +233 -0
- package/blocks/_foundation/server/route.ts +30 -0
- package/blocks/agents/README.md +133 -0
- package/blocks/agents/block.json +223 -0
- package/blocks/agents/components/agent-builder.tsx +199 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +144 -0
- package/blocks/agents/components/agent-runs.tsx +175 -0
- package/blocks/agents/components/agent-skills-panel.tsx +126 -0
- package/blocks/agents/components/agent-test-console.tsx +175 -0
- package/blocks/agents/lib/agent-types.ts +114 -0
- package/blocks/agents/recipes/create-agent-and-attach-skill.ts +50 -0
- package/blocks/agents/recipes/run-and-follow-agent-run.ts +46 -0
- package/blocks/agents/recipes/test-agent-before-activating.ts +36 -0
- package/blocks/analytics/README.md +73 -0
- package/blocks/analytics/block.json +178 -0
- package/blocks/analytics/components/funnel-chart.tsx +79 -0
- package/blocks/analytics/components/kpi-tiles.tsx +74 -0
- package/blocks/analytics/components/leaderboard.tsx +86 -0
- package/blocks/analytics/components/report-builder.tsx +200 -0
- package/blocks/analytics/hooks/use-report.ts +26 -0
- package/blocks/analytics/lib/analytics-types.ts +108 -0
- package/blocks/analytics/lib/use-debounced.ts +16 -0
- package/blocks/analytics/recipes/01-weekly-outbound-report.ts +32 -0
- package/blocks/analytics/recipes/02-revenue-snapshot.ts +22 -0
- package/blocks/analytics/recipes/03-saved-report.ts +30 -0
- package/blocks/appointments/README.md +80 -0
- package/blocks/appointments/block.json +218 -0
- package/blocks/appointments/components/availability-editor.tsx +192 -0
- package/blocks/appointments/components/booking-page.tsx +154 -0
- package/blocks/appointments/components/event-type-editor.tsx +214 -0
- package/blocks/appointments/components/meeting-detail.tsx +147 -0
- package/blocks/appointments/components/meeting-list.tsx +148 -0
- package/blocks/appointments/hooks/use-available-slots.ts +29 -0
- package/blocks/appointments/hooks/use-schedules.ts +18 -0
- package/blocks/appointments/lib/appointments-types.ts +159 -0
- package/blocks/appointments/lib/idempotency.ts +30 -0
- package/blocks/appointments/recipes/01-create-event-type-and-hours.ts +27 -0
- package/blocks/appointments/recipes/02-find-slots-and-book.ts +34 -0
- package/blocks/appointments/recipes/03-bookings-digest.ts +22 -0
- package/blocks/appointments/recipes/04-calendar-invite.ts +21 -0
- package/blocks/block.schema.json +130 -0
- package/blocks/block.types.ts +109 -0
- package/blocks/campaigns/README.md +118 -0
- package/blocks/campaigns/block.json +361 -0
- package/blocks/campaigns/components/campaign-assets.tsx +180 -0
- package/blocks/campaigns/components/campaign-audience-step.tsx +91 -0
- package/blocks/campaigns/components/campaign-launch-step.tsx +147 -0
- package/blocks/campaigns/components/campaign-list.tsx +120 -0
- package/blocks/campaigns/components/campaign-metrics.tsx +93 -0
- package/blocks/campaigns/components/campaign-select.tsx +52 -0
- package/blocks/campaigns/components/campaign-sequence-step.tsx +158 -0
- package/blocks/campaigns/components/campaign-wizard.tsx +238 -0
- package/blocks/campaigns/components/form-field.tsx +29 -0
- package/blocks/campaigns/components/icp-editor.tsx +230 -0
- package/blocks/campaigns/components/persona-editor.tsx +210 -0
- package/blocks/campaigns/hooks/use-company-profile.ts +34 -0
- package/blocks/campaigns/lib/campaigns-utils.ts +155 -0
- package/blocks/campaigns/recipes/attach-audience-and-launch.ts +33 -0
- package/blocks/campaigns/recipes/campaign-report.ts +22 -0
- package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +51 -0
- package/blocks/campaigns/recipes/set-up-icp-and-personas.ts +37 -0
- package/blocks/coach/README.md +93 -0
- package/blocks/coach/block.json +125 -0
- package/blocks/coach/components/call-scorecard.tsx +126 -0
- package/blocks/coach/components/coaching-feed.tsx +140 -0
- package/blocks/coach/lib/coach-types.ts +116 -0
- package/blocks/coach/recipes/read-a-scorecard.ts +27 -0
- package/blocks/coach/recipes/weekly-coaching-digest.ts +53 -0
- package/blocks/copilot/README.md +99 -0
- package/blocks/copilot/block.json +151 -0
- package/blocks/copilot/components/copilot-memory.tsx +87 -0
- package/blocks/copilot/components/copilot-panel.tsx +189 -0
- package/blocks/copilot/components/copilot-team-chat.tsx +111 -0
- package/blocks/copilot/lib/copilot-types.ts +111 -0
- package/blocks/copilot/recipes/ask-the-copilot.ts +35 -0
- package/blocks/copilot/recipes/post-in-a-work-channel.ts +33 -0
- package/blocks/copilot/recipes/remember-and-recall.ts +19 -0
- package/blocks/crm/README.md +122 -0
- package/blocks/crm/block.json +378 -0
- package/blocks/crm/components/activity-timeline.tsx +99 -0
- package/blocks/crm/components/company-detail.tsx +118 -0
- package/blocks/crm/components/company-table.tsx +173 -0
- package/blocks/crm/components/contact-detail.tsx +135 -0
- package/blocks/crm/components/contact-table.tsx +217 -0
- package/blocks/crm/components/dedupe-review.tsx +164 -0
- package/blocks/crm/components/import-wizard.tsx +193 -0
- package/blocks/crm/components/notes-panel.tsx +112 -0
- package/blocks/crm/components/record-form.tsx +191 -0
- package/blocks/crm/components/task-list.tsx +107 -0
- package/blocks/crm/hooks/use-custom-fields.ts +18 -0
- package/blocks/crm/hooks/use-debounced.ts +13 -0
- package/blocks/crm/hooks/use-page-query.ts +52 -0
- package/blocks/crm/lib/crm-types.ts +136 -0
- package/blocks/crm/recipes/custom-fields-and-values.ts +28 -0
- package/blocks/crm/recipes/dedupe-and-merge.ts +30 -0
- package/blocks/crm/recipes/notes-tasks-and-activity.ts +23 -0
- package/blocks/crm/recipes/upsert-contact-with-company.ts +33 -0
- package/blocks/deals/README.md +78 -0
- package/blocks/deals/block.json +198 -0
- package/blocks/deals/components/deal-detail.tsx +134 -0
- package/blocks/deals/components/forecast-view.tsx +112 -0
- package/blocks/deals/components/pipeline-board.tsx +142 -0
- package/blocks/deals/components/stage-editor.tsx +96 -0
- package/blocks/deals/hooks/use-pipeline-deals.ts +67 -0
- package/blocks/deals/hooks/use-pipelines.ts +19 -0
- package/blocks/deals/lib/deals-types.ts +128 -0
- package/blocks/deals/recipes/01-create-deal-and-advance.ts +32 -0
- package/blocks/deals/recipes/02-pipeline-report.ts +35 -0
- package/blocks/deals/recipes/03-stale-deals-sweep.ts +20 -0
- package/blocks/dialer/README.md +114 -0
- package/blocks/dialer/block.json +260 -0
- package/blocks/dialer/components/call-log.tsx +78 -0
- package/blocks/dialer/components/call-queue.tsx +188 -0
- package/blocks/dialer/components/dialer-workspace.tsx +47 -0
- package/blocks/dialer/components/disposition-panel.tsx +75 -0
- package/blocks/dialer/components/live-calls-table.tsx +107 -0
- package/blocks/dialer/components/session-start-form.tsx +111 -0
- package/blocks/dialer/components/softphone.tsx +122 -0
- package/blocks/dialer/components/transcript-viewer.tsx +82 -0
- package/blocks/dialer/hooks/use-dialer-numbers.ts +19 -0
- package/blocks/dialer/hooks/use-dialer-session.ts +68 -0
- package/blocks/dialer/hooks/use-dispositions.ts +29 -0
- package/blocks/dialer/lib/dialer-types.ts +158 -0
- package/blocks/dialer/lib/softphone-transport.ts +67 -0
- package/blocks/dialer/lib/twilio-transport.ts +74 -0
- package/blocks/dialer/recipes/01-create-session-and-dial.ts +39 -0
- package/blocks/dialer/recipes/02-disposition-calls.ts +26 -0
- package/blocks/dialer/recipes/03-review-a-call.ts +23 -0
- package/blocks/enrichment/README.md +105 -0
- package/blocks/enrichment/block.json +204 -0
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +205 -0
- package/blocks/enrichment/components/email-verify-badge.tsx +82 -0
- package/blocks/enrichment/components/enrich-button.tsx +136 -0
- package/blocks/enrichment/components/waterfall-config.tsx +127 -0
- package/blocks/enrichment/hooks/use-providers.ts +18 -0
- package/blocks/enrichment/lib/enrichment-types.ts +104 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +41 -0
- package/blocks/enrichment/recipes/lookup-and-verify.ts +25 -0
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +26 -0
- package/blocks/forms/README.md +112 -0
- package/blocks/forms/block.json +200 -0
- package/blocks/forms/components/form-builder.tsx +150 -0
- package/blocks/forms/components/form-embed.tsx +103 -0
- package/blocks/forms/components/form-mapping-editor.tsx +125 -0
- package/blocks/forms/components/site-audit-panel.tsx +73 -0
- package/blocks/forms/components/submissions-table.tsx +158 -0
- package/blocks/forms/lib/form-types.ts +127 -0
- package/blocks/forms/recipes/install-snippet-and-form.ts +21 -0
- package/blocks/forms/recipes/retry-failed-deliveries.ts +26 -0
- package/blocks/forms/recipes/route-form-to-list.ts +44 -0
- package/blocks/inbox/README.md +72 -0
- package/blocks/inbox/block.json +194 -0
- package/blocks/inbox/components/inbox-list.tsx +156 -0
- package/blocks/inbox/components/inbox-screen.tsx +49 -0
- package/blocks/inbox/components/reply-composer.tsx +183 -0
- package/blocks/inbox/components/tag-and-assign.tsx +189 -0
- package/blocks/inbox/components/thread-badge.tsx +17 -0
- package/blocks/inbox/components/thread-view.tsx +108 -0
- package/blocks/inbox/hooks/use-inbox-thread.ts +24 -0
- package/blocks/inbox/hooks/use-inbox-threads.ts +40 -0
- package/blocks/inbox/lib/idempotency.ts +30 -0
- package/blocks/inbox/lib/inbox-types.ts +100 -0
- package/blocks/inbox/recipes/01-triage-and-reply.ts +43 -0
- package/blocks/inbox/recipes/02-tag-and-assign.ts +31 -0
- package/blocks/inbox/recipes/03-hot-contacts-digest.ts +16 -0
- package/blocks/integrations/README.md +75 -0
- package/blocks/integrations/block.json +172 -0
- package/blocks/integrations/components/integration-list.tsx +165 -0
- package/blocks/integrations/components/sync-status.tsx +223 -0
- package/blocks/integrations/components/webhook-manager.tsx +300 -0
- package/blocks/integrations/lib/integrations-utils.ts +62 -0
- package/blocks/integrations/recipes/crm-sync-health.ts +19 -0
- package/blocks/integrations/recipes/push-contacts-to-crm.ts +20 -0
- package/blocks/integrations/recipes/register-webhook.ts +18 -0
- package/blocks/integrations/recipes/verify-webhook-route.ts +33 -0
- package/blocks/intent/README.md +57 -0
- package/blocks/intent/block.json +138 -0
- package/blocks/intent/components/account-intent-score.tsx +91 -0
- package/blocks/intent/components/intent-feed.tsx +215 -0
- package/blocks/intent/components/intent-keyword-manager.tsx +331 -0
- package/blocks/intent/hooks/use-account-intent.ts +46 -0
- package/blocks/intent/lib/intent-utils.ts +95 -0
- package/blocks/intent/recipes/rank-intent-accounts.ts +36 -0
- package/blocks/intent/recipes/track-competitor-domain.ts +23 -0
- package/blocks/intent/recipes/who-visited-url.ts +18 -0
- package/blocks/lifecycle/README.md +79 -0
- package/blocks/lifecycle/block.json +198 -0
- package/blocks/lifecycle/components/churn-list.tsx +92 -0
- package/blocks/lifecycle/components/customer-table.tsx +119 -0
- package/blocks/lifecycle/components/lead-queue.tsx +127 -0
- package/blocks/lifecycle/components/lifecycle-board.tsx +102 -0
- package/blocks/lifecycle/components/stage-move-dialog.tsx +75 -0
- package/blocks/lifecycle/hooks/use-debounced.ts +13 -0
- package/blocks/lifecycle/hooks/use-lifecycle-stages.ts +27 -0
- package/blocks/lifecycle/lib/lifecycle.ts +99 -0
- package/blocks/lifecycle/recipes/customer-brief.ts +23 -0
- package/blocks/lifecycle/recipes/move-company-stage.ts +17 -0
- package/blocks/lifecycle/recipes/read-lifecycle-board.ts +21 -0
- package/blocks/lifecycle/recipes/unlock-stage-contacts.ts +25 -0
- package/blocks/lists/README.md +96 -0
- package/blocks/lists/block.json +166 -0
- package/blocks/lists/components/add-to-list-button.tsx +111 -0
- package/blocks/lists/components/list-picker.tsx +94 -0
- package/blocks/lists/components/list-table.tsx +160 -0
- package/blocks/lists/components/suppression-manager.tsx +153 -0
- package/blocks/lists/recipes/create-list-and-add-contacts.ts +35 -0
- package/blocks/lists/recipes/refresh-and-check-lists.ts +21 -0
- package/blocks/lists/recipes/suppress-and-reinstate.ts +30 -0
- package/blocks/mailboxes/README.md +71 -0
- package/blocks/mailboxes/block.json +187 -0
- package/blocks/mailboxes/components/connect-mailbox.tsx +284 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +271 -0
- package/blocks/mailboxes/components/mailbox-list.tsx +185 -0
- package/blocks/mailboxes/components/warmup-health.tsx +232 -0
- package/blocks/mailboxes/lib/mailboxes-utils.ts +69 -0
- package/blocks/mailboxes/recipes/buy-domain-and-mailboxes.ts +64 -0
- package/blocks/mailboxes/recipes/connect-imap-mailbox.ts +38 -0
- package/blocks/mailboxes/recipes/is-it-safe-to-send.ts +17 -0
- package/blocks/mailboxes/recipes/warmup-report.ts +31 -0
- package/blocks/newsletter/README.md +122 -0
- package/blocks/newsletter/block.json +224 -0
- package/blocks/newsletter/components/issue-form.tsx +124 -0
- package/blocks/newsletter/components/issue-scheduler.tsx +152 -0
- package/blocks/newsletter/components/newsletter-editor.tsx +131 -0
- package/blocks/newsletter/components/newsletter-stats.tsx +93 -0
- package/blocks/newsletter/components/subscriber-table.tsx +173 -0
- package/blocks/newsletter/lib/newsletter-types.ts +97 -0
- package/blocks/newsletter/recipes/create-newsletter-and-draft-issue.ts +27 -0
- package/blocks/newsletter/recipes/import-audience-and-send.ts +43 -0
- package/blocks/newsletter/recipes/issue-performance-report.ts +26 -0
- package/blocks/nurture/README.md +110 -0
- package/blocks/nurture/block.json +191 -0
- package/blocks/nurture/components/nurture-builder.tsx +114 -0
- package/blocks/nurture/components/nurture-enroll.tsx +107 -0
- package/blocks/nurture/components/nurture-form.tsx +120 -0
- package/blocks/nurture/components/nurture-stats.tsx +81 -0
- package/blocks/nurture/lib/nurture-types.ts +109 -0
- package/blocks/nurture/recipes/create-a-nurture.ts +34 -0
- package/blocks/nurture/recipes/enroll-contacts-and-launch.ts +24 -0
- package/blocks/nurture/recipes/nurture-health-check.ts +28 -0
- package/blocks/objects/README.md +120 -0
- package/blocks/objects/block.json +243 -0
- package/blocks/objects/components/attribute-field-input.tsx +82 -0
- package/blocks/objects/components/attribute-form-dialog.tsx +159 -0
- package/blocks/objects/components/object-not-found.tsx +34 -0
- package/blocks/objects/components/object-record-detail.tsx +143 -0
- package/blocks/objects/components/object-schema-editor.tsx +197 -0
- package/blocks/objects/components/object-table.tsx +130 -0
- package/blocks/objects/components/record-form-dialog.tsx +112 -0
- package/blocks/objects/components/record-roles-panel.tsx +106 -0
- package/blocks/objects/hooks/use-object-schema.ts +36 -0
- package/blocks/objects/lib/object-types.ts +214 -0
- package/blocks/objects/recipes/define-object-and-add-records.ts +24 -0
- package/blocks/objects/recipes/update-audit-and-archive-records.ts +39 -0
- package/blocks/objects/recipes/upsert-records-from-your-system.ts +30 -0
- package/blocks/pages/README.md +104 -0
- package/blocks/pages/block.json +223 -0
- package/blocks/pages/components/brand-kit-editor.tsx +152 -0
- package/blocks/pages/components/content-library.tsx +163 -0
- package/blocks/pages/components/page-editor.tsx +253 -0
- package/blocks/pages/components/page-list.tsx +159 -0
- package/blocks/pages/components/seo-report.tsx +159 -0
- package/blocks/pages/lib/studio.ts +77 -0
- package/blocks/pages/recipes/audit-site-seo.ts +28 -0
- package/blocks/pages/recipes/create-and-publish-page.ts +26 -0
- package/blocks/pages/recipes/edit-page-section.ts +25 -0
- package/blocks/pages/recipes/refresh-brand-kit.ts +22 -0
- package/blocks/quotes/README.md +79 -0
- package/blocks/quotes/block.json +183 -0
- package/blocks/quotes/components/product-picker.tsx +80 -0
- package/blocks/quotes/components/quote-builder.tsx +195 -0
- package/blocks/quotes/components/quote-list.tsx +79 -0
- package/blocks/quotes/components/quote-viewer.tsx +135 -0
- package/blocks/quotes/components/signature-status.tsx +77 -0
- package/blocks/quotes/hooks/use-debounced.ts +13 -0
- package/blocks/quotes/lib/quotes.ts +142 -0
- package/blocks/quotes/recipes/create-and-send-quote.ts +44 -0
- package/blocks/quotes/recipes/revise-sent-quote.ts +18 -0
- package/blocks/quotes/recipes/track-quote-signature.ts +22 -0
- package/blocks/radar/README.md +106 -0
- package/blocks/radar/block.json +189 -0
- package/blocks/radar/components/competitor-battle-card.tsx +53 -0
- package/blocks/radar/components/competitor-tracker.tsx +158 -0
- package/blocks/radar/components/radar-overview.tsx +67 -0
- package/blocks/radar/components/radar-report.tsx +86 -0
- package/blocks/radar/hooks/use-competitors.ts +23 -0
- package/blocks/radar/lib/radar-types.ts +124 -0
- package/blocks/radar/recipes/battle-card.ts +28 -0
- package/blocks/radar/recipes/deep-dive-report.ts +32 -0
- package/blocks/radar/recipes/marketing-snapshot.ts +18 -0
- package/blocks/radar/recipes/track-and-scan.ts +39 -0
- package/blocks/search/README.md +129 -0
- package/blocks/search/block.json +185 -0
- package/blocks/search/components/filter-builder.tsx +149 -0
- package/blocks/search/components/prospect-results.tsx +175 -0
- package/blocks/search/components/save-to-list-dialog.tsx +135 -0
- package/blocks/search/components/saved-searches.tsx +95 -0
- package/blocks/search/hooks/use-filter-options.ts +41 -0
- package/blocks/search/lib/search-types.ts +149 -0
- package/blocks/search/recipes/explore-filter-values.ts +27 -0
- package/blocks/search/recipes/saved-searches.ts +34 -0
- package/blocks/search/recipes/search-and-save-to-list.ts +37 -0
- package/blocks/sequencer/README.md +87 -0
- package/blocks/sequencer/block.json +214 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +93 -0
- package/blocks/sequencer/components/sequence-builder.tsx +306 -0
- package/blocks/sequencer/components/sequence-list.tsx +200 -0
- package/blocks/sequencer/components/sequence-stats.tsx +256 -0
- package/blocks/sequencer/components/step-editor.tsx +213 -0
- package/blocks/sequencer/lib/sequencer-utils.ts +168 -0
- package/blocks/sequencer/recipes/create-multichannel-sequence.ts +51 -0
- package/blocks/sequencer/recipes/launch-and-enroll.ts +44 -0
- package/blocks/sequencer/recipes/pause-when-unsafe.ts +30 -0
- package/blocks/sequencer/recipes/sequence-report.ts +22 -0
- package/blocks/social-ads/README.md +128 -0
- package/blocks/social-ads/block.json +256 -0
- package/blocks/social-ads/components/ad-campaign-list.tsx +136 -0
- package/blocks/social-ads/components/audience-sync.tsx +150 -0
- package/blocks/social-ads/components/creative-gallery.tsx +121 -0
- package/blocks/social-ads/components/post-composer.tsx +122 -0
- package/blocks/social-ads/components/social-post-list.tsx +116 -0
- package/blocks/social-ads/hooks/use-social-accounts.ts +16 -0
- package/blocks/social-ads/lib/social-ads-types.ts +167 -0
- package/blocks/social-ads/recipes/creative-to-gallery.ts +41 -0
- package/blocks/social-ads/recipes/schedule-post.ts +51 -0
- package/blocks/social-ads/recipes/sync-audience-and-launch.ts +41 -0
- package/blocks/visitors/README.md +56 -0
- package/blocks/visitors/block.json +139 -0
- package/blocks/visitors/components/snippet-install.tsx +169 -0
- package/blocks/visitors/components/traffic-chart.tsx +156 -0
- package/blocks/visitors/components/visitor-company-card.tsx +92 -0
- package/blocks/visitors/components/visitor-feed.tsx +157 -0
- package/blocks/visitors/lib/visitors-utils.ts +78 -0
- package/blocks/visitors/recipes/check-snippet-installed.ts +24 -0
- package/blocks/visitors/recipes/recent-company-visits.ts +27 -0
- package/blocks/visitors/recipes/traffic-summary.ts +24 -0
- package/blocks/webchat/README.md +87 -0
- package/blocks/webchat/block.json +134 -0
- package/blocks/webchat/components/chat-widget-config.tsx +160 -0
- package/blocks/webchat/components/conversation-list.tsx +74 -0
- package/blocks/webchat/lib/webchat-types.ts +83 -0
- package/blocks/webchat/recipes/create-bot-and-style-widget.ts +24 -0
- package/blocks/webchat/recipes/list-chat-visitors.ts +24 -0
- package/blocks/workbench/README.md +135 -0
- package/blocks/workbench/block.json +259 -0
- package/blocks/workbench/components/column-recipe-editor.tsx +183 -0
- package/blocks/workbench/components/run-progress.tsx +132 -0
- package/blocks/workbench/components/workbench-add-rows.tsx +100 -0
- package/blocks/workbench/components/workbench-grid.tsx +159 -0
- package/blocks/workbench/components/workbench-row-actions.tsx +102 -0
- package/blocks/workbench/components/workbench-table-list.tsx +105 -0
- package/blocks/workbench/hooks/use-workbench-table.ts +30 -0
- package/blocks/workbench/lib/waterfall-presets.ts +119 -0
- package/blocks/workbench/lib/workbench-types.ts +191 -0
- package/blocks/workbench/recipes/build-enriched-table.ts +65 -0
- package/blocks/workbench/recipes/import-from-search.ts +42 -0
- package/blocks/workbench/recipes/scrape-into-table.ts +36 -0
- package/blocks/workbench/recipes/waterfall-column.ts +55 -0
- package/blocks/workflows/README.md +100 -0
- package/blocks/workflows/block.json +188 -0
- package/blocks/workflows/components/run-history.tsx +136 -0
- package/blocks/workflows/components/trigger-config.tsx +134 -0
- package/blocks/workflows/components/workflow-editor.tsx +142 -0
- package/blocks/workflows/components/workflow-list.tsx +153 -0
- package/blocks/workflows/hooks/use-workflow.ts +51 -0
- package/blocks/workflows/lib/graph.ts +187 -0
- package/blocks/workflows/recipes/create-workflow.ts +35 -0
- package/blocks/workflows/recipes/find-failed-runs.ts +15 -0
- package/blocks/workflows/recipes/run-workflow-and-wait.ts +32 -0
- package/blocks/workflows/recipes/set-trigger.ts +22 -0
- package/dist/cli.js +5330 -29
- package/dist/index.browser.js +7740 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.browser.mjs +7717 -0
- package/dist/index.browser.mjs.map +1 -0
- package/dist/index.d.mts +1440 -21
- package/dist/index.d.ts +1440 -21
- package/dist/index.js +42 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +42 -3
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +7598 -0
- package/dist/react.browser.js.map +1 -0
- package/dist/react.browser.mjs +7568 -0
- package/dist/react.browser.mjs.map +1 -0
- package/dist/react.d.mts +1340 -18
- package/dist/react.d.ts +1340 -18
- package/dist/react.js +41 -2
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +41 -2
- package/dist/react.mjs.map +1 -1
- package/dist/types/agentSetup.d.ts +6 -6
- package/dist/types/blocks/add.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +46 -0
- package/dist/types/blocks/doctor.d.ts +26 -0
- package/dist/types/blocks/registry.d.ts +134 -0
- package/dist/types/cli.d.ts +16 -0
- package/dist/types/core.d.ts +1 -1
- package/dist/types/crypto-browser.d.ts +2 -0
- package/dist/types/crypto-node.d.ts +6 -0
- package/dist/types/react.d.ts +1 -1
- package/dist/types/version.d.ts +2 -2
- package/dist/types/webhooks.d.ts +2 -2
- package/dist/ui/index.d.mts +54 -0
- package/dist/ui/index.d.ts +54 -0
- package/dist/ui/index.js +105 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/index.mjs +44 -0
- package/dist/ui/index.mjs.map +1 -0
- package/dist/ui/lib/compose-refs.d.mts +19 -0
- package/dist/ui/lib/compose-refs.d.ts +19 -0
- package/dist/ui/lib/compose-refs.js +76 -0
- package/dist/ui/lib/compose-refs.js.map +1 -0
- package/dist/ui/lib/compose-refs.mjs +41 -0
- package/dist/ui/lib/compose-refs.mjs.map +1 -0
- package/dist/ui/lib/use-mobile.d.mts +4 -0
- package/dist/ui/lib/use-mobile.d.ts +4 -0
- package/dist/ui/lib/use-mobile.js +57 -0
- package/dist/ui/lib/use-mobile.js.map +1 -0
- package/dist/ui/lib/use-mobile.mjs +22 -0
- package/dist/ui/lib/use-mobile.mjs.map +1 -0
- package/dist/ui/lib/utils.d.mts +10 -0
- package/dist/ui/lib/utils.d.ts +10 -0
- package/dist/ui/lib/utils.js +33 -0
- package/dist/ui/lib/utils.js.map +1 -0
- package/dist/ui/lib/utils.mjs +9 -0
- package/dist/ui/lib/utils.mjs.map +1 -0
- package/dist/ui/primitives/alert-dialog.d.mts +22 -0
- package/dist/ui/primitives/alert-dialog.d.ts +22 -0
- package/dist/ui/primitives/alert-dialog.js +163 -0
- package/dist/ui/primitives/alert-dialog.js.map +1 -0
- package/dist/ui/primitives/alert-dialog.mjs +119 -0
- package/dist/ui/primitives/alert-dialog.mjs.map +1 -0
- package/dist/ui/primitives/avatar.d.mts +8 -0
- package/dist/ui/primitives/avatar.d.ts +8 -0
- package/dist/ui/primitives/avatar.js +91 -0
- package/dist/ui/primitives/avatar.js.map +1 -0
- package/dist/ui/primitives/avatar.mjs +55 -0
- package/dist/ui/primitives/avatar.mjs.map +1 -0
- package/dist/ui/primitives/breadcrumb.d.mts +13 -0
- package/dist/ui/primitives/breadcrumb.d.ts +13 -0
- package/dist/ui/primitives/breadcrumb.js +127 -0
- package/dist/ui/primitives/breadcrumb.js.map +1 -0
- package/dist/ui/primitives/breadcrumb.mjs +97 -0
- package/dist/ui/primitives/breadcrumb.mjs.map +1 -0
- package/dist/ui/primitives/button.d.mts +15 -0
- package/dist/ui/primitives/button.d.ts +15 -0
- package/dist/ui/primitives/button.js +91 -0
- package/dist/ui/primitives/button.js.map +1 -0
- package/dist/ui/primitives/button.mjs +56 -0
- package/dist/ui/primitives/button.mjs.map +1 -0
- package/dist/ui/primitives/card.d.mts +10 -0
- package/dist/ui/primitives/card.d.ts +10 -0
- package/dist/ui/primitives/card.js +101 -0
- package/dist/ui/primitives/card.js.map +1 -0
- package/dist/ui/primitives/card.mjs +62 -0
- package/dist/ui/primitives/card.mjs.map +1 -0
- package/dist/ui/primitives/carousel.d.mts +45 -0
- package/dist/ui/primitives/carousel.d.ts +45 -0
- package/dist/ui/primitives/carousel.js +300 -0
- package/dist/ui/primitives/carousel.js.map +1 -0
- package/dist/ui/primitives/carousel.mjs +261 -0
- package/dist/ui/primitives/carousel.mjs.map +1 -0
- package/dist/ui/primitives/chart.d.mts +68 -0
- package/dist/ui/primitives/chart.d.ts +68 -0
- package/dist/ui/primitives/chart.js +280 -0
- package/dist/ui/primitives/chart.js.map +1 -0
- package/dist/ui/primitives/chart.mjs +241 -0
- package/dist/ui/primitives/chart.mjs.map +1 -0
- package/dist/ui/primitives/collapsible.d.mts +8 -0
- package/dist/ui/primitives/collapsible.d.ts +8 -0
- package/dist/ui/primitives/collapsible.js +72 -0
- package/dist/ui/primitives/collapsible.js.map +1 -0
- package/dist/ui/primitives/collapsible.mjs +36 -0
- package/dist/ui/primitives/collapsible.mjs.map +1 -0
- package/dist/ui/primitives/context-menu.d.mts +29 -0
- package/dist/ui/primitives/context-menu.d.ts +29 -0
- package/dist/ui/primitives/context-menu.js +203 -0
- package/dist/ui/primitives/context-menu.js.map +1 -0
- package/dist/ui/primitives/context-menu.mjs +155 -0
- package/dist/ui/primitives/context-menu.mjs.map +1 -0
- package/dist/ui/primitives/dialog.d.mts +26 -0
- package/dist/ui/primitives/dialog.d.ts +26 -0
- package/dist/ui/primitives/dialog.js +192 -0
- package/dist/ui/primitives/dialog.js.map +1 -0
- package/dist/ui/primitives/dialog.mjs +148 -0
- package/dist/ui/primitives/dialog.mjs.map +1 -0
- package/dist/ui/primitives/drawer.d.mts +26 -0
- package/dist/ui/primitives/drawer.d.ts +26 -0
- package/dist/ui/primitives/drawer.js +146 -0
- package/dist/ui/primitives/drawer.js.map +1 -0
- package/dist/ui/primitives/drawer.mjs +103 -0
- package/dist/ui/primitives/drawer.mjs.map +1 -0
- package/dist/ui/primitives/input.d.mts +5 -0
- package/dist/ui/primitives/input.d.ts +5 -0
- package/dist/ui/primitives/input.js +46 -0
- package/dist/ui/primitives/input.js.map +1 -0
- package/dist/ui/primitives/input.mjs +22 -0
- package/dist/ui/primitives/input.mjs.map +1 -0
- package/dist/ui/primitives/item.d.mts +27 -0
- package/dist/ui/primitives/item.d.ts +27 -0
- package/dist/ui/primitives/item.js +221 -0
- package/dist/ui/primitives/item.js.map +1 -0
- package/dist/ui/primitives/item.mjs +188 -0
- package/dist/ui/primitives/item.mjs.map +1 -0
- package/dist/ui/primitives/kbd.d.mts +15 -0
- package/dist/ui/primitives/kbd.d.ts +15 -0
- package/dist/ui/primitives/kbd.js +45 -0
- package/dist/ui/primitives/kbd.js.map +1 -0
- package/dist/ui/primitives/kbd.mjs +21 -0
- package/dist/ui/primitives/kbd.mjs.map +1 -0
- package/dist/ui/primitives/label.d.mts +6 -0
- package/dist/ui/primitives/label.d.ts +6 -0
- package/dist/ui/primitives/label.js +55 -0
- package/dist/ui/primitives/label.js.map +1 -0
- package/dist/ui/primitives/label.mjs +21 -0
- package/dist/ui/primitives/label.mjs.map +1 -0
- package/dist/ui/primitives/resizable.d.mts +10 -0
- package/dist/ui/primitives/resizable.d.ts +10 -0
- package/dist/ui/primitives/resizable.js +68 -0
- package/dist/ui/primitives/resizable.js.map +1 -0
- package/dist/ui/primitives/resizable.mjs +42 -0
- package/dist/ui/primitives/resizable.mjs.map +1 -0
- package/dist/ui/primitives/save-status.d.mts +31 -0
- package/dist/ui/primitives/save-status.d.ts +31 -0
- package/dist/ui/primitives/save-status.js +150 -0
- package/dist/ui/primitives/save-status.js.map +1 -0
- package/dist/ui/primitives/save-status.mjs +126 -0
- package/dist/ui/primitives/save-status.mjs.map +1 -0
- package/dist/ui/primitives/separator.d.mts +6 -0
- package/dist/ui/primitives/separator.d.ts +6 -0
- package/dist/ui/primitives/separator.js +62 -0
- package/dist/ui/primitives/separator.js.map +1 -0
- package/dist/ui/primitives/separator.mjs +28 -0
- package/dist/ui/primitives/separator.mjs.map +1 -0
- package/dist/ui/primitives/sheet.d.mts +16 -0
- package/dist/ui/primitives/sheet.d.ts +16 -0
- package/dist/ui/primitives/sheet.js +170 -0
- package/dist/ui/primitives/sheet.js.map +1 -0
- package/dist/ui/primitives/sheet.mjs +129 -0
- package/dist/ui/primitives/sheet.mjs.map +1 -0
- package/dist/ui/primitives/sidebar.d.mts +74 -0
- package/dist/ui/primitives/sidebar.d.ts +74 -0
- package/dist/ui/primitives/sidebar.js +697 -0
- package/dist/ui/primitives/sidebar.js.map +1 -0
- package/dist/ui/primitives/sidebar.mjs +651 -0
- package/dist/ui/primitives/sidebar.mjs.map +1 -0
- package/dist/ui/primitives/skeleton.d.mts +5 -0
- package/dist/ui/primitives/skeleton.d.ts +5 -0
- package/dist/ui/primitives/skeleton.js +40 -0
- package/dist/ui/primitives/skeleton.js.map +1 -0
- package/dist/ui/primitives/skeleton.mjs +16 -0
- package/dist/ui/primitives/skeleton.mjs.map +1 -0
- package/dist/ui/primitives/slider.d.mts +20 -0
- package/dist/ui/primitives/slider.d.ts +20 -0
- package/dist/ui/primitives/slider.js +112 -0
- package/dist/ui/primitives/slider.js.map +1 -0
- package/dist/ui/primitives/slider.mjs +78 -0
- package/dist/ui/primitives/slider.mjs.map +1 -0
- package/dist/ui/primitives/sortable.d.mts +50 -0
- package/dist/ui/primitives/sortable.d.ts +50 -0
- package/dist/ui/primitives/sortable.js +456 -0
- package/dist/ui/primitives/sortable.js.map +1 -0
- package/dist/ui/primitives/sortable.mjs +435 -0
- package/dist/ui/primitives/sortable.mjs.map +1 -0
- package/dist/ui/primitives/switch.d.mts +6 -0
- package/dist/ui/primitives/switch.d.ts +6 -0
- package/dist/ui/primitives/switch.js +67 -0
- package/dist/ui/primitives/switch.js.map +1 -0
- package/dist/ui/primitives/switch.mjs +33 -0
- package/dist/ui/primitives/switch.mjs.map +1 -0
- package/dist/ui/primitives/table.d.mts +12 -0
- package/dist/ui/primitives/table.d.ts +12 -0
- package/dist/ui/primitives/table.js +130 -0
- package/dist/ui/primitives/table.js.map +1 -0
- package/dist/ui/primitives/table.mjs +89 -0
- package/dist/ui/primitives/table.mjs.map +1 -0
- package/dist/ui/primitives/tabs.d.mts +9 -0
- package/dist/ui/primitives/tabs.d.ts +9 -0
- package/dist/ui/primitives/tabs.js +86 -0
- package/dist/ui/primitives/tabs.js.map +1 -0
- package/dist/ui/primitives/tabs.mjs +49 -0
- package/dist/ui/primitives/tabs.mjs.map +1 -0
- package/dist/ui/primitives/textarea.d.mts +5 -0
- package/dist/ui/primitives/textarea.d.ts +5 -0
- package/dist/ui/primitives/textarea.js +43 -0
- package/dist/ui/primitives/textarea.js.map +1 -0
- package/dist/ui/primitives/textarea.mjs +19 -0
- package/dist/ui/primitives/textarea.mjs.map +1 -0
- package/dist/ui/primitives/tooltip.d.mts +9 -0
- package/dist/ui/primitives/tooltip.d.ts +9 -0
- package/dist/ui/primitives/tooltip.js +94 -0
- package/dist/ui/primitives/tooltip.js.map +1 -0
- package/dist/ui/primitives/tooltip.mjs +57 -0
- package/dist/ui/primitives/tooltip.mjs.map +1 -0
- package/docs/agents.md +72 -29
- package/docs/api/README.md +6 -3
- package/docs/api/appObjects.d.ts +1 -1
- package/docs/api/appObjects.md +1 -1
- package/docs/api/by-path.md +29 -0
- package/docs/api/objects.d.ts +72 -5
- package/docs/api/objects.md +84 -7
- package/docs/api/processLists.d.ts +247 -0
- package/docs/api/processLists.md +409 -0
- package/docs/api/reports.d.ts +476 -0
- package/docs/api/reports.md +539 -0
- package/docs/api/scheduleBuy.d.ts +12 -8
- package/docs/api/scheduleBuy.md +13 -10
- package/docs/api/seo.d.ts +406 -3
- package/docs/api/seo.md +505 -1
- package/docs/api/workspaceBackup.d.ts +324 -0
- package/docs/api/workspaceBackup.md +427 -0
- package/package.json +174 -5
- package/stacks/6sense.json +46 -0
- package/stacks/README.md +20 -0
- package/stacks/apollo.json +128 -0
- package/stacks/clay.json +59 -0
- package/stacks/gong.json +72 -0
- package/stacks/hubspot.json +162 -0
- package/stacks/intercom.json +53 -0
- package/stacks/mailchimp.json +71 -0
- package/stacks/outreach.json +86 -0
- package/stacks/salesforce.json +94 -0
- package/stacks/stack.schema.json +32 -0
- package/stacks/starter.json +22 -0
- package/styles/styles.css +2 -0
- package/styles/theme.css +181 -0
- package/styles/tokens.css +284 -0
- package/templates/next-app/README.md.tmpl +35 -0
- package/templates/next-app/_pages/redirect-page.tsx.tmpl +5 -0
- package/templates/next-app/_pages/route-page.tsx.tmpl +9 -0
- package/templates/next-app/app/globals.css.tmpl +8 -0
- package/templates/next-app/app/layout.tsx.tmpl +19 -0
- package/templates/next-app/components/app-shell.tsx.tmpl +34 -0
- package/templates/next-app/dot-env.local.example.tmpl +6 -0
- package/templates/next-app/dot-gitignore.tmpl +7 -0
- package/templates/next-app/lib/nav.ts.tmpl +2 -0
- package/templates/next-app/next.config.mjs.tmpl +4 -0
- package/templates/next-app/package.json.tmpl +25 -0
- package/templates/next-app/postcss.config.mjs.tmpl +4 -0
- package/templates/next-app/tsconfig.json.tmpl +21 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# newsletter
|
|
2
|
+
|
|
3
|
+
Write, test, schedule and send newsletter issues, manage subscribers, and read open and click numbers.
|
|
4
|
+
**Sending reaches real inboxes and cannot be recalled**, so every call that can mail a person is tier `external`
|
|
5
|
+
and goes through `ConfirmSpendDialog` with the recipient count.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npx @graph8/sdk add newsletter
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Installs `foundation`, `crm` and `lists` first if missing. Everything lands under `g8/newsletter/`.
|
|
14
|
+
|
|
15
|
+
## Prerequisites (the human must do these)
|
|
16
|
+
|
|
17
|
+
| Where | What | How to verify |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| graph8 workspace | A verified **sending domain** and default sender for the newsletter (settings: `default_sender_email`, `sending_domain`; DNS records are verified in the graph8 app). | `GET /api/v1/newsletters/{id}/settings` has a `sending_domain`; `.../settings/domain-status?domain=` is verified. |
|
|
20
|
+
| The business | **Consent**: only people who agreed to be mailed may be added or imported. The block never imports anyone by itself. | The owner confirmed the source of every imported list. |
|
|
21
|
+
| graph8 key | `campaigns:launch` permission behind the key for activate, resume and send. | Activating a test newsletter is not a 403. |
|
|
22
|
+
| Your app | `allowedPrefixes` in `app/api/g8/[...path]/route.ts` must include `newsletters` and `lists`. | The route file lists both. |
|
|
23
|
+
|
|
24
|
+
## The flow, in order (IDs passed between steps)
|
|
25
|
+
|
|
26
|
+
1. `createNewsletter({ name, goal })` -> **`newsletter.id`** (a UUID string). `NewsletterEditor` does this behind a confirm.
|
|
27
|
+
2. `createIssue(newsletter.id, { subject, html_body, ... })` -> **`issue.id`** (a NUMBER, not a string). The issue is a **draft**.
|
|
28
|
+
Optional: `generateNewsletterIssue(issue.id)` (billable, runs a model) then poll `getIssueGenerationStatus` until it is no longer running.
|
|
29
|
+
**Never send an issue whose generation has not finished**: it mails the partial HTML (`IssueScheduler` blocks it).
|
|
30
|
+
3. `sendTestEmail(issue.id, { test_email })`: one email to one address you own.
|
|
31
|
+
4. Subscribers: `addSubscriber` (one person) or `importSubscribersFromAudience({ audience_id: list.id })` (a list; asynchronous, billable when
|
|
32
|
+
`auto_verify_on_import` is on). Optional: `previewNewsletterSubscriberVerification` (free, shows the cost) then `verifyNewsletterSubscribers` (billable).
|
|
33
|
+
5. `activateNewsletter(newsletter.id)`: launch-class. Once active the scheduler mails any issue whose scheduled time has passed.
|
|
34
|
+
6. `IssueScheduler`: reads `listSubscribers?status=subscribed&page_size=1` for the **recipient count**, then `sendIssue(issue.id, { scheduled_at? })`.
|
|
35
|
+
`202` means queued, not delivered. Follow with `getSendStatus` (polled until `active` is false). `pauseSend` is immediate;
|
|
36
|
+
`resumeSend` is external (confirmed); `cancelSend` is permanent.
|
|
37
|
+
7. `NewsletterStats`: `listIssues` (sent rows carry inline KPIs) and `getIssueAnalyticsKpis` for one issue.
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
// app/newsletter/page.tsx
|
|
41
|
+
"use client";
|
|
42
|
+
import { useState } from "react";
|
|
43
|
+
import { NewsletterEditor } from "@/g8/newsletter/components/newsletter-editor";
|
|
44
|
+
import { IssueScheduler } from "@/g8/newsletter/components/issue-scheduler";
|
|
45
|
+
|
|
46
|
+
export default function Page() {
|
|
47
|
+
const [sel, setSel] = useState<{ newsletterId: string; issueId: number } | null>(null);
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
<NewsletterEditor onSelectIssue={(newsletterId, issue) => setSel({ newsletterId, issueId: issue.id })} />
|
|
51
|
+
{sel ? <IssueScheduler newsletterId={sel.newsletterId} issueId={sel.issueId} /> : null}
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Default decisions (do not ask the human unless marked)
|
|
58
|
+
|
|
59
|
+
| Decision | Default | Ask? |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Send now vs scheduled | Send now, with Schedule as a second option | no |
|
|
62
|
+
| Who receives an issue | Every `subscribed` person; the count is in the confirm dialog | no |
|
|
63
|
+
| Confirming a send | Always, never auto-send | no |
|
|
64
|
+
| Auto-verify imported addresses | Off; run the preview first and confirm the cost | **yes** |
|
|
65
|
+
| Removing a subscriber | Unsubscribe (keeps the opt-out). Erase only for an erasure request | no |
|
|
66
|
+
|
|
67
|
+
## Things that surprise people
|
|
68
|
+
|
|
69
|
+
- **Saving a draft is tier `external` in the contract.** `createNewsletter`, `createIssue` and `updateIssue` are classified conservatively, so the block confirms them too (the dialog says nothing is sent).
|
|
70
|
+
- **Only drafts can be edited or deleted.** A sent issue is a record of mail real people received.
|
|
71
|
+
- **The hand-written `g8.newsletter` client has stale types.** `issueKpis` is typed with `opens/clicks` and `listSubscribers` as a bare array, but the API returns `opened/clicked/delivered/...` and `{ items, total, page, page_size }`. The block and recipes use `g8.api.newsletter.*` for those.
|
|
72
|
+
- **KPIs can be `stale: true`**: the read failed, which is not "zero engagement". `NewsletterStats` says so.
|
|
73
|
+
- **Unsubscribe vs delete**: deleting a subscriber loses the opt-out record, so the same address can be re-imported and mailed again.
|
|
74
|
+
|
|
75
|
+
## Recipes (server-side, `recipes/`)
|
|
76
|
+
|
|
77
|
+
- `create-newsletter-and-draft-issue.ts`: set up and test, nothing reaches a subscriber.
|
|
78
|
+
- `import-audience-and-send.ts`: import, count, guard against partial AI content, send with an idempotency key, follow to the end.
|
|
79
|
+
- `issue-performance-report.ts`: per-issue rates, skipping stale reads.
|
|
80
|
+
|
|
81
|
+
## What is in the block
|
|
82
|
+
|
|
83
|
+
| Component | File | What it is |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `NewsletterEditor` | `components/newsletter-editor.tsx` | Pick or create a newsletter, list its issues, write issues and activate or pause the newsletter. |
|
|
86
|
+
| `IssueForm` | `components/issue-form.tsx` | Draft one issue: subject, preview text, HTML, AI generation, test send and delete of drafts. |
|
|
87
|
+
| `IssueScheduler` | `components/issue-scheduler.tsx` | Send an issue now or schedule it, confirmed with the subscriber count, then follow, pause, resume or cancel the send. |
|
|
88
|
+
| `SubscriberTable` | `components/subscriber-table.tsx` | Subscribers with search, status filter, add, import a list, verify addresses, unsubscribe and erase. |
|
|
89
|
+
| `NewsletterStats` | `components/newsletter-stats.tsx` | Per-issue delivered, opened, clicked, bounced and unsubscribed numbers, with a stale-data warning. |
|
|
90
|
+
|
|
91
|
+
## Operations
|
|
92
|
+
|
|
93
|
+
Tiers: `read` and `write` need no confirmation; `billable`, `external` and `destructive` always open `ConfirmSpendDialog` first.
|
|
94
|
+
|
|
95
|
+
| Operation | Method and path | Tier | Scope | Used by |
|
|
96
|
+
|---|---|---|---|---|
|
|
97
|
+
| `newsletter.listNewsletters` | `GET /api/v1/newsletters` | read | `newsletter:read` | NewsletterEditor |
|
|
98
|
+
| `newsletter.createNewsletter` | `POST /api/v1/newsletters` | external | `newsletter:run` | NewsletterEditor |
|
|
99
|
+
| `newsletter.listIssues` | `GET /api/v1/newsletters/{newsletter_id}/issues` | read | `newsletter:read` | NewsletterEditor, NewsletterStats |
|
|
100
|
+
| `newsletter.activateNewsletter` | `POST /api/v1/newsletters/{newsletter_id}/activate` | external | `newsletter:run` | NewsletterEditor |
|
|
101
|
+
| `newsletter.pauseNewsletter` | `POST /api/v1/newsletters/{newsletter_id}/pause` | write | `newsletter:write` | NewsletterEditor |
|
|
102
|
+
| `newsletter.getIssue` | `GET /api/v1/newsletters/{newsletter_id}/issues/{issue_id}` | read | `newsletter:read` | IssueForm, IssueScheduler |
|
|
103
|
+
| `newsletter.createIssue` | `POST /api/v1/newsletters/{newsletter_id}/issues` | external | `newsletter:run` | IssueForm |
|
|
104
|
+
| `newsletter.updateIssue` | `PUT /api/v1/newsletters/{newsletter_id}/issues/{issue_id}` | external | `newsletter:run` | IssueForm |
|
|
105
|
+
| `newsletter.generateNewsletterIssue` | `POST /api/v1/newsletters/{newsletter_id}/issues/{issue_id}/generate` | billable | `newsletter:run` | IssueForm |
|
|
106
|
+
| `newsletter.getIssueGenerationStatus` | `GET /api/v1/newsletters/{newsletter_id}/issues/{issue_id}/generation-status` | read | `newsletter:read` | IssueForm, IssueScheduler |
|
|
107
|
+
| `newsletter.sendTestEmail` | `POST /api/v1/newsletters/{newsletter_id}/issues/{issue_id}/send-test` | external | `newsletter:run` | IssueForm |
|
|
108
|
+
| `newsletter.deleteIssue` | `DELETE /api/v1/newsletters/{newsletter_id}/issues/{issue_id}` | destructive | `newsletter:delete` | IssueForm |
|
|
109
|
+
| `newsletter.listSubscribers` | `GET /api/v1/newsletters/{newsletter_id}/subscribers` | read | `newsletter:read` | IssueScheduler, SubscriberTable |
|
|
110
|
+
| `newsletter.sendIssue` | `POST /api/v1/newsletters/{newsletter_id}/issues/{issue_id}/send` | external | `newsletter:run` | IssueScheduler |
|
|
111
|
+
| `newsletter.getSendStatus` | `GET /api/v1/newsletters/{newsletter_id}/issues/{issue_id}/send` | read | `newsletter:read` | IssueScheduler |
|
|
112
|
+
| `newsletter.pauseSend` | `POST /api/v1/newsletters/{newsletter_id}/issues/{issue_id}/send/pause` | write | `newsletter:write` | IssueScheduler |
|
|
113
|
+
| `newsletter.resumeSend` | `POST /api/v1/newsletters/{newsletter_id}/issues/{issue_id}/send/resume` | external | `newsletter:run` | IssueScheduler |
|
|
114
|
+
| `newsletter.cancelSend` | `POST /api/v1/newsletters/{newsletter_id}/issues/{issue_id}/send/cancel` | write | `newsletter:write` | IssueScheduler |
|
|
115
|
+
| `newsletter.addSubscriber` | `POST /api/v1/newsletters/{newsletter_id}/subscribers` | external | `newsletter:run` | SubscriberTable |
|
|
116
|
+
| `lists.listLists` | `GET /api/v1/lists` | read | `lists:read` | SubscriberTable |
|
|
117
|
+
| `newsletter.importSubscribersFromAudience` | `POST /api/v1/newsletters/{newsletter_id}/subscribers/import-audience` | billable | `newsletter:run` | SubscriberTable |
|
|
118
|
+
| `newsletter.previewNewsletterSubscriberVerification` | `POST /api/v1/newsletters/{newsletter_id}/subscribers/verify/preview` | read | `newsletter:read` | SubscriberTable |
|
|
119
|
+
| `newsletter.verifyNewsletterSubscribers` | `POST /api/v1/newsletters/{newsletter_id}/subscribers/verify` | billable | `newsletter:run` | SubscriberTable |
|
|
120
|
+
| `newsletter.unsubscribeSubscriber` | `PATCH /api/v1/newsletters/{newsletter_id}/subscribers/{subscriber_id}/unsubscribe` | external | `newsletter:run` | SubscriberTable |
|
|
121
|
+
| `newsletter.deleteSubscriber` | `DELETE /api/v1/newsletters/{newsletter_id}/subscribers/{subscriber_id}` | destructive | `newsletter:delete` | SubscriberTable |
|
|
122
|
+
| `newsletter.getIssueAnalyticsKpis` | `GET /api/v1/newsletters/{newsletter_id}/issues/{issue_id}/analytics/kpis` | read | `newsletter:read` | NewsletterStats |
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../block.schema.json",
|
|
3
|
+
"id": "newsletter",
|
|
4
|
+
"title": "Newsletter",
|
|
5
|
+
"summary": "Write, test, schedule and send newsletter issues to subscribers, manage the subscriber list, and read open and click statistics.",
|
|
6
|
+
"features": [
|
|
7
|
+
"newsletters and issue drafts with AI generation",
|
|
8
|
+
"test send to one address",
|
|
9
|
+
"send now or schedule with the subscriber count confirmed",
|
|
10
|
+
"send progress with pause, resume and cancel",
|
|
11
|
+
"subscriber table with add, import a list, verify, unsubscribe and erase",
|
|
12
|
+
"per-issue delivery, open and click statistics"
|
|
13
|
+
],
|
|
14
|
+
"dependsOn": [
|
|
15
|
+
"lists"
|
|
16
|
+
],
|
|
17
|
+
"operations": [
|
|
18
|
+
"list_newsletters_newsletters_get",
|
|
19
|
+
"create_newsletter_newsletters_post",
|
|
20
|
+
"list_issues_newsletters__newsletter_id__issues_get",
|
|
21
|
+
"activate_newsletter_newsletters__newsletter_id__activate_post",
|
|
22
|
+
"pause_newsletter_newsletters__newsletter_id__pause_post",
|
|
23
|
+
"get_issue_newsletters__newsletter_id__issues__issue_id__get",
|
|
24
|
+
"create_issue_newsletters__newsletter_id__issues_post",
|
|
25
|
+
"update_issue_newsletters__newsletter_id__issues__issue_id__put",
|
|
26
|
+
"generate_newsletter_issue_newsletters__newsletter_id__issues__issue_id__generate_post",
|
|
27
|
+
"get_issue_generation_status_newsletters__newsletter_id__issues__issue_id__generation_status_get",
|
|
28
|
+
"send_test_email_newsletters__newsletter_id__issues__issue_id__send_test_post",
|
|
29
|
+
"delete_issue_newsletters__newsletter_id__issues__issue_id__delete",
|
|
30
|
+
"list_subscribers_newsletters__newsletter_id__subscribers_get",
|
|
31
|
+
"send_issue_newsletters__newsletter_id__issues__issue_id__send_post",
|
|
32
|
+
"get_send_status_newsletters__newsletter_id__issues__issue_id__send_get",
|
|
33
|
+
"pause_send_newsletters__newsletter_id__issues__issue_id__send_pause_post",
|
|
34
|
+
"resume_send_newsletters__newsletter_id__issues__issue_id__send_resume_post",
|
|
35
|
+
"cancel_send_newsletters__newsletter_id__issues__issue_id__send_cancel_post",
|
|
36
|
+
"add_subscriber_newsletters__newsletter_id__subscribers_post",
|
|
37
|
+
"list_lists_lists_get",
|
|
38
|
+
"import_subscribers_from_audience_newsletters__newsletter_id__subscribers_import_audience_post",
|
|
39
|
+
"preview_newsletter_subscriber_verification_newsletters__newsletter_id__subscribers_verify_preview_post",
|
|
40
|
+
"verify_newsletter_subscribers_newsletters__newsletter_id__subscribers_verify_post",
|
|
41
|
+
"unsubscribe_subscriber_newsletters__newsletter_id__subscribers__subscriber_id__unsubscribe_patch",
|
|
42
|
+
"delete_subscriber_newsletters__newsletter_id__subscribers__subscriber_id__delete",
|
|
43
|
+
"get_issue_analytics_kpis_newsletters__newsletter_id__issues__issue_id__analytics_kpis_get"
|
|
44
|
+
],
|
|
45
|
+
"components": [
|
|
46
|
+
{
|
|
47
|
+
"name": "NewsletterEditor",
|
|
48
|
+
"file": "components/newsletter-editor.tsx",
|
|
49
|
+
"purpose": "Pick or create a newsletter, list its issues, write issues and activate or pause the newsletter.",
|
|
50
|
+
"operations": [
|
|
51
|
+
"list_newsletters_newsletters_get",
|
|
52
|
+
"create_newsletter_newsletters_post",
|
|
53
|
+
"list_issues_newsletters__newsletter_id__issues_get",
|
|
54
|
+
"activate_newsletter_newsletters__newsletter_id__activate_post",
|
|
55
|
+
"pause_newsletter_newsletters__newsletter_id__pause_post"
|
|
56
|
+
],
|
|
57
|
+
"tiers": [
|
|
58
|
+
"read",
|
|
59
|
+
"write",
|
|
60
|
+
"external"
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "IssueForm",
|
|
65
|
+
"file": "components/issue-form.tsx",
|
|
66
|
+
"purpose": "Draft one issue: subject, preview text, HTML, AI generation, test send and delete of drafts.",
|
|
67
|
+
"operations": [
|
|
68
|
+
"get_issue_newsletters__newsletter_id__issues__issue_id__get",
|
|
69
|
+
"create_issue_newsletters__newsletter_id__issues_post",
|
|
70
|
+
"update_issue_newsletters__newsletter_id__issues__issue_id__put",
|
|
71
|
+
"generate_newsletter_issue_newsletters__newsletter_id__issues__issue_id__generate_post",
|
|
72
|
+
"get_issue_generation_status_newsletters__newsletter_id__issues__issue_id__generation_status_get",
|
|
73
|
+
"send_test_email_newsletters__newsletter_id__issues__issue_id__send_test_post",
|
|
74
|
+
"delete_issue_newsletters__newsletter_id__issues__issue_id__delete"
|
|
75
|
+
],
|
|
76
|
+
"tiers": [
|
|
77
|
+
"read",
|
|
78
|
+
"billable",
|
|
79
|
+
"external",
|
|
80
|
+
"destructive"
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"name": "IssueScheduler",
|
|
85
|
+
"file": "components/issue-scheduler.tsx",
|
|
86
|
+
"purpose": "Send an issue now or schedule it, confirmed with the subscriber count, then follow, pause, resume or cancel the send.",
|
|
87
|
+
"operations": [
|
|
88
|
+
"get_issue_newsletters__newsletter_id__issues__issue_id__get",
|
|
89
|
+
"list_subscribers_newsletters__newsletter_id__subscribers_get",
|
|
90
|
+
"get_issue_generation_status_newsletters__newsletter_id__issues__issue_id__generation_status_get",
|
|
91
|
+
"send_issue_newsletters__newsletter_id__issues__issue_id__send_post",
|
|
92
|
+
"get_send_status_newsletters__newsletter_id__issues__issue_id__send_get",
|
|
93
|
+
"pause_send_newsletters__newsletter_id__issues__issue_id__send_pause_post",
|
|
94
|
+
"resume_send_newsletters__newsletter_id__issues__issue_id__send_resume_post",
|
|
95
|
+
"cancel_send_newsletters__newsletter_id__issues__issue_id__send_cancel_post"
|
|
96
|
+
],
|
|
97
|
+
"tiers": [
|
|
98
|
+
"read",
|
|
99
|
+
"write",
|
|
100
|
+
"external"
|
|
101
|
+
]
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"name": "SubscriberTable",
|
|
105
|
+
"file": "components/subscriber-table.tsx",
|
|
106
|
+
"purpose": "Subscribers with search, status filter, add, import a list, verify addresses, unsubscribe and erase.",
|
|
107
|
+
"operations": [
|
|
108
|
+
"list_subscribers_newsletters__newsletter_id__subscribers_get",
|
|
109
|
+
"add_subscriber_newsletters__newsletter_id__subscribers_post",
|
|
110
|
+
"list_lists_lists_get",
|
|
111
|
+
"import_subscribers_from_audience_newsletters__newsletter_id__subscribers_import_audience_post",
|
|
112
|
+
"preview_newsletter_subscriber_verification_newsletters__newsletter_id__subscribers_verify_preview_post",
|
|
113
|
+
"verify_newsletter_subscribers_newsletters__newsletter_id__subscribers_verify_post",
|
|
114
|
+
"unsubscribe_subscriber_newsletters__newsletter_id__subscribers__subscriber_id__unsubscribe_patch",
|
|
115
|
+
"delete_subscriber_newsletters__newsletter_id__subscribers__subscriber_id__delete"
|
|
116
|
+
],
|
|
117
|
+
"tiers": [
|
|
118
|
+
"read",
|
|
119
|
+
"billable",
|
|
120
|
+
"external",
|
|
121
|
+
"destructive"
|
|
122
|
+
]
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"name": "NewsletterStats",
|
|
126
|
+
"file": "components/newsletter-stats.tsx",
|
|
127
|
+
"purpose": "Per-issue delivered, opened, clicked, bounced and unsubscribed numbers, with a stale-data warning.",
|
|
128
|
+
"operations": [
|
|
129
|
+
"list_issues_newsletters__newsletter_id__issues_get",
|
|
130
|
+
"get_issue_analytics_kpis_newsletters__newsletter_id__issues__issue_id__analytics_kpis_get"
|
|
131
|
+
],
|
|
132
|
+
"tiers": [
|
|
133
|
+
"read"
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
"hooks": [],
|
|
138
|
+
"lib": [
|
|
139
|
+
{
|
|
140
|
+
"file": "lib/newsletter-types.ts",
|
|
141
|
+
"purpose": "Newsletter/issue/subscriber types and helpers: draft check, percentages, scheduled-time conversion, generation-in-progress guard."
|
|
142
|
+
}
|
|
143
|
+
],
|
|
144
|
+
"recipes": [
|
|
145
|
+
{
|
|
146
|
+
"file": "recipes/create-newsletter-and-draft-issue.ts",
|
|
147
|
+
"title": "Create a newsletter, draft its first issue, send yourself a test",
|
|
148
|
+
"when": "Setting up a newsletter before any subscriber exists."
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"file": "recipes/import-audience-and-send.ts",
|
|
152
|
+
"title": "Import a list as subscribers, send an issue and follow it to the end",
|
|
153
|
+
"when": "A one-off broadcast to an existing audience; count and confirm before sending."
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"file": "recipes/issue-performance-report.ts",
|
|
157
|
+
"title": "Report how every sent issue performed",
|
|
158
|
+
"when": "A weekly digest or dashboard backend; handles stale analytics."
|
|
159
|
+
}
|
|
160
|
+
],
|
|
161
|
+
"prerequisites": [
|
|
162
|
+
{
|
|
163
|
+
"id": "sending-domain",
|
|
164
|
+
"description": "A verified sending domain/sender for the newsletter (newsletter settings: default_sender_email, sending_domain). Without it issues cannot be delivered. Verify the domain DNS records in the graph8 app.",
|
|
165
|
+
"where": "api",
|
|
166
|
+
"verify": "GET /api/v1/newsletters/{id}/settings shows a sending_domain, and GET .../settings/domain-status?domain= reports verified."
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"id": "consent",
|
|
170
|
+
"description": "Only people who agreed to receive the newsletter may be added or imported. The block never imports contacts by itself: a person chooses the list and confirms the count.",
|
|
171
|
+
"where": "app",
|
|
172
|
+
"verify": "A human owner has confirmed the source and consent of every list that is imported."
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"id": "launch-permission",
|
|
176
|
+
"description": "Activating, resuming and sending are launch-class and need the campaigns:launch permission behind the key.",
|
|
177
|
+
"where": "api",
|
|
178
|
+
"verify": "Activating a test newsletter does not return 403."
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"id": "proxy-allowlist",
|
|
182
|
+
"description": "Add \"newsletters\" and \"lists\" to allowedPrefixes in app/api/g8/[...path]/route.ts.",
|
|
183
|
+
"where": "app",
|
|
184
|
+
"verify": "The route file's allowedPrefixes contains both."
|
|
185
|
+
}
|
|
186
|
+
],
|
|
187
|
+
"scopes": [
|
|
188
|
+
"lists:read",
|
|
189
|
+
"newsletter:delete",
|
|
190
|
+
"newsletter:read",
|
|
191
|
+
"newsletter:run",
|
|
192
|
+
"newsletter:write"
|
|
193
|
+
],
|
|
194
|
+
"defaults": [
|
|
195
|
+
{
|
|
196
|
+
"decision": "Send now or scheduled",
|
|
197
|
+
"default": "Send now; offer Schedule as a second option with a datetime picker",
|
|
198
|
+
"askHuman": false
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"decision": "Who gets an issue",
|
|
202
|
+
"default": "Every subscribed person of the newsletter; the count is shown in the confirm dialog",
|
|
203
|
+
"askHuman": false
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"decision": "Confirming a send",
|
|
207
|
+
"default": "Always: external tier, with the subscriber count. Never auto-send",
|
|
208
|
+
"askHuman": false
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"decision": "Whether to enable auto-verify of imported addresses",
|
|
212
|
+
"default": "Off; run the verification preview first and confirm the credit cost",
|
|
213
|
+
"askHuman": true
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"decision": "Removing a subscriber",
|
|
217
|
+
"default": "Unsubscribe (keeps the opt-out record); Erase only for an erasure request",
|
|
218
|
+
"askHuman": false
|
|
219
|
+
}
|
|
220
|
+
],
|
|
221
|
+
"peerDependencies": {
|
|
222
|
+
"react": ">=18"
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IssueForm: write one newsletter issue (draft), generate it with AI, send yourself a test.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. getIssue (GET /newsletters/{id}/issues/{issue_id}) read
|
|
8
|
+
* 2. createIssue (POST .../issues) or updateIssue (PUT .../issues/{issue_id}) external -> ConfirmSpendDialog (saves a DRAFT; nothing is sent)
|
|
9
|
+
* 3. generateNewsletterIssue (POST .../issues/{issue_id}/generate) billable -> ConfirmSpendDialog (runs a model)
|
|
10
|
+
* 4. getIssueGenerationStatus (GET .../generation-status), polled read (an unfinished generation is partial HTML)
|
|
11
|
+
* 5. sendTestEmail (POST .../issues/{issue_id}/send-test) external -> ConfirmSpendDialog (ONE address)
|
|
12
|
+
* 6. deleteIssue (DELETE .../issues/{issue_id}) destructive -> ConfirmSpendDialog (drafts only)
|
|
13
|
+
* Only drafts are editable; a sent issue opens read-only.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect, useState } from "react";
|
|
17
|
+
import { Button, Input, Label, Skeleton, Textarea } from "@graph8/sdk/ui";
|
|
18
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
19
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
20
|
+
import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
|
|
21
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
22
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
23
|
+
import { useJobPoll } from "@/g8/hooks/use-job-poll";
|
|
24
|
+
import { generationInProgress, isDraft } from "../lib/newsletter-types";
|
|
25
|
+
import type { Issue } from "../lib/newsletter-types";
|
|
26
|
+
|
|
27
|
+
export interface IssueFormProps {
|
|
28
|
+
newsletterId: string;
|
|
29
|
+
/** Edit this issue; omit to write a new one. */
|
|
30
|
+
issueId?: number;
|
|
31
|
+
onSaved?: (issue: Issue) => void;
|
|
32
|
+
onDeleted?: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Confirm = "save" | "generate" | "test" | "delete" | null;
|
|
36
|
+
|
|
37
|
+
export function IssueForm({ newsletterId, issueId: given, onSaved, onDeleted }: IssueFormProps) {
|
|
38
|
+
// The id of a draft THIS form created: a second Save (or a retry after a failed follow-up) updates it, never creates a duplicate.
|
|
39
|
+
const [created, setCreated] = useState<number | undefined>(undefined);
|
|
40
|
+
const issueId = given ?? created;
|
|
41
|
+
const base = `/newsletters/${encodeURIComponent(newsletterId)}/issues`;
|
|
42
|
+
const loaded = useG8Query((signal) => g8Get<Issue>(`${base}/${issueId}`, {}, { signal }).then((r) => r.data), [base, issueId], { enabled: issueId !== undefined });
|
|
43
|
+
const [subject, setSubject] = useState("");
|
|
44
|
+
const [preview, setPreview] = useState("");
|
|
45
|
+
const [html, setHtml] = useState("");
|
|
46
|
+
const [prompt, setPrompt] = useState("");
|
|
47
|
+
const [testEmail, setTestEmail] = useState("");
|
|
48
|
+
const [confirm, setConfirm] = useState<Confirm>(null);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!loaded.data) return;
|
|
52
|
+
setSubject(loaded.data.subject ?? "");
|
|
53
|
+
setPreview(loaded.data.preview_text ?? "");
|
|
54
|
+
setHtml(loaded.data.html_body ?? "");
|
|
55
|
+
}, [loaded.data]);
|
|
56
|
+
|
|
57
|
+
const generation = useJobPoll((signal) => g8Get<{ status?: string }>(`${base}/${issueId}/generation-status`, {}, { signal }).then((r) => r.data), {
|
|
58
|
+
enabled: false,
|
|
59
|
+
intervalMs: 2500,
|
|
60
|
+
isTerminal: (s) => !generationInProgress(s),
|
|
61
|
+
onDone: () => void loaded.refetch(),
|
|
62
|
+
});
|
|
63
|
+
const save = useG8Mutation(async () => {
|
|
64
|
+
const body = { subject: subject.trim(), preview_text: preview.trim() || undefined, html_body: html };
|
|
65
|
+
const res = issueId === undefined ? await g8Post<Issue>(base, body) : await g8Put<Issue>(`${base}/${issueId}`, body);
|
|
66
|
+
return res.data;
|
|
67
|
+
}, { onSuccess: (issue) => { setConfirm(null); if (given === undefined) setCreated(issue.id); onSaved?.(issue); } });
|
|
68
|
+
const generate = useG8Mutation(() => g8Post(`${base}/${issueId}/generate`, { prompt: prompt.trim() || undefined }), { onSuccess: () => { setConfirm(null); generation.start(); } });
|
|
69
|
+
const sendTest = useG8Mutation(() => g8Post(`${base}/${issueId}/send-test`, { test_email: testEmail.trim() }), { onSuccess: () => setConfirm(null) });
|
|
70
|
+
const remove = useG8Mutation(() => g8Delete(`${base}/${issueId}`), { onSuccess: () => { setConfirm(null); onDeleted?.(); } });
|
|
71
|
+
|
|
72
|
+
if (issueId !== undefined && loaded.loading && !loaded.data) return <div aria-busy="true" className="grid gap-2"><Skeleton className="h-9 w-full" /><Skeleton className="h-48 w-full" /></div>;
|
|
73
|
+
if (loaded.error) return <ApiError error={loaded.error} title="Could not load the issue" onRetry={() => void loaded.refetch()} />;
|
|
74
|
+
const editable = issueId === undefined || isDraft(loaded.data);
|
|
75
|
+
const busy = generation.polling;
|
|
76
|
+
const failure = { save: save.error, generate: generate.error, test: sendTest.error, delete: remove.error }[confirm ?? "save"];
|
|
77
|
+
const dialog = {
|
|
78
|
+
save: { tier: "external", title: issueId === undefined ? "Save this new draft?" : "Save changes to this draft?", note: "This saves a draft. Nothing is sent to subscribers.", label: "Save draft", go: () => void save.mutate(undefined), loading: save.loading },
|
|
79
|
+
generate: { tier: "billable", title: "Write this issue with AI?", note: "This runs a model and spends credits. It replaces the current content of the draft.", label: "Generate", go: () => void generate.mutate(undefined), loading: generate.loading },
|
|
80
|
+
test: { tier: "external", title: `Send a test to ${testEmail}?`, note: "One email goes to this address only. Subscribers are not contacted.", label: "Send test", go: () => void sendTest.mutate(undefined), loading: sendTest.loading },
|
|
81
|
+
delete: { tier: "destructive", title: "Delete this draft?", note: "The draft is removed for good.", label: "Delete", go: () => void remove.mutate(undefined), loading: remove.loading },
|
|
82
|
+
} as const;
|
|
83
|
+
const active = dialog[confirm ?? "save"];
|
|
84
|
+
return (
|
|
85
|
+
<form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (subject.trim() && html.trim()) setConfirm("save"); }}>
|
|
86
|
+
{!editable ? <p className="text-muted-foreground text-sm">This issue has been {loaded.data?.status}. It cannot be edited.</p> : null}
|
|
87
|
+
<div><Label htmlFor="g8-issue-subject" className="mb-1">Subject</Label><Input id="g8-issue-subject" value={subject} disabled={!editable} maxLength={2000} onChange={(e) => setSubject(e.target.value)} /></div>
|
|
88
|
+
<div><Label htmlFor="g8-issue-preview" className="mb-1">Preview text</Label><Input id="g8-issue-preview" value={preview} disabled={!editable} maxLength={500} onChange={(e) => setPreview(e.target.value)} /></div>
|
|
89
|
+
<div><Label htmlFor="g8-issue-html" className="mb-1">Content (HTML)</Label><Textarea id="g8-issue-html" rows={10} value={html} disabled={!editable || busy} className="font-mono text-xs" onChange={(e) => setHtml(e.target.value)} /></div>
|
|
90
|
+
{html ? <iframe title="Issue preview" sandbox="" srcDoc={html} className="h-64 w-full rounded-md border" /> : null}
|
|
91
|
+
{busy ? <p role="status" className="text-sm">Writing the issue with AI. Do not send it until this finishes: an unfinished issue is partial.</p> : null}
|
|
92
|
+
{generation.error ? <ApiError error={generation.error} title="Could not read generation progress" /> : null}
|
|
93
|
+
{editable ? (
|
|
94
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
95
|
+
<Button type="submit" disabled={!subject.trim() || !html.trim() || busy}>Save draft</Button>
|
|
96
|
+
{issueId !== undefined ? (
|
|
97
|
+
<>
|
|
98
|
+
<div className="min-w-48 flex-1"><Label htmlFor="g8-issue-prompt" className="mb-1">What should it say? (for AI)</Label><Input id="g8-issue-prompt" value={prompt} onChange={(e) => setPrompt(e.target.value)} /></div>
|
|
99
|
+
<Button type="button" variant="outline" disabled={busy} onClick={() => setConfirm("generate")}>Write with AI</Button>
|
|
100
|
+
<Button type="button" variant="ghost" disabled={busy} onClick={() => setConfirm("delete")}>Delete draft</Button>
|
|
101
|
+
</>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
) : null}
|
|
105
|
+
{issueId !== undefined ? (
|
|
106
|
+
<div className="flex items-end gap-2">
|
|
107
|
+
<div className="max-w-xs flex-1"><Label htmlFor="g8-issue-test" className="mb-1">Send a test to</Label><Input id="g8-issue-test" type="email" value={testEmail} onChange={(e) => setTestEmail(e.target.value)} /></div>
|
|
108
|
+
<Button type="button" variant="outline" disabled={!testEmail.includes("@") || busy} onClick={() => setConfirm("test")}>Send test</Button>
|
|
109
|
+
</div>
|
|
110
|
+
) : null}
|
|
111
|
+
<ConfirmSpendDialog
|
|
112
|
+
open={confirm !== null}
|
|
113
|
+
tier={active.tier}
|
|
114
|
+
title={active.title}
|
|
115
|
+
description={<><p>{active.note}</p>{failure ? <ApiError error={failure} title="That did not work" /> : null}</>}
|
|
116
|
+
recipients={confirm === "test" ? 1 : undefined}
|
|
117
|
+
confirmLabel={active.label}
|
|
118
|
+
confirming={active.loading}
|
|
119
|
+
onCancel={() => setConfirm(null)}
|
|
120
|
+
onConfirm={active.go}
|
|
121
|
+
/>
|
|
122
|
+
</form>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IssueScheduler: send an issue now or schedule it, and follow the send. THIS MAILS REAL PEOPLE.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. getIssue (GET /newsletters/{id}/issues/{issue_id}) read
|
|
8
|
+
* 2. listSubscribers (GET .../subscribers?status=subscribed&page_size=1) read (only for the recipient count in the confirm dialog)
|
|
9
|
+
* 3. getIssueGenerationStatus (GET .../generation-status), polled and re-read before the confirm read (an unfinished AI issue is partial HTML: sending is blocked)
|
|
10
|
+
* 4. sendIssue (POST .../issues/{issue_id}/send, optional scheduled_at) external -> ConfirmSpendDialog with the subscriber count
|
|
11
|
+
* 5. getSendStatus (GET .../issues/{issue_id}/send), polled read (202 means queued, not delivered)
|
|
12
|
+
* 6. pauseSend (POST .../send/pause) write
|
|
13
|
+
* 7. resumeSend (POST .../send/resume) external -> ConfirmSpendDialog (restarts delivery)
|
|
14
|
+
* 8. cancelSend (POST .../send/cancel) write -> confirmed anyway: there is no resuming a canceled send
|
|
15
|
+
* Delivery happens asynchronously in a worker, so progress comes from polling getSendStatus until `active` is false.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useEffect, useState } from "react";
|
|
19
|
+
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Skeleton } from "@graph8/sdk/ui";
|
|
20
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
21
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
22
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
23
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
24
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
25
|
+
import { useJobPoll } from "@/g8/hooks/use-job-poll";
|
|
26
|
+
import { generationInProgress, isDraft, issueTitle, sendIsMoving, sendIsPaused, toIsoInstant } from "../lib/newsletter-types";
|
|
27
|
+
import type { Issue, SendState } from "../lib/newsletter-types";
|
|
28
|
+
|
|
29
|
+
export interface IssueSchedulerProps {
|
|
30
|
+
newsletterId: string;
|
|
31
|
+
issueId: number;
|
|
32
|
+
onSent?: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Confirm = "send" | "resume" | "cancel" | null;
|
|
36
|
+
|
|
37
|
+
export function IssueScheduler({ newsletterId, issueId, onSent }: IssueSchedulerProps) {
|
|
38
|
+
const base = `/newsletters/${encodeURIComponent(newsletterId)}`;
|
|
39
|
+
const issueBase = `${base}/issues/${issueId}`;
|
|
40
|
+
const issue = useG8Query((signal) => g8Get<Issue>(issueBase, {}, { signal }).then((r) => r.data), [issueBase]);
|
|
41
|
+
const audience = useG8Query((signal) => g8Get<{ total?: number }>(`${base}/subscribers`, { status: "subscribed", page_size: 1 }, { signal }).then((r) => r.data?.total ?? 0), [base]);
|
|
42
|
+
// Follow the AI generation until it is final: opening this screen mid-generation must unblock Send when it finishes.
|
|
43
|
+
const generation = useJobPoll((signal) => g8Get<{ status?: string }>(`${issueBase}/generation-status`, {}, { signal }).then((r) => r.data), { intervalMs: 3000, isTerminal: (s) => !generationInProgress(s?.status) });
|
|
44
|
+
const [lateGeneration, setLateGeneration] = useState(false);
|
|
45
|
+
// A paused send still reports active: stop polling while it is paused; Resume (or Pause) restarts the poll.
|
|
46
|
+
const send = useJobPoll((signal) => g8Get<SendState>(`${issueBase}/send`, {}, { signal }).then((r) => r.data), { enabled: false, intervalMs: 3000, isTerminal: (s) => s.active === false || (sendIsPaused(s.send?.status) && !/pausing/i.test(s.send?.status ?? "")) });
|
|
47
|
+
const [when, setWhen] = useState<"now" | "later">("now");
|
|
48
|
+
const [at, setAt] = useState("");
|
|
49
|
+
const [confirm, setConfirm] = useState<Confirm>(null);
|
|
50
|
+
const scheduledAt = when === "later" ? toIsoInstant(at) : undefined;
|
|
51
|
+
const inPast = scheduledAt !== undefined && new Date(scheduledAt).getTime() <= Date.now();
|
|
52
|
+
// Everything typed or opened here belongs to one issue: switching issues starts clean.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
setWhen("now");
|
|
55
|
+
setAt("");
|
|
56
|
+
setConfirm(null);
|
|
57
|
+
setLateGeneration(false);
|
|
58
|
+
}, [newsletterId, issueId]);
|
|
59
|
+
|
|
60
|
+
// The late-generation flag only lasts until the restarted poll sees a final status.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (generation.done && !generationInProgress(generation.status?.status)) setLateGeneration(false);
|
|
63
|
+
}, [generation.done, generation.status]);
|
|
64
|
+
|
|
65
|
+
// A generation can start after this screen opened: re-read it right before the confirm instead of trusting the last poll.
|
|
66
|
+
const openSend = async (): Promise<void> => {
|
|
67
|
+
try {
|
|
68
|
+
const fresh = (await g8Get<{ status?: string }>(`${issueBase}/generation-status`)).data;
|
|
69
|
+
if (generationInProgress(fresh?.status)) {
|
|
70
|
+
setLateGeneration(true);
|
|
71
|
+
generation.start();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// The poll above already surfaces a failing status read; do not block the send on a transient error here.
|
|
76
|
+
}
|
|
77
|
+
setConfirm("send");
|
|
78
|
+
};
|
|
79
|
+
const leftDraft = issue.data ? !isDraft(issue.data) : false;
|
|
80
|
+
// An issue that is already out: show where its send stands (progress, or "no send is running").
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (leftDraft) send.start();
|
|
83
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
84
|
+
}, [leftDraft]);
|
|
85
|
+
const subscribers = audience.data ?? 0;
|
|
86
|
+
|
|
87
|
+
const start = useG8Mutation(() => g8Post(`${issueBase}/send`, scheduledAt ? { scheduled_at: scheduledAt } : {}), { onSuccess: () => { setConfirm(null); send.start(); void issue.refetch(); onSent?.(); } });
|
|
88
|
+
const pause = useG8Mutation(() => g8Post(`${issueBase}/send/pause`, {}), { onSuccess: () => send.start() });
|
|
89
|
+
const resume = useG8Mutation(() => g8Post(`${issueBase}/send/resume`), { onSuccess: () => { setConfirm(null); send.start(); } });
|
|
90
|
+
const cancel = useG8Mutation(() => g8Post(`${issueBase}/send/cancel`, {}), { onSuccess: () => { setConfirm(null); send.start(); } });
|
|
91
|
+
|
|
92
|
+
if (issue.error) return <ApiError error={issue.error} title="Could not load the issue" onRetry={() => void issue.refetch()} />;
|
|
93
|
+
if (!issue.data) return <Skeleton className="h-40 w-full" aria-busy="true" />;
|
|
94
|
+
const draft = isDraft(issue.data);
|
|
95
|
+
const partial = (generationInProgress(generation.status?.status) && !generation.done) || lateGeneration;
|
|
96
|
+
const invalidLater = when === "later" && (!scheduledAt || inPast);
|
|
97
|
+
const blocked = !draft || partial || subscribers === 0 || invalidLater;
|
|
98
|
+
const state = send.status?.send;
|
|
99
|
+
const moving = sendIsMoving(state?.status);
|
|
100
|
+
const paused = sendIsPaused(state?.status);
|
|
101
|
+
const dialogs = {
|
|
102
|
+
send: { title: scheduledAt ? `Schedule "${issueTitle(issue.data)}" for ${new Date(scheduledAt).toLocaleString()}?` : `Send "${issueTitle(issue.data)}" now?`, label: scheduledAt ? "Schedule" : "Send", go: () => void start.mutate(undefined), loading: start.loading, error: start.error, tier: "external" as const, note: "Real emails go to your subscribers and cannot be recalled." },
|
|
103
|
+
resume: { title: "Resume this send?", label: "Resume", go: () => void resume.mutate(undefined), loading: resume.loading, error: resume.error, tier: "external" as const, note: "Delivery restarts for the recipients who have not received this issue yet." },
|
|
104
|
+
cancel: { title: "Cancel this send for good?", label: "Cancel send", go: () => void cancel.mutate(undefined), loading: cancel.loading, error: cancel.error, tier: "destructive" as const, note: "Whoever already received the issue has it; nobody else will. A canceled send cannot be resumed." },
|
|
105
|
+
};
|
|
106
|
+
const active = dialogs[confirm ?? "send"];
|
|
107
|
+
return (
|
|
108
|
+
<Card>
|
|
109
|
+
<CardHeader><CardTitle>{issueTitle(issue.data)} <span className="text-muted-foreground text-sm font-normal">{issue.data.status}</span></CardTitle></CardHeader>
|
|
110
|
+
<CardContent className="grid gap-4">
|
|
111
|
+
{partial ? <p role="alert" className="text-sm text-amber-700">This issue is still being written by AI. Wait for it to finish: sending now would mail the partial version.</p> : null}
|
|
112
|
+
{draft ? (
|
|
113
|
+
<div className="grid gap-3">
|
|
114
|
+
<p className="text-sm">Audience: <strong>{audience.loading ? "..." : subscribers.toLocaleString()}</strong> subscribed {subscribers === 1 ? "person" : "people"}.</p>
|
|
115
|
+
<div className="flex gap-2" role="group" aria-label="When to send">
|
|
116
|
+
<Button size="sm" variant={when === "now" ? "secondary" : "ghost"} onClick={() => setWhen("now")}>Send now</Button>
|
|
117
|
+
<Button size="sm" variant={when === "later" ? "secondary" : "ghost"} onClick={() => setWhen("later")}>Schedule</Button>
|
|
118
|
+
</div>
|
|
119
|
+
{when === "later" ? <div className="max-w-xs"><Label htmlFor="g8-send-at" className="mb-1">Send at</Label><Input id="g8-send-at" type="datetime-local" value={at} onChange={(e) => setAt(e.target.value)} /></div> : null}
|
|
120
|
+
{inPast ? <p role="alert" className="text-sm text-red-700">Pick a time in the future.</p> : null}
|
|
121
|
+
<div><Button disabled={blocked} onClick={() => void openSend()}>{when === "later" ? "Schedule issue" : "Send issue"}</Button></div>
|
|
122
|
+
{subscribers === 0 && !audience.loading ? <p className="text-muted-foreground text-sm">Add subscribers first (SubscriberTable).</p> : null}
|
|
123
|
+
</div>
|
|
124
|
+
) : <p className="text-sm">{issue.data.sent_at ? `Sent ${new Date(issue.data.sent_at).toLocaleString()}.` : issue.data.scheduled_at ? `Scheduled for ${new Date(issue.data.scheduled_at).toLocaleString()}.` : "This issue is not a draft."}</p>}
|
|
125
|
+
{send.status ? (
|
|
126
|
+
<div className="grid gap-2 text-sm" role="status">
|
|
127
|
+
<p>{send.status.active ? `Sending: ${state?.status ?? "in progress"}` : "No send is running."}{state?.sent_count != null ? ` ${state.sent_count}${state.total_recipients ? ` of ${state.total_recipients}` : ""} sent.${state.failed_count ? ` ${state.failed_count} failed.` : ""}` : ""}</p>
|
|
128
|
+
{send.status.active ? (
|
|
129
|
+
<div className="flex gap-2">
|
|
130
|
+
{moving ? <Button size="sm" variant="outline" loading={pause.loading} onClick={() => void pause.mutate(undefined)}>Pause send</Button> : null}
|
|
131
|
+
{paused ? <Button size="sm" variant="outline" onClick={() => setConfirm("resume")}>Resume send</Button> : null}
|
|
132
|
+
<Button size="sm" variant="ghost" onClick={() => setConfirm("cancel")}>Cancel send</Button>
|
|
133
|
+
</div>
|
|
134
|
+
) : null}
|
|
135
|
+
</div>
|
|
136
|
+
) : null}
|
|
137
|
+
{[send.error, pause.error].filter(Boolean).map((e, i) => <ApiError key={i} error={e} title="Could not follow the send" />)}
|
|
138
|
+
<ConfirmSpendDialog
|
|
139
|
+
open={confirm !== null}
|
|
140
|
+
tier={active.tier}
|
|
141
|
+
title={active.title}
|
|
142
|
+
recipients={confirm === "send" ? subscribers : undefined}
|
|
143
|
+
description={<><p>{active.note}</p>{active.error ? <ApiError error={active.error} title="That did not work" /> : null}</>}
|
|
144
|
+
confirmLabel={active.label}
|
|
145
|
+
confirming={active.loading}
|
|
146
|
+
onCancel={() => setConfirm(null)}
|
|
147
|
+
onConfirm={active.go}
|
|
148
|
+
/>
|
|
149
|
+
</CardContent>
|
|
150
|
+
</Card>
|
|
151
|
+
);
|
|
152
|
+
}
|