@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,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CustomerTable: the Customers stage as a book of business: headline counts, MRR per account, and an
|
|
5
|
+
* account brief (what the team knows about the customer) that can be rewritten with AI.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in the order the screen uses them:
|
|
8
|
+
* 1. getRevenueSummary GET /accounts/revenue-summary (read; bucket counts)
|
|
9
|
+
* 2. listLifecycleStages GET /lifecycle-stages (read; resolves "customers")
|
|
10
|
+
* 3. listLifecycleStageRecords GET /lifecycle-stages/customers/records?page&limit (read; name, MRR, subscription)
|
|
11
|
+
* 4. getBrief GET /customer-success/companies/{company_id}/brief (read; free)
|
|
12
|
+
* 5. synthesizeBrief POST /customer-success/companies/{company_id}/brief/synthesize (billable: runs a model)
|
|
13
|
+
*
|
|
14
|
+
* MRR on this screen is the sum of `mrr_cents` over the rows on the current page, not the whole book; the API
|
|
15
|
+
* has no total MRR field on this route, so the figure is labelled "this page".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import { Button, Card, CardContent, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
20
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
21
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
23
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
24
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
25
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
26
|
+
import { asRec, body, money, text } from "../lib/lifecycle";
|
|
27
|
+
import type { CompanyRecord } from "../lib/lifecycle";
|
|
28
|
+
|
|
29
|
+
export interface CustomerTableProps {
|
|
30
|
+
/** Stage slug to list. Defaults to "customers". */
|
|
31
|
+
stageSlug?: string;
|
|
32
|
+
onOpenCompany?: (companyId: number) => void;
|
|
33
|
+
pageSize?: number;
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Summary() {
|
|
38
|
+
const q = useG8Query((signal) => g8Get("/accounts/revenue-summary", undefined, { signal }), []);
|
|
39
|
+
const tiles = Object.entries(asRec(body(q.data))).filter(([, v]) => typeof v === "number") as Array<[string, number]>;
|
|
40
|
+
if (q.error || tiles.length === 0) return null;
|
|
41
|
+
return (
|
|
42
|
+
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4" data-testid="revenue-summary">
|
|
43
|
+
{tiles.map(([key, value]) => (
|
|
44
|
+
<Card key={key}><CardContent className="p-4"><div className="text-2xl font-semibold">{value.toLocaleString()}</div><div className="text-muted-foreground text-xs capitalize">{key.replace(/_count$/, "").replace(/_/g, " ")}</div></CardContent></Card>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function BriefSheet({ company, onClose }: { company: CompanyRecord; onClose: () => void }) {
|
|
51
|
+
const base = `/customer-success/companies/${company.company_id}/brief`;
|
|
52
|
+
const brief = useG8Query((signal) => g8Get(base, undefined, { signal }), [company.company_id]);
|
|
53
|
+
const [confirm, setConfirm] = useState(false);
|
|
54
|
+
const write = useG8Mutation(() => g8Post(`${base}/synthesize`), { onSuccess: () => { setConfirm(false); void brief.refetch(); } });
|
|
55
|
+
const b = asRec(body(brief.data));
|
|
56
|
+
return (
|
|
57
|
+
<Sheet open onOpenChange={(open) => { if (!open) onClose(); }}>
|
|
58
|
+
<SheetContent className="w-full overflow-y-auto sm:max-w-lg">
|
|
59
|
+
<SheetHeader>
|
|
60
|
+
<SheetTitle>{company.name ?? `Company ${company.company_id}`}</SheetTitle>
|
|
61
|
+
<SheetDescription>{[company.domain, money(company.mrr_cents) && `${money(company.mrr_cents)} MRR`].filter(Boolean).join(" · ")}</SheetDescription>
|
|
62
|
+
</SheetHeader>
|
|
63
|
+
<div className="space-y-3 px-4 pb-4 text-sm">
|
|
64
|
+
{brief.error ? <ApiError error={brief.error} onRetry={() => void brief.refetch()} /> : null}
|
|
65
|
+
{brief.loading && !brief.data ? <Skeleton className="h-24 w-full" /> : b.summary ? <p className="whitespace-pre-wrap" data-testid="brief-summary">{text(b.summary)}</p> : <p className="text-muted-foreground">No brief written yet.</p>}
|
|
66
|
+
{b.synthesized_at ? <p className="text-muted-foreground text-xs">Written {new Date(text(b.synthesized_at)).toLocaleDateString()}</p> : null}
|
|
67
|
+
{write.error ? <ApiError error={write.error} title="Could not write the brief" /> : null}
|
|
68
|
+
<Button variant="outline" onClick={() => setConfirm(true)}>{b.summary ? "Rewrite brief" : "Write brief"}</Button>
|
|
69
|
+
</div>
|
|
70
|
+
{confirm ? (
|
|
71
|
+
<ConfirmSpendDialog open tier="billable" title={`Write a brief for ${company.name ?? "this customer"}`} confirmLabel="Write brief" confirming={write.loading}
|
|
72
|
+
description="Reads everything known about the customer and writes a new brief with an AI model. This spends credits." onConfirm={() => void write.mutate(undefined)} onCancel={() => setConfirm(false)} />
|
|
73
|
+
) : null}
|
|
74
|
+
</SheetContent>
|
|
75
|
+
</Sheet>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function CustomerTable({ stageSlug = "customers", onOpenCompany, pageSize = 25, className }: CustomerTableProps) {
|
|
80
|
+
const [page, setPage] = useState(1);
|
|
81
|
+
const [open, setOpen] = useState<CompanyRecord | null>(null);
|
|
82
|
+
const list = useG8Query((signal) => g8Get(`/lifecycle-stages/${encodeURIComponent(stageSlug)}/records`, { page, limit: pageSize }, { signal }), [stageSlug, page, pageSize]);
|
|
83
|
+
const rows = (Array.isArray(body(list.data)) ? body(list.data) : []) as CompanyRecord[];
|
|
84
|
+
const pagination = asRec(asRec(list.data).pagination);
|
|
85
|
+
const total = typeof pagination.total === "number" ? pagination.total : rows.length;
|
|
86
|
+
const pageMrr = rows.reduce((sum, r) => sum + (r.mrr_cents ?? 0), 0);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className={className}>
|
|
90
|
+
<Summary />
|
|
91
|
+
{list.error ? <ApiError error={list.error} onRetry={() => void list.refetch()} /> : null}
|
|
92
|
+
{list.loading && !list.data ? <div className="space-y-2" aria-busy="true">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-10 w-full" />)}</div> : !list.error && rows.length === 0 ? (
|
|
93
|
+
<EmptyState title="No customers yet" description="Companies with an active subscription appear here." />
|
|
94
|
+
) : (
|
|
95
|
+
<Table>
|
|
96
|
+
<TableHeader><TableRow><TableHead>Customer</TableHead><TableHead>Subscription</TableHead><TableHead className="text-right">MRR</TableHead><TableHead className="text-right">Actions</TableHead></TableRow></TableHeader>
|
|
97
|
+
<TableBody>
|
|
98
|
+
{rows.map((r) => (
|
|
99
|
+
<TableRow key={r.company_id}>
|
|
100
|
+
<TableCell><button type="button" className="font-medium hover:underline" onClick={() => onOpenCompany?.(r.company_id)}>{r.name || r.domain || `Company ${r.company_id}`}</button><div className="text-muted-foreground text-xs">{r.domain}</div></TableCell>
|
|
101
|
+
<TableCell className="capitalize">{text(r.subscription_status).replace(/_/g, " ")}</TableCell>
|
|
102
|
+
<TableCell className="text-right">{money(r.mrr_cents)}</TableCell>
|
|
103
|
+
<TableCell className="text-right"><Button size="sm" variant="outline" aria-label={`Brief for ${r.name ?? r.company_id}`} onClick={() => setOpen(r)}>Brief</Button></TableCell>
|
|
104
|
+
</TableRow>
|
|
105
|
+
))}
|
|
106
|
+
</TableBody>
|
|
107
|
+
</Table>
|
|
108
|
+
)}
|
|
109
|
+
<div className="mt-3 flex items-center justify-between text-sm">
|
|
110
|
+
<span className="text-muted-foreground">{total.toLocaleString()} customers · {money(pageMrr)} MRR this page</span>
|
|
111
|
+
<div className="flex gap-2">
|
|
112
|
+
<Button size="sm" variant="outline" disabled={page === 1} onClick={() => setPage(page - 1)}>Previous</Button>
|
|
113
|
+
<Button size="sm" variant="outline" disabled={page * pageSize >= total} onClick={() => setPage(page + 1)}>Next</Button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
{open ? <BriefSheet company={open} onClose={() => setOpen(null)} /> : null}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LeadQueue: the contacts in one lifecycle stage (Leads by default) as a work queue, with search, unlock and move.
|
|
5
|
+
*
|
|
6
|
+
* Emails and phones of contacts you have not unlocked arrive masked as "***". Unlocking charges credits per
|
|
7
|
+
* NEWLY unlocked contact (already unlocked ones are free), so it goes through ConfirmSpendDialog; the credits
|
|
8
|
+
* actually charged are reported after the call because the API has no price quote or balance read.
|
|
9
|
+
*
|
|
10
|
+
* SDK operations, in the order the screen uses them:
|
|
11
|
+
* 1. listLifecycleStages GET /lifecycle-stages (read; resolves the stage's entry id)
|
|
12
|
+
* 2. listLifecycleContacts GET /lifecycle-contacts?lifecycle_id&search&skip&limit (read; masked PII)
|
|
13
|
+
* 3. unlockLifecycleContacts POST /lifecycle-contacts/unlock { lifecycle_id, contact_ids } (billable)
|
|
14
|
+
* 4. moveContactLifecycleStage POST /contacts/{id}/lifecycle-stage (write; via StageMoveDialog)
|
|
15
|
+
*
|
|
16
|
+
* `lifecycle_id` is the stage's ENTRY id (`id` in step 1), not its slug.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useState } from "react";
|
|
20
|
+
import { Button, Input, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
21
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
22
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
23
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
24
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
25
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
26
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
27
|
+
import { useDebounced } from "../hooks/use-debounced";
|
|
28
|
+
import { useLifecycleStages } from "../hooks/use-lifecycle-stages";
|
|
29
|
+
import { asRec, body, contactName, findStage, isMasked, text } from "../lib/lifecycle";
|
|
30
|
+
import type { ContactRecord } from "../lib/lifecycle";
|
|
31
|
+
import { StageMoveDialog } from "./stage-move-dialog";
|
|
32
|
+
|
|
33
|
+
export interface LeadQueueProps {
|
|
34
|
+
/** Stage slug to list. Defaults to "leads". */
|
|
35
|
+
stageSlug?: string;
|
|
36
|
+
onOpenContact?: (contactId: number) => void;
|
|
37
|
+
pageSize?: number;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const show = (value: unknown, masked = "Locked") => (isMasked(value) ? masked : text(value, ""));
|
|
42
|
+
|
|
43
|
+
export function LeadQueue({ stageSlug = "leads", onOpenContact, pageSize = 25, className }: LeadQueueProps) {
|
|
44
|
+
const { stages, loading: stagesLoading, error: stagesError } = useLifecycleStages();
|
|
45
|
+
const stage = findStage(stages, stageSlug);
|
|
46
|
+
const [search, setSearch] = useState("");
|
|
47
|
+
const debounced = useDebounced(search);
|
|
48
|
+
const [skip, setSkip] = useState(0);
|
|
49
|
+
const [picked, setPicked] = useState<number[]>([]);
|
|
50
|
+
const [confirm, setConfirm] = useState(false);
|
|
51
|
+
const [moving, setMoving] = useState<ContactRecord | null>(null);
|
|
52
|
+
const [charged, setCharged] = useState<string | null>(null);
|
|
53
|
+
|
|
54
|
+
const list = useG8Query(
|
|
55
|
+
(signal) => g8Get("/lifecycle-contacts", { lifecycle_id: stage?.id, search: debounced || undefined, skip, limit: pageSize }, { signal }),
|
|
56
|
+
[stage?.id, debounced, skip, pageSize],
|
|
57
|
+
{ enabled: Boolean(stage) },
|
|
58
|
+
);
|
|
59
|
+
const unlock = useG8Mutation(
|
|
60
|
+
async (ids: number[]) => asRec(body(await g8Post("/lifecycle-contacts/unlock", { lifecycle_id: stage?.id, contact_ids: ids }))),
|
|
61
|
+
{
|
|
62
|
+
onSuccess: (r) => {
|
|
63
|
+
setConfirm(false);
|
|
64
|
+
setPicked([]);
|
|
65
|
+
setCharged(`Unlocked ${text(r.contact_count, "0")} contacts and charged ${text(r.credits_charged, "0")} credits.`);
|
|
66
|
+
void list.refetch();
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const data = asRec(body(list.data));
|
|
72
|
+
const rows = (Array.isArray(data.contacts) ? data.contacts : []) as ContactRecord[];
|
|
73
|
+
const total = typeof data.total === "number" ? data.total : rows.length;
|
|
74
|
+
// Selection only ever holds contacts that are on screen: it is pruned when the page, search or stage changes.
|
|
75
|
+
const selected = picked.filter((id) => rows.some((r) => r.id === id));
|
|
76
|
+
const toggle = (id: number) => setPicked(picked.includes(id) ? picked.filter((p) => p !== id) : [...picked, id]);
|
|
77
|
+
|
|
78
|
+
if (stagesLoading) return <div className={className} aria-busy="true"><Skeleton className="h-32 w-full" /></div>;
|
|
79
|
+
if (stagesError) return <ApiError error={stagesError} />;
|
|
80
|
+
if (!stage) return <EmptyState title={`No "${stageSlug}" stage`} description="Check the stage slug against GET /lifecycle-stages." />;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className={className}>
|
|
84
|
+
<div className="mb-3 flex flex-wrap items-center gap-2">
|
|
85
|
+
<Input aria-label="Search contacts" placeholder={`Search ${stage.name.toLowerCase()}`} value={search} onChange={(e) => { setSearch(e.target.value); setSkip(0); setPicked([]); setCharged(null); }} className="w-64" />
|
|
86
|
+
<Button className="ml-auto" disabled={selected.length === 0} onClick={() => setConfirm(true)}>Unlock selected ({selected.length})</Button>
|
|
87
|
+
</div>
|
|
88
|
+
{charged ? <p className="mb-2 text-sm" role="status">{charged}</p> : null}
|
|
89
|
+
{list.error ? <ApiError error={list.error} onRetry={() => void list.refetch()} /> : null}
|
|
90
|
+
{unlock.error ? <ApiError error={unlock.error} title="Could not unlock those contacts" /> : null}
|
|
91
|
+
{list.loading && !list.data ? <div className="space-y-2" aria-busy="true">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-10 w-full" />)}</div> : !list.error && rows.length === 0 ? (
|
|
92
|
+
<EmptyState title={`No contacts in ${stage.name}`} description="Contacts appear here as they enter this stage." />
|
|
93
|
+
) : (
|
|
94
|
+
<Table>
|
|
95
|
+
<TableHeader><TableRow><TableHead /><TableHead>Name</TableHead><TableHead>Title</TableHead><TableHead>Email</TableHead><TableHead className="text-right">Actions</TableHead></TableRow></TableHeader>
|
|
96
|
+
<TableBody>
|
|
97
|
+
{rows.map((c) => (
|
|
98
|
+
<TableRow key={c.id}>
|
|
99
|
+
<TableCell><input type="checkbox" aria-label={`Select ${contactName(c)}`} checked={picked.includes(c.id)} onChange={() => toggle(c.id)} /></TableCell>
|
|
100
|
+
<TableCell><button type="button" className="font-medium hover:underline" onClick={() => onOpenContact?.(c.id)}>{contactName(c)}</button><div className="text-muted-foreground text-xs">{text(c.company_name)}</div></TableCell>
|
|
101
|
+
<TableCell>{text(c.title)}</TableCell>
|
|
102
|
+
<TableCell>{show(c.email) || "No email"}</TableCell>
|
|
103
|
+
<TableCell className="text-right"><Button size="sm" variant="ghost" aria-label={`Move ${contactName(c)}`} onClick={() => setMoving(c)}>Move</Button></TableCell>
|
|
104
|
+
</TableRow>
|
|
105
|
+
))}
|
|
106
|
+
</TableBody>
|
|
107
|
+
</Table>
|
|
108
|
+
)}
|
|
109
|
+
<div className="mt-3 flex items-center justify-between text-sm">
|
|
110
|
+
<span className="text-muted-foreground">{total.toLocaleString()}{data.total_is_estimate ? "+" : ""} contacts</span>
|
|
111
|
+
<div className="flex gap-2">
|
|
112
|
+
<Button size="sm" variant="outline" disabled={skip === 0} onClick={() => { setSkip(Math.max(0, skip - pageSize)); setPicked([]); setCharged(null); }}>Previous</Button>
|
|
113
|
+
<Button size="sm" variant="outline" disabled={skip + pageSize >= total && !data.total_is_estimate} onClick={() => { setSkip(skip + pageSize); setPicked([]); setCharged(null); }}>Next</Button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
{confirm ? (
|
|
117
|
+
<ConfirmSpendDialog open tier="billable" title={`Unlock ${selected.length} contacts`} confirmLabel="Unlock" confirming={unlock.loading}
|
|
118
|
+
description="Charges credits for each contact that is not unlocked yet. Contacts you already unlocked cost nothing."
|
|
119
|
+
onConfirm={() => void unlock.mutate(selected)} onCancel={() => setConfirm(false)} />
|
|
120
|
+
) : null}
|
|
121
|
+
{moving ? (
|
|
122
|
+
<StageMoveDialog kind="contact" recordId={moving.id} recordName={contactName(moving)} currentSlug={stage.slug} stages={stages}
|
|
123
|
+
onClose={() => setMoving(null)} onMoved={() => { setPicked(picked.filter((id) => id !== moving.id)); setMoving(null); void list.refetch(); }} />
|
|
124
|
+
) : null}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LifecycleBoard: one column per lifecycle stage (Suspects, Leads, Trial, Customers, Churned, plus custom ones)
|
|
5
|
+
* with the companies in each, counts in the headers, and a Move action on every card.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in the order the screen uses them:
|
|
8
|
+
* 1. listLifecycleStages GET /lifecycle-stages (read; columns, in display order)
|
|
9
|
+
* 2. getLifecycleStageCounts GET /lifecycle-stages/counts (read; keyed by stage ENTRY ID)
|
|
10
|
+
* 3. listLifecycleStageRecords GET /lifecycle-stages/{slug}/records?page&limit (read; one call per column)
|
|
11
|
+
* 4. moveCompanyLifecycleStage POST /companies/{company_id}/lifecycle-stage (write; via StageMoveDialog, dry run first)
|
|
12
|
+
*
|
|
13
|
+
* Nothing here is billable, external or destructive. The `company_id` on each record is the id to pass to your
|
|
14
|
+
* company detail route (`onOpenCompany`) and to the move call; it is `mashup_companies.id`, the same id
|
|
15
|
+
* `GET /companies/{id}` takes.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@graph8/sdk/ui";
|
|
20
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
21
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
23
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
24
|
+
import { useLifecycleStages } from "../hooks/use-lifecycle-stages";
|
|
25
|
+
import { asRec, body, money } from "../lib/lifecycle";
|
|
26
|
+
import type { CompanyRecord, LifecycleStage } from "../lib/lifecycle";
|
|
27
|
+
import { StageMoveDialog } from "./stage-move-dialog";
|
|
28
|
+
|
|
29
|
+
export interface LifecycleBoardProps {
|
|
30
|
+
/** Open a company in your detail route (CompanyDetail from the crm block, for example). */
|
|
31
|
+
onOpenCompany?: (companyId: number) => void;
|
|
32
|
+
/** Cards loaded per column; "Show more" adds this many (API maximum 200 per column). */
|
|
33
|
+
pageSize?: number;
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ColumnProps {
|
|
38
|
+
stage: LifecycleStage;
|
|
39
|
+
count?: number;
|
|
40
|
+
pageSize: number;
|
|
41
|
+
version: number;
|
|
42
|
+
onOpenCompany?: (id: number) => void;
|
|
43
|
+
onMove: (record: CompanyRecord, stage: LifecycleStage) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Column({ stage, count, pageSize, version, onOpenCompany, onMove }: ColumnProps) {
|
|
47
|
+
const [limit, setLimit] = useState(pageSize);
|
|
48
|
+
const q = useG8Query(
|
|
49
|
+
(signal) => g8Get(`/lifecycle-stages/${encodeURIComponent(stage.slug)}/records`, { page: 1, limit }, { signal }),
|
|
50
|
+
[stage.slug, limit, version],
|
|
51
|
+
);
|
|
52
|
+
const rows = (Array.isArray(body(q.data)) ? body(q.data) : []) as CompanyRecord[];
|
|
53
|
+
const total = (asRec(asRec(q.data).pagination).total as number | undefined) ?? count ?? rows.length;
|
|
54
|
+
return (
|
|
55
|
+
<Card className="w-72 shrink-0" data-testid={`column-${stage.slug}`}>
|
|
56
|
+
<CardHeader>
|
|
57
|
+
<CardTitle className="text-sm">{stage.name} <span className="text-muted-foreground font-normal">{count ?? total}</span></CardTitle>
|
|
58
|
+
</CardHeader>
|
|
59
|
+
<CardContent className="space-y-2">
|
|
60
|
+
{q.error ? <ApiError error={q.error} onRetry={() => void q.refetch()} /> : null}
|
|
61
|
+
{q.loading && !q.data ? <><Skeleton className="h-14 w-full" /><Skeleton className="h-14 w-full" /></> : null}
|
|
62
|
+
{!q.loading && !q.error && rows.length === 0 ? <EmptyState title="Nobody here" description={stage.description ?? undefined} className="py-4" /> : null}
|
|
63
|
+
{rows.map((r) => (
|
|
64
|
+
<div key={r.company_id} className="rounded-md border p-2 text-sm">
|
|
65
|
+
<button type="button" className="text-left font-medium hover:underline" onClick={() => onOpenCompany?.(r.company_id)}>{r.name || r.domain || `Company ${r.company_id}`}</button>
|
|
66
|
+
<div className="text-muted-foreground text-xs">{[r.domain, r.industry].filter(Boolean).join(" · ")}</div>
|
|
67
|
+
<div className="mt-1 flex items-center justify-between">
|
|
68
|
+
<span className="text-xs">{money(r.mrr_cents)}</span>
|
|
69
|
+
<Button size="sm" variant="ghost" aria-label={`Move ${r.name ?? r.company_id}`} onClick={() => onMove(r, stage)}>Move</Button>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
{rows.length < total && limit < 200 ? <Button size="sm" variant="outline" className="w-full" loading={q.loading} onClick={() => setLimit(Math.min(200, limit + pageSize))}>Show more</Button> : null}
|
|
74
|
+
</CardContent>
|
|
75
|
+
</Card>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function LifecycleBoard({ onOpenCompany, pageSize = 25, className }: LifecycleBoardProps) {
|
|
80
|
+
const { stages, countOf, loading, error, refetch } = useLifecycleStages();
|
|
81
|
+
const [moving, setMoving] = useState<{ record: CompanyRecord; stage: LifecycleStage } | null>(null);
|
|
82
|
+
const [version, setVersion] = useState(0);
|
|
83
|
+
|
|
84
|
+
if (loading) return <div className={className} aria-busy="true"><Skeleton className="h-48 w-full" /></div>;
|
|
85
|
+
if (error) return <ApiError error={error} onRetry={() => void refetch()} />;
|
|
86
|
+
if (stages.length === 0) return <EmptyState title="No lifecycle stages" description="Stages are configured in graph8 under Revenue settings." />;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className={className}>
|
|
90
|
+
<div className="flex gap-3 overflow-x-auto pb-2">
|
|
91
|
+
{stages.map((s) => (
|
|
92
|
+
<Column key={s.id} stage={s} count={countOf(s)} pageSize={pageSize} version={version} onOpenCompany={onOpenCompany} onMove={(record, stage) => setMoving({ record, stage })} />
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
{moving ? (
|
|
96
|
+
<StageMoveDialog kind="company" recordId={moving.record.company_id} recordName={moving.record.name ?? `company ${moving.record.company_id}`}
|
|
97
|
+
currentSlug={moving.stage.slug} stages={stages} onClose={() => setMoving(null)}
|
|
98
|
+
onMoved={() => { setMoving(null); setVersion((v) => v + 1); void refetch(); }} />
|
|
99
|
+
) : null}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* StageMoveDialog: move one company or contact to another lifecycle stage.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in the order the dialog uses them:
|
|
7
|
+
* 1. moveCompanyLifecycleStage POST /companies/{id}/lifecycle-stage { target_slug, dry_run: true } (write; the check)
|
|
8
|
+
* 2. moveCompanyLifecycleStage POST /companies/{id}/lifecycle-stage { target_slug } (write; the move)
|
|
9
|
+
* or moveContactLifecycleStage POST /contacts/{id}/lifecycle-stage { target_slug } (write; no dry run)
|
|
10
|
+
*
|
|
11
|
+
* Moves are plain writes (reversible by moving back), so there is no spend confirmation. Companies get a dry run
|
|
12
|
+
* first because the API refuses some moves: to Customers without an active subscription, and to Churned for a
|
|
13
|
+
* company that never was a customer. The refusal is shown as-is instead of failing after the person clicked Move.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@graph8/sdk/ui";
|
|
18
|
+
import { g8Post } from "@/g8/lib/g8-client";
|
|
19
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
20
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
21
|
+
import { body, describeMove } from "../lib/lifecycle";
|
|
22
|
+
import type { LifecycleStage, MoveResult } from "../lib/lifecycle";
|
|
23
|
+
|
|
24
|
+
export interface StageMoveDialogProps {
|
|
25
|
+
kind: "company" | "contact";
|
|
26
|
+
/** `company_id` of a stage record, or the contact id. */
|
|
27
|
+
recordId: number;
|
|
28
|
+
recordName: string;
|
|
29
|
+
/** Slug the record is in now; hidden from the choices. */
|
|
30
|
+
currentSlug?: string;
|
|
31
|
+
stages: LifecycleStage[];
|
|
32
|
+
/** Pre-select a destination (for example "leads" for a win-back). */
|
|
33
|
+
defaultTargetSlug?: string;
|
|
34
|
+
onMoved: (result: MoveResult) => void;
|
|
35
|
+
onClose: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function StageMoveDialog(props: StageMoveDialogProps) {
|
|
39
|
+
const { kind, recordId, recordName, currentSlug, stages, onMoved, onClose } = props;
|
|
40
|
+
const choices = stages.filter((s) => s.slug !== currentSlug);
|
|
41
|
+
const [target, setTarget] = useState(props.defaultTargetSlug ?? choices[0]?.slug ?? "");
|
|
42
|
+
const [checked, setChecked] = useState<{ slug: string; result: MoveResult } | null>(null);
|
|
43
|
+
const path = `/${kind === "company" ? "companies" : "contacts"}/${recordId}/lifecycle-stage`;
|
|
44
|
+
|
|
45
|
+
const check = useG8Mutation(async (slug: string) => body(await g8Post(path, { target_slug: slug, dry_run: true })) as MoveResult, { onSuccess: (r, slug) => setChecked({ slug, result: r }) });
|
|
46
|
+
const move = useG8Mutation(async (slug: string) => body(await g8Post(path, { target_slug: slug })) as MoveResult, { onSuccess: (r, slug) => onMoved({ ...r, target_slug: r.target_slug ?? slug }) });
|
|
47
|
+
const error = check.error ?? move.error;
|
|
48
|
+
const needsCheck = kind === "company" && checked?.slug !== target;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
|
52
|
+
<DialogContent>
|
|
53
|
+
<DialogHeader>
|
|
54
|
+
<DialogTitle>Move {recordName}</DialogTitle>
|
|
55
|
+
<DialogDescription>Choose the stage this {kind} belongs in now.</DialogDescription>
|
|
56
|
+
</DialogHeader>
|
|
57
|
+
<label htmlFor="move-target" className="text-sm font-medium">New stage</label>
|
|
58
|
+
<select id="move-target" value={target} onChange={(e) => { setTarget(e.target.value); setChecked(null); check.reset(); move.reset(); }}
|
|
59
|
+
className="border-input bg-background h-9 w-full rounded-md border px-2 text-sm">
|
|
60
|
+
{choices.map((s) => <option key={s.id} value={s.slug}>{s.name}</option>)}
|
|
61
|
+
</select>
|
|
62
|
+
{checked && !needsCheck ? <p className="text-sm" data-testid="move-preview">This will change: {describeMove(checked.result)}</p> : null}
|
|
63
|
+
{error ? <ApiError error={error} title="That move is not allowed" /> : null}
|
|
64
|
+
<DialogFooter>
|
|
65
|
+
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
|
66
|
+
{needsCheck ? (
|
|
67
|
+
<Button disabled={!target} loading={check.loading} onClick={() => void check.mutate(target)}>Check move</Button>
|
|
68
|
+
) : (
|
|
69
|
+
<Button disabled={!target} loading={move.loading} onClick={() => void move.mutate(target)}>Move</Button>
|
|
70
|
+
)}
|
|
71
|
+
</DialogFooter>
|
|
72
|
+
</DialogContent>
|
|
73
|
+
</Dialog>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/** The value, `ms` after it stopped changing. Search boxes use it so each keystroke is not a request. */
|
|
6
|
+
export function useDebounced<T>(value: T, ms = 300): T {
|
|
7
|
+
const [settled, setSettled] = useState(value);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const t = setTimeout(() => setSettled(value), ms);
|
|
10
|
+
return () => clearTimeout(t);
|
|
11
|
+
}, [value, ms]);
|
|
12
|
+
return settled;
|
|
13
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useLifecycleStages: the configured stages and their company counts.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order: listLifecycleStages (GET /lifecycle-stages, read), getLifecycleStageCounts
|
|
7
|
+
* (GET /lifecycle-stages/counts, read; keyed by stage id). Both are free; counts are company-only.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useMemo } from "react";
|
|
11
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
12
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
13
|
+
import { countFor, stagesOf } from "../lib/lifecycle";
|
|
14
|
+
import type { LifecycleStage } from "../lib/lifecycle";
|
|
15
|
+
|
|
16
|
+
export function useLifecycleStages() {
|
|
17
|
+
const stagesQ = useG8Query((signal) => g8Get("/lifecycle-stages", undefined, { signal }), []);
|
|
18
|
+
const countsQ = useG8Query((signal) => g8Get("/lifecycle-stages/counts", undefined, { signal }), []);
|
|
19
|
+
const stages = useMemo<LifecycleStage[]>(() => stagesOf(stagesQ.data), [stagesQ.data]);
|
|
20
|
+
return {
|
|
21
|
+
stages,
|
|
22
|
+
countOf: (stage: LifecycleStage) => countFor(countsQ.data, stage),
|
|
23
|
+
loading: stagesQ.loading && !stagesQ.data,
|
|
24
|
+
error: stagesQ.error,
|
|
25
|
+
refetch: () => Promise.all([stagesQ.refetch(), countsQ.refetch()]),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Types and helpers for the revenue lifecycle (Suspects, Leads, Trial, Customers, Churned, plus any custom stage).
|
|
5
|
+
*
|
|
6
|
+
* Two things are easy to get wrong and are handled here:
|
|
7
|
+
* - `GET /lifecycle-stages/counts` is keyed by stage ENTRY ID, not slug; `countFor` resolves it.
|
|
8
|
+
* - Money is in cents (`mrr_cents`); `money` formats it and never shows a fake zero for an unknown value.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type Rec = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
export function asRec(value: unknown): Rec {
|
|
14
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Rec) : {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function text(value: unknown, fallback = ""): string {
|
|
18
|
+
return typeof value === "string" ? value : value == null ? fallback : String(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The body of a response: unwrap `{ data }` when present (most routes), otherwise the object itself. */
|
|
22
|
+
export function body(res: unknown): unknown {
|
|
23
|
+
const r = asRec(res);
|
|
24
|
+
return "data" in r ? r.data : res;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LifecycleStage {
|
|
28
|
+
id: string;
|
|
29
|
+
slug: string;
|
|
30
|
+
name: string;
|
|
31
|
+
sort_order: number;
|
|
32
|
+
hidden: boolean;
|
|
33
|
+
description?: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CompanyRecord {
|
|
37
|
+
company_id: number;
|
|
38
|
+
name?: string | null;
|
|
39
|
+
domain?: string | null;
|
|
40
|
+
industry?: string | null;
|
|
41
|
+
status?: string | null;
|
|
42
|
+
custom_status?: string | null;
|
|
43
|
+
subscription_status?: string | null;
|
|
44
|
+
owner_id?: string | null;
|
|
45
|
+
mrr_cents?: number | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ContactRecord {
|
|
49
|
+
id: number;
|
|
50
|
+
first_name?: string | null;
|
|
51
|
+
last_name?: string | null;
|
|
52
|
+
email?: string | null;
|
|
53
|
+
title?: string | null;
|
|
54
|
+
company_name?: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MoveResult {
|
|
58
|
+
target_slug: string;
|
|
59
|
+
previous_status?: string | null;
|
|
60
|
+
new_status?: string | null;
|
|
61
|
+
previous_custom_status?: string | null;
|
|
62
|
+
new_custom_status?: string | null;
|
|
63
|
+
dry_run?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function stagesOf(res: unknown): LifecycleStage[] {
|
|
67
|
+
const rows = body(res);
|
|
68
|
+
return (Array.isArray(rows) ? (rows as LifecycleStage[]) : []).filter((s) => !s.hidden).sort((a, b) => a.sort_order - b.sort_order);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function countFor(counts: unknown, stage: LifecycleStage): number | undefined {
|
|
72
|
+
const value = asRec(body(counts))[stage.id];
|
|
73
|
+
return typeof value === "number" ? value : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A stage by slug, falling back to a case-insensitive name match ("Trial" is slug `trials`). */
|
|
77
|
+
export function findStage(stages: LifecycleStage[], slugOrName: string): LifecycleStage | undefined {
|
|
78
|
+
const needle = slugOrName.trim().toLowerCase();
|
|
79
|
+
return stages.find((s) => s.slug.toLowerCase() === needle) ?? stages.find((s) => s.name.toLowerCase() === needle);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function money(cents: number | null | undefined, currency = "USD"): string {
|
|
83
|
+
if (typeof cents !== "number") return "";
|
|
84
|
+
return (cents / 100).toLocaleString(undefined, { style: "currency", currency, maximumFractionDigits: 0 });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function contactName(c: ContactRecord): string {
|
|
88
|
+
return [c.first_name, c.last_name].filter(Boolean).join(" ") || "Unnamed contact";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A masked field arrives as `"***"`; the real value needs an unlock (billable) or an enrichment. */
|
|
92
|
+
export const isMasked = (value: unknown): boolean => value === "***";
|
|
93
|
+
|
|
94
|
+
/** Describe a stage move for a confirmation line: "customers: status trial -> active". */
|
|
95
|
+
export function describeMove(m: MoveResult): string {
|
|
96
|
+
const from = m.previous_status ?? m.previous_custom_status;
|
|
97
|
+
const to = m.new_status ?? m.new_custom_status;
|
|
98
|
+
return from || to ? `${m.target_slug}: ${from ?? "none"} -> ${to ?? "none"}` : m.target_slug;
|
|
99
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Read a customer's brief, and rewrite it when it is stale
|
|
3
|
+
* @when Before a renewal or a check-in call. Reading is free. Rewriting runs an AI model over everything known about
|
|
4
|
+
* the customer and spends credits (billable tier), so only rewrite when the brief is missing or old and the person
|
|
5
|
+
* agreed. `company_id` is the id from a lifecycle stage record.
|
|
6
|
+
*/
|
|
7
|
+
import { g8 } from "@graph8/sdk";
|
|
8
|
+
|
|
9
|
+
g8.init({ apiKey: process.env.G8_API_KEY! });
|
|
10
|
+
|
|
11
|
+
type Brief = { summary?: string | null; synthesized_at?: string | null };
|
|
12
|
+
|
|
13
|
+
export async function customerBrief(companyId: number, rewriteIfOlderThanDays?: number): Promise<string> {
|
|
14
|
+
const read = async () => ((await g8.api.customerSuccess.getBrief({ path: { company_id: String(companyId) } })).data ?? {}) as Brief;
|
|
15
|
+
let brief = await read();
|
|
16
|
+
|
|
17
|
+
const ageDays = brief.synthesized_at ? (Date.now() - Date.parse(brief.synthesized_at)) / 86_400_000 : Infinity;
|
|
18
|
+
if (rewriteIfOlderThanDays !== undefined && ageDays > rewriteIfOlderThanDays) {
|
|
19
|
+
await g8.api.customerSuccess.synthesizeBrief({ path: { company_id: String(companyId) } });
|
|
20
|
+
brief = await read();
|
|
21
|
+
}
|
|
22
|
+
return brief.summary ?? "No brief yet";
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Move a company to another lifecycle stage, checking first
|
|
3
|
+
* @when A company should change stage (qualified lead, customer, churned, win-back). Always run the dry run first:
|
|
4
|
+
* the API refuses a move to Customers without an active subscription and a move to Churned for a company that never
|
|
5
|
+
* was a customer, and the dry run reports that without writing anything. The id is `company_id` from a stage record.
|
|
6
|
+
*/
|
|
7
|
+
import { g8 } from "@graph8/sdk";
|
|
8
|
+
|
|
9
|
+
g8.init({ apiKey: process.env.G8_API_KEY! });
|
|
10
|
+
|
|
11
|
+
export async function moveCompany(companyId: number, targetSlug: string): Promise<string> {
|
|
12
|
+
const check = await g8.api.revenueLifecycle.moveCompanyLifecycleStage({ path: { company_id: companyId }, body: { target_slug: targetSlug, dry_run: true } });
|
|
13
|
+
console.log(`would change: ${check.data.previous_status ?? "none"} -> ${check.data.new_status ?? "none"}`);
|
|
14
|
+
|
|
15
|
+
const done = await g8.api.revenueLifecycle.moveCompanyLifecycleStage({ path: { company_id: companyId }, body: { target_slug: targetSlug } });
|
|
16
|
+
return `${done.data.target_slug}: ${done.data.previous_status ?? "none"} -> ${done.data.new_status ?? "none"}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Read the lifecycle board: stages, counts and who is in each
|
|
3
|
+
* @when The customer wants to see (or report on) Suspects / Leads / Trial / Customers / Churned. All three calls are
|
|
4
|
+
* free. Counts are keyed by stage ENTRY ID, not slug, so join them to the stage list before showing a name.
|
|
5
|
+
*/
|
|
6
|
+
import { g8 } from "@graph8/sdk";
|
|
7
|
+
|
|
8
|
+
g8.init({ apiKey: process.env.G8_API_KEY! });
|
|
9
|
+
|
|
10
|
+
export async function readBoard(perStage = 25): Promise<Array<{ stage: string; count: number | undefined; companies: string[] }>> {
|
|
11
|
+
const stages = (await g8.api.revenueLifecycle.listLifecycleStages({})).data.filter((s) => !s.hidden);
|
|
12
|
+
const counts = (await g8.api.revenueLifecycle.getLifecycleStageCounts({})).data;
|
|
13
|
+
|
|
14
|
+
const board = [];
|
|
15
|
+
for (const stage of stages.sort((a, b) => a.sort_order - b.sort_order)) {
|
|
16
|
+
// The path takes the slug (or the entry id); `limit` is capped at 200 and pages are 1-indexed.
|
|
17
|
+
const records = await g8.api.revenueLifecycle.listLifecycleStageRecords({ path: { stage: stage.slug }, query: { page: 1, limit: perStage } });
|
|
18
|
+
board.push({ stage: stage.name, count: counts[stage.id], companies: records.data.map((r) => r.name ?? r.domain ?? String(r.company_id)) });
|
|
19
|
+
}
|
|
20
|
+
return board;
|
|
21
|
+
}
|