@graph8/sdk 0.303.0 → 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 +2220 -23
- package/dist/index.d.ts +2220 -23
- package/dist/index.js +64 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +64 -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 +2082 -20
- package/dist/react.d.ts +2082 -20
- package/dist/react.js +63 -2
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +63 -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 +7 -3
- package/docs/api/appObjects.d.ts +3 -1
- package/docs/api/appObjects.md +2 -1
- package/docs/api/by-path.md +51 -0
- package/docs/api/dataPipelines.d.ts +149 -0
- package/docs/api/dataPipelines.md +232 -0
- package/docs/api/forms.d.ts +16 -0
- package/docs/api/forms.md +8 -0
- package/docs/api/objects.d.ts +705 -5
- package/docs/api/objects.md +1099 -14
- package/docs/api/ops.d.ts +1 -1
- package/docs/api/ops.md +1 -1
- package/docs/api/processLists.d.ts +247 -0
- package/docs/api/processLists.md +409 -0
- package/docs/api/quotes.d.ts +3 -1
- package/docs/api/quotes.md +7 -2
- package/docs/api/radar.d.ts +4 -0
- package/docs/api/radar.md +1 -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,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CreativeGallery: AI-made ad creatives (copy plus image) for Meta, LinkedIn, Google and X. Generate one from a
|
|
5
|
+
* brief, follow it while it generates, edit its copy, regenerate the image, move it to the gallery (ready to use) or
|
|
6
|
+
* delete it. Generation and regeneration spend credits and are confirmed; every card has its own busy state.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* 1. getAdCreativeSpecs GET /ad-creatives/specs (read) { platforms, specs: { platform: { format: { label } } }, ctas }
|
|
10
|
+
* 2. listAdCreatives GET /ad-creatives?status&platform&limit&offset (read) { items }
|
|
11
|
+
* 3. createAdCreative POST /ad-creatives { platform, ad_format, prompt, name } (billable) ConfirmSpendDialog first; 201 with status "generating"
|
|
12
|
+
* While any card is `generating` the list is re-read with useJobPoll (no streams) until none is.
|
|
13
|
+
* 5. updateAdCreative PATCH /ad-creatives/{id} { headline, primary_text, description, cta } (write)
|
|
14
|
+
* 6. regenerateAdCreativeImage POST /ad-creatives/{id}/regenerate-image (billable) ConfirmSpendDialog first
|
|
15
|
+
* 7. moveAdCreativeToGallery POST /ad-creatives/{id}/move-to-gallery (write) needs the ads launch permission
|
|
16
|
+
* 8. deleteAdCreative DELETE /ad-creatives/{id} (destructive)
|
|
17
|
+
* The API quotes no price for generation: the dialog says credits are used and the balance is not checked here.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useState } from "react";
|
|
21
|
+
import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
|
|
22
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
23
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
24
|
+
import type { ConfirmTier } from "@/g8/components/confirm-spend-dialog";
|
|
25
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
26
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
27
|
+
import { useJobPoll } from "@/g8/hooks/use-job-poll";
|
|
28
|
+
import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
29
|
+
import { listOf } from "../lib/social-ads-types";
|
|
30
|
+
import type { AdCreative } from "../lib/social-ads-types";
|
|
31
|
+
|
|
32
|
+
export interface CreativeGalleryProps {
|
|
33
|
+
/** Show only creatives in this state. Default: all working creatives. */
|
|
34
|
+
status?: "draft" | "completed" | "gallery";
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Specs { platforms: string[]; specs: Record<string, Record<string, { label: string }>> }
|
|
39
|
+
type Pending = { kind: "create" } | { kind: "regenerate" | "delete"; creative: AdCreative };
|
|
40
|
+
|
|
41
|
+
export function CreativeGallery({ status, className }: CreativeGalleryProps) {
|
|
42
|
+
const [platform, setPlatform] = useState("");
|
|
43
|
+
const [form, setForm] = useState({ platform: "meta", format: "", prompt: "", name: "" });
|
|
44
|
+
const [pending, setPending] = useState<Pending | null>(null);
|
|
45
|
+
const [editing, setEditing] = useState<{ id: string; headline: string; primary_text: string } | null>(null);
|
|
46
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
47
|
+
const [failure, setFailure] = useState<unknown>(null);
|
|
48
|
+
|
|
49
|
+
const specs = useG8Query(async (signal) => (await g8Get<Specs>("/ad-creatives/specs", undefined, { signal })).data, []);
|
|
50
|
+
const list = useG8Query(async (signal) => listOf<AdCreative>((await g8Get("/ad-creatives", { status, platform: platform || undefined, limit: 50 }, { signal })).data), [status, platform]);
|
|
51
|
+
const items = list.data ?? [];
|
|
52
|
+
const generating = items.filter((c) => c.status === "generating");
|
|
53
|
+
useJobPoll(async () => (await list.refetch()) ?? ([{ status: "generating" }] as AdCreative[]), { enabled: generating.length > 0, intervalMs: 4000, maxAttempts: 100, isTerminal: (rows) => !rows.some((c) => c.status === "generating") });
|
|
54
|
+
|
|
55
|
+
const formats = Object.entries(specs.data?.specs?.[form.platform] ?? {});
|
|
56
|
+
const format = form.format || formats[0]?.[0] || "";
|
|
57
|
+
|
|
58
|
+
const run = async (id: string, work: () => Promise<unknown>, tolerate404 = false) => {
|
|
59
|
+
setBusy(id);
|
|
60
|
+
setFailure(null);
|
|
61
|
+
try { await work(); } catch (err) { if (!(tolerate404 && (err as { status?: number })?.status === 404)) setFailure(err); }
|
|
62
|
+
finally { setBusy(null); setPending(null); await list.refetch(); }
|
|
63
|
+
};
|
|
64
|
+
const confirmPending = () => {
|
|
65
|
+
if (!pending) return;
|
|
66
|
+
if (pending.kind === "create") return void run("new", async () => { await g8Post("/ad-creatives", { platform: form.platform, ad_format: format, prompt: form.prompt.trim() || undefined, name: form.name.trim() || undefined }); setForm({ ...form, prompt: "", name: "" }); });
|
|
67
|
+
const id = pending.creative.id;
|
|
68
|
+
if (pending.kind === "regenerate") return void run(id, () => g8Post(`/ad-creatives/${id}/regenerate-image`, {}));
|
|
69
|
+
return void run(id, () => g8Delete(`/ad-creatives/${id}`), true);
|
|
70
|
+
};
|
|
71
|
+
const saveCopy = () => editing && void run(editing.id, async () => { await g8Patch(`/ad-creatives/${editing.id}`, { headline: editing.headline, primary_text: editing.primary_text }); setEditing(null); });
|
|
72
|
+
|
|
73
|
+
const tier: ConfirmTier = pending?.kind === "delete" ? "destructive" : "billable";
|
|
74
|
+
const name = pending && pending.kind !== "create" ? pending.creative.name : "";
|
|
75
|
+
return (
|
|
76
|
+
<div className={`flex flex-col gap-4 ${className ?? ""}`}>
|
|
77
|
+
<form className="flex flex-wrap items-end gap-2" onSubmit={(e) => { e.preventDefault(); if (format) setPending({ kind: "create" }); }}>
|
|
78
|
+
<select aria-label="New creative platform" className="h-9 rounded-md border bg-background px-2" value={form.platform} onChange={(e) => setForm({ ...form, platform: e.target.value, format: "" })}>
|
|
79
|
+
{(specs.data?.platforms ?? ["meta", "linkedin", "google", "x"]).map((p) => <option key={p} value={p}>{p}</option>)}
|
|
80
|
+
</select>
|
|
81
|
+
<select aria-label="Format" className="h-9 rounded-md border bg-background px-2" value={format} onChange={(e) => setForm({ ...form, format: e.target.value })}>
|
|
82
|
+
{formats.map(([key, spec]) => <option key={key} value={key}>{spec.label}</option>)}
|
|
83
|
+
</select>
|
|
84
|
+
<div className="flex flex-col gap-1"><Label htmlFor="cg-prompt">What is the ad about?</Label><Input id="cg-prompt" value={form.prompt} onChange={(e) => setForm({ ...form, prompt: e.target.value })} maxLength={2000} /></div>
|
|
85
|
+
<Button type="submit" loading={busy === "new"} disabled={!format || busy != null}>Generate</Button>
|
|
86
|
+
</form>
|
|
87
|
+
{specs.error ? <ApiError error={specs.error} title="Could not load the formats" onRetry={() => void specs.refetch()} /> : null}
|
|
88
|
+
<select aria-label="Filter platform" className="h-9 w-40 rounded-md border bg-background px-2" value={platform} onChange={(e) => setPlatform(e.target.value)}><option value="">All platforms</option>{(specs.data?.platforms ?? []).map((p) => <option key={p} value={p}>{p}</option>)}</select>
|
|
89
|
+
{list.error ? <ApiError error={list.error} title="Could not load creatives" onRetry={() => void list.refetch()} /> : null}
|
|
90
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
91
|
+
{list.loading && !list.data ? <Skeleton className="h-32 w-full" /> : null}
|
|
92
|
+
{!list.loading && !list.error && items.length === 0 ? <EmptyState title="No creatives yet" description="Describe an ad above and generate the first one." /> : null}
|
|
93
|
+
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
94
|
+
{items.map((c) => (
|
|
95
|
+
<li key={c.id} className="flex flex-col gap-2 rounded-md border p-3" data-testid="creative" aria-busy={c.status === "generating" || busy === c.id}>
|
|
96
|
+
{c.image_url ? <img src={c.image_url} alt={c.headline ?? c.name} className="aspect-video w-full rounded object-cover" /> : <div className="flex aspect-video items-center justify-center rounded bg-muted text-xs text-muted-foreground">{c.status === "generating" ? "Generating..." : "No image"}</div>}
|
|
97
|
+
<p className="text-xs text-muted-foreground">{c.platform} · {c.ad_format} · {c.status}</p>
|
|
98
|
+
{editing?.id === c.id ? (
|
|
99
|
+
<div className="flex flex-col gap-1">
|
|
100
|
+
<Input aria-label="Headline" value={editing.headline} onChange={(e) => setEditing({ ...editing, headline: e.target.value })} />
|
|
101
|
+
<Input aria-label="Primary text" value={editing.primary_text} onChange={(e) => setEditing({ ...editing, primary_text: e.target.value })} />
|
|
102
|
+
<div className="flex gap-1"><Button size="sm" loading={busy === c.id} onClick={saveCopy}>Save</Button><Button size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button></div>
|
|
103
|
+
</div>
|
|
104
|
+
) : <div><p className="text-sm font-medium">{c.headline || c.name}</p><p className="line-clamp-2 text-sm text-muted-foreground">{c.primary_text}</p></div>}
|
|
105
|
+
{c.status === "failed" && c.error_message ? <p className="text-xs text-destructive">{c.error_message}</p> : null}
|
|
106
|
+
<div className="flex flex-wrap gap-1">
|
|
107
|
+
<Button size="sm" variant="ghost" disabled={busy != null || c.status === "generating"} onClick={() => setEditing({ id: c.id, headline: c.headline ?? "", primary_text: c.primary_text ?? "" })}>Edit copy</Button>
|
|
108
|
+
<Button size="sm" variant="ghost" loading={busy === c.id} disabled={busy != null || c.status === "generating"} onClick={() => setPending({ kind: "regenerate", creative: c })}>New image</Button>
|
|
109
|
+
{c.status !== "gallery" && c.status !== "generating" ? <Button size="sm" variant="outline" disabled={busy != null} onClick={() => void run(c.id, () => g8Post(`/ad-creatives/${c.id}/move-to-gallery`, {}))}>Move to gallery</Button> : null}
|
|
110
|
+
<Button size="sm" variant="ghost" disabled={busy != null} onClick={() => setPending({ kind: "delete", creative: c })}>Delete</Button>
|
|
111
|
+
</div>
|
|
112
|
+
</li>
|
|
113
|
+
))}
|
|
114
|
+
</ul>
|
|
115
|
+
<ConfirmSpendDialog open={pending != null} tier={tier}
|
|
116
|
+
title={pending?.kind === "create" ? `Generate a ${form.platform} creative` : pending?.kind === "regenerate" ? `Make a new image for "${name}"` : `Delete "${name}"`}
|
|
117
|
+
description={tier === "billable" ? "Uses credits (copy and image). The API gives no price in advance and your balance is not checked here." : "The creative and its revisions are removed."}
|
|
118
|
+
confirmLabel={pending?.kind === "delete" ? "Delete" : "Generate"} confirming={busy != null} onConfirm={confirmPending} onCancel={() => setPending(null)} />
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PostComposer: write a social post, optionally with AI drafts, then save it as a draft, schedule it or publish it.
|
|
5
|
+
* Publishing reaches real audiences, so scheduling and publishing are confirmed with the account, the exact time
|
|
6
|
+
* (in the viewer's zone, named) and what will happen.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* 1. listSocialAccounts GET /social/accounts (read) the account `id` is `account_ref`; capacity { publish_used, publish_limit }
|
|
10
|
+
* 2. generateSocialPost POST /social/posts/generate { instructions, variants } (billable) returns { variants: [{ frame, body }], credits_used };
|
|
11
|
+
* saves nothing. ConfirmSpendDialog first; no advance price, so the dialog says credits are used and the balance is not checked.
|
|
12
|
+
* 3. createSocialPost POST /social/posts { body, account_ref, post_type, media, idempotency_key } (write) makes a DRAFT. The new id is kept at once
|
|
13
|
+
* so a retry after a later step failed updates this post instead of creating a second one.
|
|
14
|
+
* updateSocialPost PATCH /social/posts/{post_id} (write) when the draft already exists
|
|
15
|
+
* 4. scheduleSocialPost POST /social/posts/{post_id}/schedule { scheduled_at } (external) an ISO instant; ConfirmSpendDialog first
|
|
16
|
+
* 5. publishSocialPost POST /social/posts/{post_id}/publish (external) now; ConfirmSpendDialog first
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useRef, useState } from "react";
|
|
20
|
+
import { Button, Input, Label, Textarea } from "@graph8/sdk/ui";
|
|
21
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
22
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
23
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
24
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
25
|
+
import { g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
26
|
+
import { useSocialAccounts } from "../hooks/use-social-accounts";
|
|
27
|
+
import { formatInstant, isFuture, localInputToIso } from "../lib/social-ads-types";
|
|
28
|
+
import type { PostVariant, SocialPost } from "../lib/social-ads-types";
|
|
29
|
+
|
|
30
|
+
export interface PostComposerProps {
|
|
31
|
+
/** Called after a post was saved, scheduled or published. */
|
|
32
|
+
onSaved?: (post: SocialPost, action: "draft" | "scheduled" | "published") => void;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Action = "schedule" | "publish";
|
|
37
|
+
const newKey = () => globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
38
|
+
|
|
39
|
+
export function PostComposer({ onSaved, className }: PostComposerProps) {
|
|
40
|
+
const { accounts, loading, error, refetch } = useSocialAccounts();
|
|
41
|
+
const [account, setAccount] = useState("");
|
|
42
|
+
const [body, setBody] = useState("");
|
|
43
|
+
const [imageUrl, setImageUrl] = useState("");
|
|
44
|
+
const [when, setWhen] = useState("");
|
|
45
|
+
const [brief, setBrief] = useState("");
|
|
46
|
+
const [variants, setVariants] = useState<PostVariant[]>([]);
|
|
47
|
+
const [confirm, setConfirm] = useState<Action | "generate" | null>(null);
|
|
48
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
49
|
+
// Retry-safety: the draft made by step 1 is remembered so a retry updates it instead of creating a second post.
|
|
50
|
+
const draft = useRef<{ id: string; key: string } | null>(null);
|
|
51
|
+
const key = useRef(newKey());
|
|
52
|
+
|
|
53
|
+
const chosen = accounts.find((a) => a.id === (account || accounts[0]?.id));
|
|
54
|
+
const media = imageUrl.trim() ? [{ kind: "image", url: imageUrl.trim(), alt: "" }] : [];
|
|
55
|
+
const payload = { body: body.trim(), account_ref: chosen?.id, post_type: media.length ? "image" : "text", media };
|
|
56
|
+
const when_ = when && isFuture(when) ? localInputToIso(when) : null;
|
|
57
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
58
|
+
|
|
59
|
+
const reset = () => { draft.current = null; key.current = newKey(); setBody(""); setImageUrl(""); setWhen(""); setBrief(""); setVariants([]); setConfirm(null); };
|
|
60
|
+
const ensure = async (): Promise<SocialPost> => {
|
|
61
|
+
if (draft.current) return (await g8Patch<SocialPost>(`/social/posts/${draft.current.id}`, payload)).data;
|
|
62
|
+
const created = (await g8Post<SocialPost>("/social/posts", { ...payload, idempotency_key: key.current })).data;
|
|
63
|
+
draft.current = { id: created.id, key: key.current };
|
|
64
|
+
return created;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const save = useG8Mutation(ensure, { onSuccess: (p) => { setNotice("Draft saved."); onSaved?.(p, "draft"); } });
|
|
68
|
+
const go = useG8Mutation(async (action: Action) => {
|
|
69
|
+
const post = await ensure();
|
|
70
|
+
const res = action === "schedule" ? await g8Post<SocialPost>(`/social/posts/${post.id}/schedule`, { scheduled_at: when_ }) : await g8Post<{ post: SocialPost }>(`/social/posts/${post.id}/publish`, {});
|
|
71
|
+
return { post: action === "schedule" ? (res.data as SocialPost) : (res.data as { post: SocialPost }).post, action };
|
|
72
|
+
}, { onSuccess: ({ post, action }) => { const msg = action === "schedule" ? `Scheduled for ${formatInstant(post.scheduled_at ?? when_)}.` : "Publishing started."; reset(); setNotice(msg); onSaved?.(post, action === "schedule" ? "scheduled" : "published"); },
|
|
73
|
+
onError: () => setConfirm(null) });
|
|
74
|
+
const generate = useG8Mutation(async () => (await g8Post<{ variants: PostVariant[]; credits_used: number }>("/social/posts/generate", { instructions: brief.trim() || undefined, variants: 3 })).data, {
|
|
75
|
+
onSuccess: (r) => { setConfirm(null); setVariants(r.variants); setNotice(`${r.credits_used} credits used for ${r.variants.length} drafts.`); },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (error) return <ApiError error={error} title="Could not load social accounts" onRetry={() => void refetch()} />;
|
|
79
|
+
if (!loading && accounts.length === 0) return <EmptyState title="No social account connected" description="Connect a LinkedIn account in graph8 (Settings, Channels), then come back. It cannot be done through the API." />;
|
|
80
|
+
|
|
81
|
+
const ready = body.trim().length > 0 && Boolean(chosen);
|
|
82
|
+
const dialog = confirm === "publish"
|
|
83
|
+
? { title: `Publish now to ${chosen?.display_name ?? chosen?.provider}`, description: "The post goes live immediately on your connected account and cannot be recalled from here.", label: "Publish" }
|
|
84
|
+
: { title: `Schedule for ${formatInstant(when_)}`, description: `Posts to ${chosen?.display_name ?? chosen?.provider} at that moment (${zone}). Until then you can cancel it from the post list.`, label: "Schedule" };
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className={`flex flex-col gap-3 ${className ?? ""}`}>
|
|
88
|
+
<div className="flex flex-col gap-1">
|
|
89
|
+
<Label htmlFor="sp-account">Post as</Label>
|
|
90
|
+
<select id="sp-account" className="h-9 rounded-md border bg-background px-2" value={chosen?.id ?? ""} onChange={(e) => setAccount(e.target.value)}>
|
|
91
|
+
{accounts.map((a) => <option key={a.id} value={a.id}>{a.display_name || a.provider} ({a.provider})</option>)}
|
|
92
|
+
</select>
|
|
93
|
+
{chosen ? <p className="text-xs text-muted-foreground">{chosen.capacity.publish_used} of {chosen.capacity.publish_limit} posts used in this window.</p> : null}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="flex flex-col gap-1">
|
|
97
|
+
<Label htmlFor="sp-brief">Ask AI for drafts (optional)</Label>
|
|
98
|
+
<div className="flex gap-2"><Input id="sp-brief" value={brief} onChange={(e) => setBrief(e.target.value)} placeholder="Announce our new integration, friendly tone" /><Button variant="outline" onClick={() => setConfirm("generate")}>Draft with AI</Button></div>
|
|
99
|
+
{generate.error ? <ApiError error={generate.error} title="Could not generate drafts" /> : null}
|
|
100
|
+
{variants.map((v) => <div key={v.frame + v.body.slice(0, 20)} className="rounded-md border p-2 text-sm"><p className="mb-1 text-xs text-muted-foreground">{v.frame}</p><p>{v.body}</p><Button size="sm" variant="ghost" onClick={() => setBody(v.body)}>Use this</Button></div>)}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div className="flex flex-col gap-1"><Label htmlFor="sp-body">Post</Label><Textarea id="sp-body" rows={6} value={body} onChange={(e) => setBody(e.target.value)} /><p className="text-xs text-muted-foreground">{body.length.toLocaleString()} characters</p></div>
|
|
104
|
+
<div className="flex flex-col gap-1"><Label htmlFor="sp-image">Image URL (optional, must be publicly reachable)</Label><Input id="sp-image" value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} /></div>
|
|
105
|
+
<div className="flex flex-col gap-1"><Label htmlFor="sp-when">Schedule for ({zone})</Label><Input id="sp-when" type="datetime-local" value={when} onChange={(e) => setWhen(e.target.value)} />
|
|
106
|
+
{when && !when_ ? <p role="alert" className="text-xs text-destructive">Pick a time at least a minute from now.</p> : null}</div>
|
|
107
|
+
|
|
108
|
+
{[save.error, go.error].filter(Boolean).map((e, i) => <ApiError key={i} error={e} title="That did not work" />)}
|
|
109
|
+
<div className="flex flex-wrap gap-2">
|
|
110
|
+
<Button variant="outline" loading={save.loading} disabled={!ready} onClick={() => void save.mutate(undefined)}>Save draft</Button>
|
|
111
|
+
<Button disabled={!ready || !when_} onClick={() => setConfirm("schedule")}>Schedule</Button>
|
|
112
|
+
<Button variant="outline" disabled={!ready} onClick={() => setConfirm("publish")}>Publish now</Button>
|
|
113
|
+
</div>
|
|
114
|
+
{notice ? <p role="status" className="text-sm text-muted-foreground">{notice}</p> : null}
|
|
115
|
+
|
|
116
|
+
<ConfirmSpendDialog open={confirm === "generate"} tier="billable" title="Draft 3 posts with AI" description="Uses credits. The API gives no price in advance and your balance is not checked here. Nothing is saved or posted."
|
|
117
|
+
confirmLabel="Draft posts" confirming={generate.loading} onConfirm={() => void generate.mutate(undefined)} onCancel={() => setConfirm(null)} />
|
|
118
|
+
<ConfirmSpendDialog open={confirm === "schedule" || confirm === "publish"} tier="external" title={dialog.title} description={dialog.description} confirmLabel={dialog.label}
|
|
119
|
+
confirming={go.loading} onConfirm={() => void go.mutate(confirm as Action)} onCancel={() => setConfirm(null)} />
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SocialPostList: your posts by status with their times, and the actions that fit each: publish a draft, cancel a
|
|
5
|
+
* scheduled post, delete one. Every action reaches the outside world or removes data, so each is confirmed, and each
|
|
6
|
+
* row shows its own spinner while ITS action runs.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* 1. listSocialPosts GET /social/posts?status&page&page_size (read) { items, total, counts }; counts are per status for the whole org
|
|
10
|
+
* 2. publishSocialPost POST /social/posts/{post_id}/publish (external) ConfirmSpendDialog first
|
|
11
|
+
* 3. cancelSocialPost POST /social/posts/{post_id}/cancel (destructive) a scheduled post only; ConfirmSpendDialog first
|
|
12
|
+
* 4. deleteSocialPost DELETE /social/posts/{post_id} (destructive) ConfirmSpendDialog first (a 404 means it is already gone)
|
|
13
|
+
* Times are instants shown in the viewer's zone with the zone name.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
18
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
19
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
20
|
+
import type { ConfirmTier } from "@/g8/components/confirm-spend-dialog";
|
|
21
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
22
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
23
|
+
import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
24
|
+
import { formatInstant } from "../lib/social-ads-types";
|
|
25
|
+
import type { SocialPost, SocialPostPage } from "../lib/social-ads-types";
|
|
26
|
+
|
|
27
|
+
export interface SocialPostListProps {
|
|
28
|
+
/** Change to reload the list, for example after PostComposer saved a post. */
|
|
29
|
+
refreshKey?: number;
|
|
30
|
+
pageSize?: number;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Pending = { kind: "publish" | "cancel" | "delete"; post: SocialPost };
|
|
35
|
+
const STATUSES = ["all", "draft", "scheduled", "live", "failed", "cancelled"] as const;
|
|
36
|
+
const COPY: Record<Pending["kind"], { tier: ConfirmTier; verb: string; text: string }> = {
|
|
37
|
+
publish: { tier: "external", verb: "Publish", text: "The post goes live now on the connected account and cannot be recalled from here." },
|
|
38
|
+
cancel: { tier: "destructive", verb: "Cancel post", text: "The scheduled post will not be published. It stays as a cancelled record." },
|
|
39
|
+
delete: { tier: "destructive", verb: "Delete", text: "The post is removed. A post that is already live stays live on the network." },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function SocialPostList({ refreshKey = 0, pageSize = 20, className }: SocialPostListProps) {
|
|
43
|
+
const [status, setStatus] = useState<(typeof STATUSES)[number]>("all");
|
|
44
|
+
const [page, setPage] = useState(1);
|
|
45
|
+
const [pending, setPending] = useState<Pending | null>(null);
|
|
46
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
47
|
+
const [failure, setFailure] = useState<unknown>(null);
|
|
48
|
+
|
|
49
|
+
const query = useG8Query(
|
|
50
|
+
async (signal) => (await g8Get<SocialPostPage>("/social/posts", { status: status === "all" ? undefined : status, page, page_size: pageSize }, { signal })).data,
|
|
51
|
+
[status, page, pageSize, refreshKey],
|
|
52
|
+
);
|
|
53
|
+
const items = query.data?.items ?? [];
|
|
54
|
+
const pages = Math.max(1, Math.ceil((query.data?.total ?? 0) / pageSize));
|
|
55
|
+
|
|
56
|
+
const act = async () => {
|
|
57
|
+
if (!pending) return;
|
|
58
|
+
const { kind, post } = pending;
|
|
59
|
+
setBusy(post.id);
|
|
60
|
+
setFailure(null);
|
|
61
|
+
try {
|
|
62
|
+
if (kind === "publish") await g8Post(`/social/posts/${post.id}/publish`, {});
|
|
63
|
+
else if (kind === "cancel") await g8Post(`/social/posts/${post.id}/cancel`, {});
|
|
64
|
+
else await g8Delete(`/social/posts/${post.id}`);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (!(kind === "delete" && (err as { status?: number })?.status === 404)) setFailure(err);
|
|
67
|
+
} finally {
|
|
68
|
+
setBusy(null);
|
|
69
|
+
setPending(null);
|
|
70
|
+
await query.refetch();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const copy = pending ? COPY[pending.kind] : COPY.delete;
|
|
75
|
+
return (
|
|
76
|
+
<div className={`flex flex-col gap-3 ${className ?? ""}`}>
|
|
77
|
+
<div role="tablist" aria-label="Post status" className="flex flex-wrap gap-2">
|
|
78
|
+
{STATUSES.map((s) => (
|
|
79
|
+
<Button key={s} role="tab" aria-selected={status === s} size="sm" variant={status === s ? "default" : "outline"} onClick={() => { setStatus(s); setPage(1); }}>
|
|
80
|
+
{s}{s !== "all" && query.data?.counts?.[s] != null ? ` (${query.data.counts[s]})` : ""}
|
|
81
|
+
</Button>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
{query.error ? <ApiError error={query.error} title="Could not load posts" onRetry={() => void query.refetch()} /> : null}
|
|
85
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
86
|
+
{query.loading && !query.data ? <Skeleton className="h-24 w-full" /> : null}
|
|
87
|
+
{!query.loading && !query.error && items.length === 0 ? <EmptyState title="No posts here" description="Write one with the composer." /> : null}
|
|
88
|
+
<ul className="flex flex-col gap-2">
|
|
89
|
+
{items.map((p) => (
|
|
90
|
+
<li key={p.id} className="flex flex-wrap items-start justify-between gap-2 rounded-md border p-3" data-testid="post">
|
|
91
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
92
|
+
<p className="line-clamp-3 text-sm">{p.body}</p>
|
|
93
|
+
<p className="text-xs text-muted-foreground">
|
|
94
|
+
{p.status}
|
|
95
|
+
{p.status === "scheduled" && p.scheduled_at ? ` for ${formatInstant(p.scheduled_at)}` : ""}
|
|
96
|
+
{p.status === "live" && p.published_at ? ` on ${formatInstant(p.published_at)}` : ""}
|
|
97
|
+
{p.failure_reason ? ` · ${p.failure_reason}` : ""}
|
|
98
|
+
</p>
|
|
99
|
+
{p.external_post_url ? <a className="text-xs underline" href={p.external_post_url} target="_blank" rel="noreferrer">View on the network</a> : null}
|
|
100
|
+
</div>
|
|
101
|
+
<div className="flex gap-1">
|
|
102
|
+
{["draft", "ready", "failed"].includes(p.status) ? <Button size="sm" variant="outline" loading={busy === p.id} disabled={busy != null} onClick={() => setPending({ kind: "publish", post: p })}>Publish</Button> : null}
|
|
103
|
+
{p.status === "scheduled" ? <Button size="sm" variant="outline" loading={busy === p.id} disabled={busy != null} onClick={() => setPending({ kind: "cancel", post: p })}>Cancel</Button> : null}
|
|
104
|
+
{p.status !== "publishing" ? <Button size="sm" variant="ghost" disabled={busy != null} onClick={() => setPending({ kind: "delete", post: p })}>Delete</Button> : null}
|
|
105
|
+
</div>
|
|
106
|
+
</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
{pages > 1 ? (
|
|
110
|
+
<div className="flex items-center gap-2 text-sm"><Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage(page - 1)}>Previous</Button>Page {page} of {pages}<Button size="sm" variant="outline" disabled={page >= pages} onClick={() => setPage(page + 1)}>Next</Button></div>
|
|
111
|
+
) : null}
|
|
112
|
+
<ConfirmSpendDialog open={pending != null} tier={copy.tier} title={`${copy.verb}: "${(pending?.post.body ?? "").slice(0, 60)}"`} description={copy.text} confirmLabel={copy.verb}
|
|
113
|
+
confirming={busy != null} onConfirm={() => void act()} onCancel={() => setPending(null)} />
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
4
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
5
|
+
import { listOf } from "../lib/social-ads-types";
|
|
6
|
+
import type { SocialAccount } from "../lib/social-ads-types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Connected social accounts (`GET /social/accounts`). The account `id` is the `account_ref` every post call takes.
|
|
10
|
+
* Connecting an account (LinkedIn sign-in) can only be done in the graph8 app: an empty list means a person must
|
|
11
|
+
* connect one there first.
|
|
12
|
+
*/
|
|
13
|
+
export function useSocialAccounts() {
|
|
14
|
+
const query = useG8Query(async (signal) => listOf<SocialAccount>((await g8Get("/social/accounts", undefined, { signal })).data), []);
|
|
15
|
+
return { accounts: query.data ?? [], loading: query.loading, error: query.error, refetch: query.refetch };
|
|
16
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shapes and helpers for the social and ads components.
|
|
5
|
+
*
|
|
6
|
+
* TIME. A scheduled post is an INSTANT: the person picks a wall-clock time in their own zone
|
|
7
|
+
* (`<input type="datetime-local">`), `localInputToIso` turns it into the UTC instant the API stores, and
|
|
8
|
+
* `formatInstant` shows it back in the viewer's zone WITH the zone name, so "9:00 AM PDT" is never read as UTC.
|
|
9
|
+
* Campaign start and end dates are DATE-ONLY values: `formatDateOnly` shows them in UTC so they never slide a day
|
|
10
|
+
* in a western time zone, and a date-only value is sent back as `YYYY-MM-DD`, never through `toISOString()`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type PostStatus = "draft" | "ready" | "scheduled" | "publishing" | "live" | "failed" | "cancelled" | string;
|
|
14
|
+
|
|
15
|
+
export interface SocialAccount {
|
|
16
|
+
/** What `account_ref` takes everywhere else. */
|
|
17
|
+
id: string;
|
|
18
|
+
display_name?: string | null;
|
|
19
|
+
provider: string;
|
|
20
|
+
status?: string | null;
|
|
21
|
+
profile_url?: string | null;
|
|
22
|
+
capabilities: { publish_personal: boolean; publish_company: boolean; analytics: boolean; leads: boolean; media: boolean };
|
|
23
|
+
capacity: { publish_used: number; publish_limit: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SocialPost {
|
|
27
|
+
id: string;
|
|
28
|
+
provider?: string | null;
|
|
29
|
+
account_ref?: string | null;
|
|
30
|
+
body: string;
|
|
31
|
+
media?: Array<{ kind?: string; url: string; alt?: string }> | null;
|
|
32
|
+
post_type: string;
|
|
33
|
+
status: PostStatus;
|
|
34
|
+
scheduled_at?: string | null;
|
|
35
|
+
published_at?: string | null;
|
|
36
|
+
external_post_url?: string | null;
|
|
37
|
+
failure_reason?: string | null;
|
|
38
|
+
created_at?: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SocialPostPage { items: SocialPost[]; total: number; counts: Record<string, number> }
|
|
42
|
+
|
|
43
|
+
export interface PostVariant { frame: string; body: string }
|
|
44
|
+
|
|
45
|
+
export interface AdCampaign {
|
|
46
|
+
id: number;
|
|
47
|
+
sync_config_id: number;
|
|
48
|
+
/** The id of the campaign on the ad platform, set once launched. Metrics join on it. */
|
|
49
|
+
platform_campaign_id?: string | null;
|
|
50
|
+
platform: string;
|
|
51
|
+
name: string;
|
|
52
|
+
objective?: string | null;
|
|
53
|
+
status: string;
|
|
54
|
+
daily_budget?: number | null;
|
|
55
|
+
currency?: string | null;
|
|
56
|
+
start_date?: string | null;
|
|
57
|
+
end_date?: string | null;
|
|
58
|
+
ad_count?: number | null;
|
|
59
|
+
launched_at?: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CampaignMetric { sync_config_id: number; platform_campaign_id?: string; currency?: string; impressions: number; clicks: number; conversions?: number; spend: number; ctr?: number; cpc?: number }
|
|
63
|
+
|
|
64
|
+
export interface AdCreative {
|
|
65
|
+
id: string;
|
|
66
|
+
name: string;
|
|
67
|
+
platform: string;
|
|
68
|
+
ad_format: string;
|
|
69
|
+
status: "generating" | "draft" | "completed" | "gallery" | "failed" | string;
|
|
70
|
+
headline?: string | null;
|
|
71
|
+
primary_text?: string | null;
|
|
72
|
+
description?: string | null;
|
|
73
|
+
cta?: string | null;
|
|
74
|
+
image_url?: string | null;
|
|
75
|
+
error_message?: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AudienceSyncConfig {
|
|
79
|
+
id: number;
|
|
80
|
+
audience_id: number;
|
|
81
|
+
platform: string;
|
|
82
|
+
platform_audience_name?: string | null;
|
|
83
|
+
mode: string;
|
|
84
|
+
refresh_cadence_hours: number;
|
|
85
|
+
is_active: boolean;
|
|
86
|
+
status?: string | null;
|
|
87
|
+
status_detail?: string | null;
|
|
88
|
+
last_sync_at?: string | null;
|
|
89
|
+
next_sync_at?: string | null;
|
|
90
|
+
last_sync_error?: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const AD_PLATFORMS = ["meta", "linkedin", "google", "x"] as const;
|
|
94
|
+
|
|
95
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
96
|
+
|
|
97
|
+
/** "2026-10-20T09:00" typed in the viewer's zone -> the UTC instant, "2026-10-20T16:00:00.000Z" in Los Angeles. */
|
|
98
|
+
export function localInputToIso(local: string): string {
|
|
99
|
+
const d = new Date(local);
|
|
100
|
+
if (Number.isNaN(d.getTime())) throw new Error("Pick a date and time");
|
|
101
|
+
return d.toISOString();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** An instant -> the value a datetime-local input expects, in the viewer's zone. */
|
|
105
|
+
export function isoToLocalInput(iso: string): string {
|
|
106
|
+
const d = new Date(iso);
|
|
107
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** An instant for display: the viewer's zone, with the zone name ("Oct 20, 2026, 9:00 AM PDT"). */
|
|
111
|
+
export function formatInstant(iso: string | null | undefined): string {
|
|
112
|
+
if (!iso) return "";
|
|
113
|
+
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" }).format(new Date(iso));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A date-only value (YYYY-MM-DD, or a midnight-UTC timestamp) for display, in UTC so it never moves a day. */
|
|
117
|
+
export function formatDateOnly(value: string | null | undefined): string {
|
|
118
|
+
if (!value) return "";
|
|
119
|
+
const d = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value);
|
|
120
|
+
return Number.isNaN(d.getTime()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeZone: "UTC" }).format(d);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isFuture(local: string, now = Date.now()): boolean {
|
|
124
|
+
const t = new Date(local).getTime();
|
|
125
|
+
return !Number.isNaN(t) && t > now + 60_000;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function formatMoney(amount: number | null | undefined, currency?: string | null): string {
|
|
129
|
+
if (amount == null) return "-";
|
|
130
|
+
try {
|
|
131
|
+
return new Intl.NumberFormat(undefined, { style: "currency", currency: currency || "USD" }).format(amount);
|
|
132
|
+
} catch {
|
|
133
|
+
return `${amount.toFixed(2)} ${currency ?? ""}`.trim();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Metrics rows are grouped by (sync_config_id, platform, platform_campaign_id) and one sync can carry several campaigns, so join on the
|
|
139
|
+
* platform campaign id whenever the campaign has one; only a campaign without it (not launched yet) falls back to the sync.
|
|
140
|
+
*/
|
|
141
|
+
export function metricsFor(campaign: Pick<AdCampaign, "sync_config_id" | "platform_campaign_id">, metrics: CampaignMetric[]): CampaignMetric | undefined {
|
|
142
|
+
const rows = campaign.platform_campaign_id
|
|
143
|
+
? metrics.filter((m) => m.sync_config_id === campaign.sync_config_id && m.platform_campaign_id === campaign.platform_campaign_id)
|
|
144
|
+
: metrics.filter((m) => m.sync_config_id === campaign.sync_config_id && !m.platform_campaign_id);
|
|
145
|
+
if (rows.length === 0) return undefined;
|
|
146
|
+
return rows.reduce((a, m) => ({ ...a, impressions: a.impressions + m.impressions, clicks: a.clicks + m.clicks, spend: a.spend + m.spend }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Pick the array out of whichever envelope the endpoint used. */
|
|
150
|
+
export function listOf<T>(data: unknown, key = "items"): T[] {
|
|
151
|
+
if (Array.isArray(data)) return data as T[];
|
|
152
|
+
const v = (data as Record<string, unknown> | null | undefined)?.[key];
|
|
153
|
+
return Array.isArray(v) ? (v as T[]) : [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface LaunchOutcome { ok: boolean; message?: string }
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Launch and retry answer a refused request with HTTP 200 and `status: "error"` in the body (the platform rejected it, the campaign or
|
|
160
|
+
* sync was not found, no audience yet). Read the body: a 200 alone is not a success. "partial" launched but needs a status check.
|
|
161
|
+
*/
|
|
162
|
+
export function launchOutcome(data: unknown): LaunchOutcome {
|
|
163
|
+
const d = (data ?? {}) as { status?: string; message?: string; reason?: string };
|
|
164
|
+
if (d.status === "error" || d.status === "failed" || d.status === "ineligible") return { ok: false, message: d.message || d.reason || `The platform did not accept the campaign (${d.status}).` };
|
|
165
|
+
if (d.status === "partial") return { ok: true, message: "Launched, but part of it was rejected. Refresh the status and review the ads." };
|
|
166
|
+
return { ok: true };
|
|
167
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Generate an ad creative, wait for it, tweak the copy, move it to the gallery
|
|
3
|
+
* @when Producing ad images and copy before a launch. Generation is BILLABLE and has no advance price: get approval.
|
|
4
|
+
* Order: specs (platforms, formats, CTAs) -> create (201, status "generating") -> poll the status until it settles ->
|
|
5
|
+
* edit the copy -> move to the gallery (needs the ads launch permission). Formats are per platform: read them from the specs.
|
|
6
|
+
*/
|
|
7
|
+
import { g8 } from "@graph8/sdk";
|
|
8
|
+
|
|
9
|
+
g8.init({ apiKey: process.env.G8_API_KEY! });
|
|
10
|
+
|
|
11
|
+
export async function formats(platform: string) {
|
|
12
|
+
const res = await g8.api.adCreatives.getAdCreativeSpecs();
|
|
13
|
+
const specs = (res.data as { specs: Record<string, Record<string, { label: string; aspect_ratio: string }>> }).specs;
|
|
14
|
+
return specs[platform] ?? {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Billable. */
|
|
18
|
+
export async function create(platform: string, adFormat: string, prompt: string) {
|
|
19
|
+
const res = await g8.api.adCreatives.createAdCreative({ body: { platform, ad_format: adFormat, prompt } });
|
|
20
|
+
return (res.data as { id: string }).id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function waitForCreative(creativeId: string) {
|
|
24
|
+
let delay = 4000;
|
|
25
|
+
for (let i = 0; i < 60; i++) {
|
|
26
|
+
const res = await g8.api.adCreatives.getAdCreativeStatus({ path: { creative_id: creativeId } });
|
|
27
|
+
const s = res.data as { status: string; error_message?: string };
|
|
28
|
+
if (s.status !== "generating") return s;
|
|
29
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
30
|
+
delay = Math.min(20000, Math.round(delay * 1.3));
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`creative ${creativeId} did not finish generating in time`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function editCopy(creativeId: string, headline: string, primaryText: string) {
|
|
36
|
+
return g8.api.adCreatives.updateAdCreative({ path: { creative_id: creativeId }, body: { headline, primary_text: primaryText } });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function toGallery(creativeId: string) {
|
|
40
|
+
return g8.api.adCreatives.moveAdCreativeToGallery({ path: { creative_id: creativeId } });
|
|
41
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Draft a social post with AI, then schedule or publish it
|
|
3
|
+
* @when Posting to a connected LinkedIn account. The account must already be connected in graph8 (sign-in cannot be done
|
|
4
|
+
* through the API); read `listSocialAccounts` first and use an account's `id` as `account_ref`. Generating drafts is
|
|
5
|
+
* BILLABLE and saves nothing; creating makes a DRAFT; scheduling and publishing are EXTERNAL (real audiences): get a
|
|
6
|
+
* human to approve the account and the time. `scheduled_at` is an instant: convert the wall-clock time the person
|
|
7
|
+
* gave in THEIR zone with `new Date("2026-10-20T09:00").toISOString()` (runs in that zone) or pass an explicit offset.
|
|
8
|
+
* Keep the created post id so a retry after a failure schedules the same post instead of creating a second.
|
|
9
|
+
*/
|
|
10
|
+
import { g8 } from "@graph8/sdk";
|
|
11
|
+
|
|
12
|
+
g8.init({ apiKey: process.env.G8_API_KEY! });
|
|
13
|
+
|
|
14
|
+
export async function accounts() {
|
|
15
|
+
const res = await g8.api.socialPublishing.listSocialAccounts();
|
|
16
|
+
return (res.data as { items: Array<{ id: string; display_name?: string; provider: string; capacity: { publish_used: number; publish_limit: number } }> }).items;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Billable. Nothing is saved; pick a variant and create the post from it. */
|
|
20
|
+
export async function draftWithAi(instructions: string) {
|
|
21
|
+
const res = await g8.api.socialPublishing.generateSocialPost({ body: { instructions, variants: 3 } });
|
|
22
|
+
return (res.data as { variants: Array<{ frame: string; body: string }>; credits_used: number }).variants;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function createDraft(accountRef: string, body: string, idempotencyKey: string) {
|
|
26
|
+
const res = await g8.api.socialPublishing.createSocialPost({ body: { body, account_ref: accountRef, post_type: "text", idempotency_key: idempotencyKey } });
|
|
27
|
+
return (res.data as { id: string }).id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** External. Only after a person approved this account and this instant. */
|
|
31
|
+
export async function schedule(postId: string, scheduledAtIso: string) {
|
|
32
|
+
return g8.api.socialPublishing.scheduleSocialPost({ path: { post_id: postId }, body: { scheduled_at: scheduledAtIso } });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** External, immediate. */
|
|
36
|
+
export async function publishNow(postId: string) {
|
|
37
|
+
return g8.api.socialPublishing.publishSocialPost({ path: { post_id: postId } });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Statuses: draft, ready, scheduled, publishing, live, failed, cancelled. Poll after publishing. */
|
|
41
|
+
export async function waitUntilLive(postId: string) {
|
|
42
|
+
let delay = 3000;
|
|
43
|
+
for (let i = 0; i < 40; i++) {
|
|
44
|
+
const res = await g8.api.socialPublishing.getSocialPost({ path: { post_id: postId } });
|
|
45
|
+
const post = res.data as { status: string; external_post_url?: string; failure_reason?: string };
|
|
46
|
+
if (["live", "failed", "cancelled"].includes(post.status)) return post;
|
|
47
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
48
|
+
delay = Math.min(20000, Math.round(delay * 1.4));
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`post ${postId} did not finish publishing in time`);
|
|
51
|
+
}
|