@graph8/sdk 0.370.0 → 0.373.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/blocks/_foundation/README.md +14 -0
- package/blocks/_foundation/block.json +44 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
- package/blocks/_foundation/components/empty-state.tsx +5 -2
- package/blocks/_foundation/components/list-select.tsx +112 -0
- package/blocks/_foundation/components/paging-notices.tsx +55 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +2 -1
- package/blocks/_foundation/components/screen/filter-controls.tsx +92 -0
- package/blocks/_foundation/components/screen/index.ts +1 -0
- package/blocks/_foundation/components/screen/split-view.tsx +1 -1
- package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
- package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
- package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
- package/blocks/_foundation/lib/paging.ts +57 -0
- package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
- package/blocks/agents/README.md +81 -4
- package/blocks/agents/block.json +311 -2
- package/blocks/agents/components/agent-activity.tsx +112 -0
- package/blocks/agents/components/agent-approvals.tsx +135 -0
- package/blocks/agents/components/agent-builder.tsx +17 -3
- package/blocks/agents/components/agent-conversations.tsx +143 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -2
- package/blocks/agents/components/agent-operators.tsx +164 -0
- package/blocks/agents/components/agent-run-detail.tsx +20 -3
- package/blocks/agents/components/agent-runs.tsx +3 -3
- package/blocks/agents/components/agent-skills-panel.tsx +7 -7
- package/blocks/agents/components/agent-test-history.tsx +121 -0
- package/blocks/agents/components/agents-screen.tsx +53 -11
- package/blocks/agents/components/assistant-sessions.tsx +150 -0
- package/blocks/agents/components/duplicate-agent-button.tsx +42 -0
- package/blocks/agents/components/from-template-button.tsx +65 -0
- package/blocks/agents/components/knowledge-browser.tsx +107 -0
- package/blocks/agents/components/knowledge-detail.tsx +156 -0
- package/blocks/agents/components/knowledge-form.tsx +69 -0
- package/blocks/agents/components/name-dialog.tsx +32 -0
- package/blocks/agents/components/receptionist-button.tsx +46 -0
- package/blocks/agents/components/save-as-template-button.tsx +32 -0
- package/blocks/agents/components/skill-editor.tsx +147 -0
- package/blocks/agents/components/skill-library.tsx +111 -0
- package/blocks/agents/lib/agent-types.ts +7 -1
- package/blocks/agents/lib/facts.tsx +13 -0
- package/blocks/agents/lib/knowledge-utils.ts +49 -0
- package/blocks/agents/lib/load-more.tsx +141 -0
- package/blocks/agents/lib/skill-utils.ts +91 -0
- package/blocks/agents/recipes/create-agent-and-attach-skill.ts +1 -1
- package/blocks/analytics/components/analytics-screen.tsx +16 -3
- package/blocks/analytics/components/kpi-tiles.tsx +1 -1
- package/blocks/analytics/components/report-builder.tsx +15 -2
- package/blocks/appointments/README.md +6 -0
- package/blocks/appointments/block.json +27 -0
- package/blocks/appointments/components/booking-page.tsx +2 -2
- package/blocks/appointments/components/meeting-contact-links.tsx +49 -0
- package/blocks/appointments/components/meeting-detail.tsx +2 -2
- package/blocks/appointments/components/meeting-list.tsx +29 -13
- package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
- package/blocks/campaigns/components/campaign-assets.tsx +1 -1
- package/blocks/campaigns/components/campaign-launch-step.tsx +16 -11
- package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
- package/blocks/campaigns/components/campaign-wizard.tsx +10 -5
- package/blocks/campaigns/components/icp-detail.tsx +11 -2
- package/blocks/campaigns/components/icp-editor.tsx +8 -3
- package/blocks/campaigns/components/persona-detail.tsx +11 -2
- package/blocks/campaigns/components/persona-editor.tsx +6 -1
- package/blocks/campaigns/lib/campaigns-utils.ts +4 -4
- package/blocks/coach/components/call-scorecard.tsx +7 -7
- package/blocks/company-intelligence/README.md +132 -0
- package/blocks/company-intelligence/block.json +318 -0
- package/blocks/company-intelligence/components/company-intelligence-screen.tsx +114 -0
- package/blocks/company-intelligence/components/company-intelligence-tab.tsx +88 -0
- package/blocks/company-intelligence/components/company-profile.tsx +68 -0
- package/blocks/company-intelligence/components/intelligence-sources.tsx +63 -0
- package/blocks/company-intelligence/components/report-detail.tsx +189 -0
- package/blocks/company-intelligence/components/research-reports.tsx +136 -0
- package/blocks/company-intelligence/components/research-run.tsx +161 -0
- package/blocks/company-intelligence/components/source-detail.tsx +117 -0
- package/blocks/company-intelligence/components/source-view.tsx +274 -0
- package/blocks/company-intelligence/components/wiki-document-detail.tsx +116 -0
- package/blocks/company-intelligence/components/wiki-documents.tsx +66 -0
- package/blocks/company-intelligence/hooks/use-intelligence.ts +173 -0
- package/blocks/company-intelligence/lib/intelligence-types.ts +230 -0
- package/blocks/company-intelligence/lib/safe-markdown.tsx +150 -0
- package/blocks/company-intelligence/recipes/read-competitors-and-news.ts +33 -0
- package/blocks/company-intelligence/recipes/research-your-website.ts +43 -0
- package/blocks/company-intelligence/recipes/write-the-research-reports.ts +54 -0
- package/blocks/copilot/components/copilot-memory.tsx +6 -2
- package/blocks/copilot/components/copilot-panel.tsx +1 -1
- package/blocks/copilot/components/copilot-team-chat.tsx +6 -1
- package/blocks/crm/README.md +6 -0
- package/blocks/crm/block.json +53 -4
- package/blocks/crm/components/companies-screen.tsx +23 -9
- package/blocks/crm/components/company-detail.tsx +2 -2
- package/blocks/crm/components/contact-detail.tsx +4 -4
- package/blocks/crm/components/contact-lookup-link.tsx +44 -0
- package/blocks/crm/components/contacts-screen.tsx +31 -13
- package/blocks/crm/components/dedupe-review.tsx +6 -1
- package/blocks/crm/components/import-wizard.tsx +3 -7
- package/blocks/crm/components/list-select.tsx +20 -0
- package/blocks/crm/components/record-engagement-tab.tsx +103 -0
- package/blocks/crm/components/record-form.tsx +1 -1
- package/blocks/crm/components/tasks-screen.tsx +35 -15
- package/blocks/crm/hooks/use-all-lists.ts +18 -0
- package/blocks/crm/hooks/use-page-query.ts +3 -1
- package/blocks/crm/lib/crm-types.ts +14 -0
- package/blocks/crm/lib/read-lists.ts +16 -0
- package/blocks/deals/README.md +24 -6
- package/blocks/deals/block.json +151 -2
- package/blocks/deals/components/close-deal-dialog.tsx +169 -0
- package/blocks/deals/components/deal-contacts-editor.tsx +134 -0
- package/blocks/deals/components/deal-detail.tsx +2 -2
- package/blocks/deals/components/deal-filters.tsx +65 -0
- package/blocks/deals/components/deal-line-items.tsx +120 -0
- package/blocks/deals/components/deal-notes-tab.tsx +97 -0
- package/blocks/deals/components/deal-related.tsx +30 -9
- package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
- package/blocks/deals/components/deals-screen.tsx +57 -22
- package/blocks/deals/components/forecast-view.tsx +4 -4
- package/blocks/deals/components/pipeline-board.tsx +8 -6
- package/blocks/deals/components/pipeline-editor.tsx +132 -0
- package/blocks/deals/components/record-deals-tab.tsx +62 -0
- package/blocks/deals/components/stage-editor.tsx +1 -1
- package/blocks/deals/hooks/use-pipeline-deals.ts +17 -4
- package/blocks/deals/lib/deal-extras.ts +156 -0
- package/blocks/dialer/README.md +14 -5
- package/blocks/dialer/block.json +17 -2
- package/blocks/dialer/components/call-contact-button.tsx +26 -9
- package/blocks/dialer/components/call-log-screen.tsx +4 -1
- package/blocks/dialer/components/call-log.tsx +16 -4
- package/blocks/dialer/components/call-queue.tsx +3 -3
- package/blocks/dialer/components/dialer-screen.tsx +69 -27
- package/blocks/dialer/components/disposition-panel.tsx +1 -1
- package/blocks/dialer/components/record-calls-tab.tsx +101 -0
- package/blocks/dialer/components/session-start-form.tsx +58 -11
- package/blocks/dialer/components/softphone.tsx +1 -1
- package/blocks/enrichment/README.md +16 -6
- package/blocks/enrichment/block.json +19 -7
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +76 -27
- package/blocks/enrichment/components/email-verify-badge.tsx +3 -3
- package/blocks/enrichment/components/enrich-button.tsx +1 -1
- package/blocks/enrichment/components/enrichment-screen.tsx +70 -17
- package/blocks/enrichment/components/waterfall-config.tsx +27 -7
- package/blocks/enrichment/lib/enrichment-types.ts +39 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
- package/blocks/forms/README.md +8 -0
- package/blocks/forms/block.json +107 -0
- package/blocks/forms/components/form-activity.tsx +71 -0
- package/blocks/forms/components/form-backfill.tsx +108 -0
- package/blocks/forms/components/form-builder.tsx +1 -1
- package/blocks/forms/components/form-embed.tsx +1 -1
- package/blocks/forms/components/form-groups.tsx +151 -0
- package/blocks/forms/components/form-mapping-editor.tsx +6 -9
- package/blocks/forms/components/form-route-history.tsx +72 -0
- package/blocks/forms/components/form-saved-views.tsx +81 -0
- package/blocks/forms/components/forms-screen.tsx +51 -17
- package/blocks/forms/components/more-rows.tsx +30 -0
- package/blocks/forms/components/submissions-export.tsx +67 -0
- package/blocks/forms/components/submissions-table.tsx +15 -6
- package/blocks/forms/lib/forms-extra.ts +67 -0
- package/blocks/inbox/README.md +5 -0
- package/blocks/inbox/block.json +15 -0
- package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
- package/blocks/inbox/components/thread-badge.tsx +2 -2
- package/blocks/inbox/components/thread-view.tsx +1 -1
- package/blocks/integrations/README.md +54 -1
- package/blocks/integrations/block.json +189 -15
- package/blocks/integrations/components/ad-accounts.tsx +82 -0
- package/blocks/integrations/components/connection-health.tsx +59 -0
- package/blocks/integrations/components/crm-detail.tsx +20 -1
- package/blocks/integrations/components/crm-records.tsx +103 -0
- package/blocks/integrations/components/field-mappings.tsx +61 -0
- package/blocks/integrations/components/integration-list.tsx +4 -4
- package/blocks/integrations/components/integrations-screen.tsx +20 -3
- package/blocks/integrations/components/migration-panel.tsx +133 -0
- package/blocks/integrations/components/sugarcrm-connect.tsx +95 -0
- package/blocks/integrations/components/sync-status.tsx +6 -6
- package/blocks/integrations/components/sync-triggers.tsx +134 -0
- package/blocks/integrations/components/webhook-detail.tsx +26 -7
- package/blocks/integrations/components/webhook-manager.tsx +30 -17
- package/blocks/integrations/components/workspace-tools.tsx +127 -0
- package/blocks/integrations/lib/crm-utils.ts +39 -0
- package/blocks/integrations/lib/facts.tsx +13 -0
- package/blocks/integrations/lib/integrations-utils.ts +5 -0
- package/blocks/integrations/lib/load-more.tsx +141 -0
- package/blocks/intent/components/account-intent-score.tsx +5 -5
- package/blocks/intent/components/intent-feed.tsx +8 -8
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -8
- package/blocks/intent/lib/intent-utils.ts +4 -4
- package/blocks/lifecycle/components/account-related.tsx +8 -1
- package/blocks/lifecycle/components/churn-list.tsx +5 -2
- package/blocks/lifecycle/components/customer-table.tsx +1 -1
- package/blocks/lists/README.md +7 -1
- package/blocks/lists/block.json +31 -3
- package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
- package/blocks/lists/components/add-to-list-button.tsx +17 -7
- package/blocks/lists/components/list-picker.tsx +13 -3
- package/blocks/lists/components/list-table.tsx +1 -1
- package/blocks/lists/components/lists-screen.tsx +4 -2
- package/blocks/lists/components/record-lists-tab.tsx +99 -0
- package/blocks/lists/components/suppression-manager.tsx +27 -6
- package/blocks/lists/components/suppressions-screen.tsx +21 -12
- package/blocks/mailboxes/block.json +4 -0
- package/blocks/mailboxes/components/connect-mailbox.tsx +9 -8
- package/blocks/mailboxes/components/domain-detail.tsx +10 -4
- package/blocks/mailboxes/components/domain-purchase.tsx +14 -11
- package/blocks/mailboxes/components/mailbox-detail.tsx +3 -2
- package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
- package/blocks/mailboxes/components/mailboxes-screen.tsx +8 -3
- package/blocks/mailboxes/components/warmup-health.tsx +20 -20
- package/blocks/mailboxes/lib/mailboxes-utils.ts +4 -4
- package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
- package/blocks/newsletter/README.md +12 -0
- package/blocks/newsletter/block.json +117 -0
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -2
- package/blocks/newsletter/components/issue-versions.tsx +93 -0
- package/blocks/newsletter/components/more-rows.tsx +30 -0
- package/blocks/newsletter/components/newsletter-editor.tsx +1 -1
- package/blocks/newsletter/components/newsletter-list-syncs.tsx +121 -0
- package/blocks/newsletter/components/newsletter-screen.tsx +36 -13
- package/blocks/newsletter/components/newsletter-settings.tsx +211 -0
- package/blocks/newsletter/components/newsletter-stats.tsx +2 -2
- package/blocks/newsletter/components/newsletter-templates.tsx +133 -0
- package/blocks/newsletter/components/subscriber-table.tsx +5 -7
- package/blocks/newsletter/components/subscriber-timeline.tsx +65 -0
- package/blocks/newsletter/lib/newsletter-extra.ts +60 -0
- package/blocks/nurture/components/nurture-builder.tsx +10 -2
- package/blocks/nurture/components/nurture-enroll.tsx +6 -7
- package/blocks/nurture/components/nurture-form.tsx +5 -8
- package/blocks/nurture/components/nurture-screen.tsx +16 -5
- package/blocks/nurture/components/nurture-stats.tsx +5 -4
- package/blocks/objects/README.md +1 -1
- package/blocks/objects/block.json +13 -5
- package/blocks/objects/components/attribute-field-input.tsx +14 -1
- package/blocks/objects/components/object-record-detail.tsx +8 -1
- package/blocks/objects/components/object-schema-editor.tsx +1 -1
- package/blocks/objects/components/record-roles-panel.tsx +15 -5
- package/blocks/pages/README.md +14 -0
- package/blocks/pages/block.json +113 -1
- package/blocks/pages/components/more-rows.tsx +30 -0
- package/blocks/pages/components/page-ai-tools.tsx +196 -0
- package/blocks/pages/components/page-analytics.tsx +176 -0
- package/blocks/pages/components/page-clone.tsx +99 -0
- package/blocks/pages/components/page-domains.tsx +167 -0
- package/blocks/pages/components/page-editor.tsx +1 -1
- package/blocks/pages/components/page-generation-status.tsx +48 -0
- package/blocks/pages/components/page-tracking.tsx +137 -0
- package/blocks/pages/components/pages-screen.tsx +57 -16
- package/blocks/pages/components/seo-report.tsx +6 -1
- package/blocks/pages/lib/studio.ts +7 -0
- package/blocks/phone-numbers/README.md +74 -0
- package/blocks/phone-numbers/block.json +262 -0
- package/blocks/phone-numbers/components/a2p-registration.tsx +230 -0
- package/blocks/phone-numbers/components/assign-number.tsx +104 -0
- package/blocks/phone-numbers/components/number-search.tsx +239 -0
- package/blocks/phone-numbers/components/number-settings.tsx +134 -0
- package/blocks/phone-numbers/components/number-usage.tsx +97 -0
- package/blocks/phone-numbers/components/phone-number-detail.tsx +84 -0
- package/blocks/phone-numbers/components/phone-number-list.tsx +110 -0
- package/blocks/phone-numbers/components/phone-numbers-screen.tsx +201 -0
- package/blocks/phone-numbers/components/register-owned-number.tsx +98 -0
- package/blocks/phone-numbers/components/release-number.tsx +126 -0
- package/blocks/phone-numbers/hooks/use-phone-numbers.ts +58 -0
- package/blocks/phone-numbers/lib/phone-numbers-utils.ts +202 -0
- package/blocks/phone-numbers/recipes/01-search-and-buy-number.ts +49 -0
- package/blocks/phone-numbers/recipes/02-assign-number-to-rep.ts +25 -0
- package/blocks/phone-numbers/recipes/03-audit-numbers.ts +36 -0
- package/blocks/phone-numbers/recipes/04-register-text-messaging.ts +39 -0
- package/blocks/pickers/README.md +23 -0
- package/blocks/pickers/block.json +103 -0
- package/blocks/pickers/components/company-picker.tsx +90 -0
- package/blocks/pickers/components/contact-picker.tsx +131 -0
- package/blocks/pickers/components/member-picker.tsx +99 -0
- package/blocks/pickers/components/search-picker.tsx +98 -0
- package/blocks/pickers/hooks/use-current-member.ts +104 -0
- package/blocks/pickers/hooks/use-picker-pages.ts +73 -0
- package/blocks/pickers/lib/use-debounced.ts +13 -0
- package/blocks/quotes/README.md +13 -0
- package/blocks/quotes/block.json +48 -1
- package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
- package/blocks/quotes/components/quote-builder.tsx +1 -1
- package/blocks/quotes/components/quote-lifecycle-actions.tsx +146 -0
- package/blocks/quotes/components/quote-list.tsx +5 -3
- package/blocks/quotes/components/quote-related.tsx +3 -2
- package/blocks/quotes/components/quote-viewer.tsx +7 -2
- package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
- package/blocks/radar/components/competitor-battle-card.tsx +1 -1
- package/blocks/radar/components/radar-overview.tsx +4 -4
- package/blocks/radar/components/radar-screen.tsx +2 -2
- package/blocks/sequencer/README.md +6 -0
- package/blocks/sequencer/block.json +28 -1
- package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +2 -2
- package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
- package/blocks/sequencer/components/sequence-builder.tsx +19 -20
- package/blocks/sequencer/components/sequence-detail.tsx +4 -2
- package/blocks/sequencer/components/sequence-enrollments.tsx +4 -2
- package/blocks/sequencer/components/sequence-list.tsx +1 -1
- package/blocks/sequencer/components/sequence-stats.tsx +8 -8
- package/blocks/sequencer/components/sequences-screen.tsx +3 -0
- package/blocks/sequencer/components/step-editor.tsx +11 -11
- package/blocks/sequencer/lib/sequencer-utils.ts +5 -5
- package/blocks/social-ads/README.md +8 -0
- package/blocks/social-ads/block.json +137 -4
- package/blocks/social-ads/components/audience-sync-detail.tsx +9 -3
- package/blocks/social-ads/components/audience-sync.tsx +6 -2
- package/blocks/social-ads/components/campaign-ads.tsx +166 -0
- package/blocks/social-ads/components/campaign-detail.tsx +6 -1
- package/blocks/social-ads/components/campaign-editor.tsx +134 -0
- package/blocks/social-ads/components/creative-concepts.tsx +74 -0
- package/blocks/social-ads/components/creative-detail.tsx +6 -1
- package/blocks/social-ads/components/creative-gallery.tsx +20 -2
- package/blocks/social-ads/components/creative-revisions.tsx +57 -0
- package/blocks/social-ads/components/creative-tools.tsx +176 -0
- package/blocks/social-ads/components/post-composer.tsx +4 -2
- package/blocks/social-ads/components/post-detail.tsx +3 -0
- package/blocks/social-ads/components/social-ideas.tsx +64 -0
- package/blocks/social-ads/components/social-performance.tsx +80 -0
- package/blocks/social-ads/components/social-screen.tsx +33 -6
- package/blocks/social-ads/lib/ads-extra.ts +67 -0
- package/blocks/surveys/README.md +91 -0
- package/blocks/surveys/block.json +299 -0
- package/blocks/surveys/components/account-nps.tsx +69 -0
- package/blocks/surveys/components/contact-survey-history.tsx +52 -0
- package/blocks/surveys/components/org-summary.tsx +82 -0
- package/blocks/surveys/components/sending-domains.tsx +113 -0
- package/blocks/surveys/components/survey-form.tsx +126 -0
- package/blocks/surveys/components/survey-responses.tsx +145 -0
- package/blocks/surveys/components/survey-results.tsx +72 -0
- package/blocks/surveys/components/survey-send-history.tsx +58 -0
- package/blocks/surveys/components/survey-send.tsx +162 -0
- package/blocks/surveys/components/survey-triggers.tsx +166 -0
- package/blocks/surveys/components/surveys-screen.tsx +234 -0
- package/blocks/surveys/lib/surveys-types.ts +228 -0
- package/blocks/surveys/recipes/create-an-nps-survey.ts +22 -0
- package/blocks/surveys/recipes/send-a-survey-and-follow-it.ts +27 -0
- package/blocks/surveys/recipes/survey-score-report.ts +27 -0
- package/blocks/visitors/components/snippet-install.tsx +18 -9
- package/blocks/visitors/components/traffic-chart.tsx +4 -4
- package/blocks/visitors/components/visitor-company-card.tsx +24 -14
- package/blocks/visitors/components/visitor-feed.tsx +5 -5
- package/blocks/webchat/components/chat-widget-config.tsx +1 -1
- package/blocks/webchat/components/conversation-list.tsx +1 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +1 -1
- package/blocks/workbench/components/run-progress.tsx +1 -1
- package/blocks/workbench/components/workbench-grid.tsx +1 -1
- package/blocks/workbench/components/workbench-screen.tsx +15 -5
- package/blocks/workbench/components/workbench-table-list.tsx +14 -2
- package/blocks/workflows/components/workflow-editor.tsx +1 -1
- package/dist/cli.js +536 -392
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/brand.d.ts +11 -2
- package/dist/types/blocks/create.d.ts +7 -3
- package/dist/types/blocks/doctor.d.ts +1 -0
- package/dist/types/blocks/flows.d.ts +7 -1
- package/dist/types/blocks/orgBrand.d.ts +34 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/version.d.ts +2 -2
- package/docs/api/desk.d.ts +2 -2
- package/docs/api/desk.md +3 -3
- package/package.json +1 -1
- package/stacks/6sense/pages/companies.tsx.tmpl +21 -0
- package/stacks/6sense/pages/contacts.tsx.tmpl +19 -0
- package/stacks/6sense/pages/tasks.tsx.tmpl +19 -0
- package/stacks/6sense.json +17 -4
- package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
- package/stacks/apollo/pages/companies.tsx.tmpl +19 -36
- package/stacks/apollo/pages/contacts.tsx.tmpl +29 -95
- package/stacks/apollo/pages/deals.tsx.tmpl +17 -0
- package/stacks/apollo/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/apollo/pages/lists.tsx.tmpl +12 -0
- package/stacks/apollo/pages/meetings.tsx.tmpl +13 -0
- package/stacks/apollo/pages/sequences.tsx.tmpl +12 -0
- package/stacks/apollo/pages/tasks.tsx.tmpl +20 -0
- package/stacks/apollo.json +36 -8
- package/stacks/calendly/pages/companies.tsx.tmpl +19 -0
- package/stacks/calendly/pages/contacts.tsx.tmpl +21 -0
- package/stacks/calendly/pages/meetings.tsx.tmpl +13 -0
- package/stacks/calendly/pages/tasks.tsx.tmpl +19 -0
- package/stacks/calendly.json +8 -4
- package/stacks/clay/pages/companies.tsx.tmpl +17 -1
- package/stacks/clay/pages/contacts.tsx.tmpl +12 -0
- package/stacks/clay/pages/lists.tsx.tmpl +12 -0
- package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
- package/stacks/clay.json +16 -3
- package/stacks/gong/pages/calls.tsx.tmpl +23 -0
- package/stacks/gong/pages/companies.tsx.tmpl +22 -0
- package/stacks/gong/pages/contacts.tsx.tmpl +32 -0
- package/stacks/gong/pages/lists.tsx.tmpl +12 -0
- package/stacks/gong/pages/meetings.tsx.tmpl +13 -0
- package/stacks/gong/pages/tasks.tsx.tmpl +19 -0
- package/stacks/gong.json +23 -6
- package/stacks/hootsuite/pages/companies.tsx.tmpl +22 -0
- package/stacks/hootsuite/pages/contacts.tsx.tmpl +28 -0
- package/stacks/hootsuite/pages/lists.tsx.tmpl +12 -0
- package/stacks/hootsuite/pages/tasks.tsx.tmpl +19 -0
- package/stacks/hootsuite.json +9 -4
- package/stacks/hubspot/pages/companies.tsx.tmpl +14 -35
- package/stacks/hubspot/pages/contacts.tsx.tmpl +29 -95
- package/stacks/hubspot/pages/deals.tsx.tmpl +10 -1
- package/stacks/hubspot/pages/inbox.tsx.tmpl +12 -1
- package/stacks/hubspot/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/hubspot/pages/lists.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/meetings.tsx.tmpl +13 -0
- package/stacks/hubspot/pages/sequences.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
- package/stacks/hubspot.json +21 -5
- package/stacks/intercom/pages/companies.tsx.tmpl +19 -0
- package/stacks/intercom/pages/contacts.tsx.tmpl +21 -0
- package/stacks/intercom/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/intercom/pages/tasks.tsx.tmpl +19 -0
- package/stacks/intercom.json +8 -4
- package/stacks/mailchimp/pages/companies.tsx.tmpl +22 -0
- package/stacks/mailchimp/pages/contacts.tsx.tmpl +28 -0
- package/stacks/mailchimp/pages/lists.tsx.tmpl +12 -0
- package/stacks/mailchimp/pages/tasks.tsx.tmpl +19 -0
- package/stacks/mailchimp.json +20 -5
- package/stacks/outreach/pages/calls.tsx.tmpl +23 -0
- package/stacks/outreach/pages/companies.tsx.tmpl +22 -0
- package/stacks/outreach/pages/contacts.tsx.tmpl +27 -66
- package/stacks/outreach/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/outreach/pages/lists.tsx.tmpl +12 -0
- package/stacks/outreach/pages/meetings.tsx.tmpl +13 -0
- package/stacks/outreach/pages/sequences.tsx.tmpl +12 -0
- package/stacks/outreach/pages/tasks.tsx.tmpl +19 -0
- package/stacks/outreach.json +25 -7
- package/stacks/pipedrive/pages/companies.tsx.tmpl +11 -35
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +15 -35
- package/stacks/pipedrive/pages/deals.tsx.tmpl +10 -1
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +12 -1
- package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
- package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
- package/stacks/pipedrive.json +4 -2
- package/stacks/salesforce/pages/companies.tsx.tmpl +14 -35
- package/stacks/salesforce/pages/contacts.tsx.tmpl +20 -35
- package/stacks/salesforce/pages/deals.tsx.tmpl +10 -1
- package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
- package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
- package/stacks/salesforce.json +7 -3
- package/stacks/zoominfo/pages/companies.tsx.tmpl +17 -1
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +12 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +12 -0
- package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +16 -3
- package/styles/styles.css +1 -1
- package/templates/next-app/app/globals.css.tmpl +349 -64
- package/templates/next-app/components/app-shell.tsx.tmpl +6 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PageAiTools: AI help for one landing page. Describe a change in plain words and the page is edited; run a review that
|
|
5
|
+
* lists what could be better and apply the exact fixes for free; write search title, description and keywords.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in the order the screen uses them:
|
|
8
|
+
* 1. editLandingPageWithChat POST /landing-pages/{id}/edit { instruction, section_idx? } (BILLABLE, confirmed; changes the draft only)
|
|
9
|
+
* 2. reviewLandingPage POST /landing-pages/{id}/ai-review { deep } (BILLABLE, confirmed; changes nothing)
|
|
10
|
+
* 3. applyLandingPageReviewFindings POST /landing-pages/{id}/ai-review/apply { finding_ids } (write, free, no model runs)
|
|
11
|
+
* 4. fixLandingPageFindingWithAi POST /landing-pages/{id}/ai-review/fix-with-ai { finding } (BILLABLE per finding, confirmed)
|
|
12
|
+
* 5. generateLandingPageSeoMetadata POST /landing-pages/{id}/generate-seo-metadata (BILLABLE, confirmed)
|
|
13
|
+
* 6. updateLandingPage PATCH /landing-pages/{id} { description } (write; "Use as description")
|
|
14
|
+
*
|
|
15
|
+
* The API quotes no price in advance and the balance is not checked here, so every dialog says what runs and how many
|
|
16
|
+
* AI passes it is (a "quick check" runs no model: 0 credits). Edits change the draft; the live page changes only when
|
|
17
|
+
* it is published again. Nothing here publishes.
|
|
18
|
+
*/
|
|
19
|
+
import { useState } from "react";
|
|
20
|
+
import { Button, 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 { useConfirmAction } from "@/g8/components/confirmed-action";
|
|
24
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
25
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
26
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
27
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
28
|
+
import type { Tone } from "@/g8/components/screen/status-pill";
|
|
29
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
30
|
+
import { g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
31
|
+
import { asRec, num, rowsOf, text } from "../lib/studio";
|
|
32
|
+
import type { Rec } from "../lib/studio";
|
|
33
|
+
|
|
34
|
+
export interface PageAiToolsProps {
|
|
35
|
+
pageId: string;
|
|
36
|
+
/** Called after the page draft changed (an edit, applied fixes, a new description). */
|
|
37
|
+
onChanged?: () => void;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SEVERITY: Record<string, Tone> = { error: "negative", critical: "negative", high: "negative", warning: "warning", medium: "warning", info: "neutral", low: "neutral" };
|
|
42
|
+
|
|
43
|
+
/** Findings the review returned. The id is required: a finding without one cannot be applied. */
|
|
44
|
+
export function findingsOf(data: unknown): Rec[] {
|
|
45
|
+
return rowsOf<Rec>(data, "findings").filter((f) => text(f.id) !== "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ChatEdit({ pageId, onChanged }: PageAiToolsProps) {
|
|
49
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
50
|
+
const [instruction, setInstruction] = useState("");
|
|
51
|
+
const [section, setSection] = useState("");
|
|
52
|
+
const [done, setDone] = useState("");
|
|
53
|
+
const sectionIdx = section.trim() === "" ? undefined : Number(section) - 1;
|
|
54
|
+
const sectionOk = sectionIdx === undefined || (Number.isInteger(sectionIdx) && sectionIdx >= 0);
|
|
55
|
+
const go = (): void => {
|
|
56
|
+
const body = { instruction: instruction.trim(), ...(sectionIdx === undefined ? {} : { section_idx: sectionIdx }) };
|
|
57
|
+
ask({
|
|
58
|
+
tier: "billable",
|
|
59
|
+
title: sectionIdx === undefined ? "Edit the whole page with AI" : `Edit section ${sectionIdx + 1} with AI`,
|
|
60
|
+
confirmLabel: "Edit with AI",
|
|
61
|
+
description: `One AI pass applies your instruction to the draft. Uses credits; the API gives no price in advance and your balance is not checked here. The live page does not change until you publish.`,
|
|
62
|
+
run: async () => { await g8Post(`/landing-pages/${encodeURIComponent(pageId)}/edit`, body); setDone("Edit applied to the draft."); setInstruction(""); },
|
|
63
|
+
onDone: () => onChanged?.(),
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
return (
|
|
67
|
+
<SectionCard title="Edit with AI" description="Say what to change, for example: make the headline shorter and add a second call to action.">
|
|
68
|
+
<form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (instruction.trim() && sectionOk) go(); }}>
|
|
69
|
+
<div>
|
|
70
|
+
<Label htmlFor="ai-instruction" className="mb-1">What should change</Label>
|
|
71
|
+
<Textarea id="ai-instruction" rows={3} value={instruction} onChange={(e) => { setInstruction(e.target.value); setDone(""); }} />
|
|
72
|
+
</div>
|
|
73
|
+
<div className="max-w-40">
|
|
74
|
+
<Label htmlFor="ai-section" className="mb-1">Section number (optional)</Label>
|
|
75
|
+
<input id="ai-section" inputMode="numeric" className="border-input bg-background h-9 w-full rounded-md border px-2 text-sm" value={section} onChange={(e) => setSection(e.target.value.replace(/\D/g, ""))} />
|
|
76
|
+
</div>
|
|
77
|
+
<div><Button type="submit" disabled={!instruction.trim() || !sectionOk}>Edit with AI</Button></div>
|
|
78
|
+
</form>
|
|
79
|
+
{done ? <p role="status" className="mt-2 text-sm">{done}</p> : null}
|
|
80
|
+
{failure ? <ApiError error={failure} title="The edit did not work" /> : null}
|
|
81
|
+
<ConfirmSpendDialog {...dialog} />
|
|
82
|
+
</SectionCard>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function Review({ pageId, onChanged }: PageAiToolsProps) {
|
|
87
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
88
|
+
const base = `/landing-pages/${encodeURIComponent(pageId)}`;
|
|
89
|
+
const [findings, setFindings] = useState<Rec[] | null>(null);
|
|
90
|
+
const [picked, setPicked] = useState<string[]>([]);
|
|
91
|
+
const [note, setNote] = useState("");
|
|
92
|
+
const apply = useG8Mutation(() => g8Post(`${base}/ai-review/apply`, { finding_ids: picked }), {
|
|
93
|
+
onSuccess: (res) => {
|
|
94
|
+
// The API answers with the findings that remain (id, title, severity only): keep our full copies of those.
|
|
95
|
+
const d = asRec(res.data);
|
|
96
|
+
const applied = num(d.applied) ?? 0;
|
|
97
|
+
// remaining_findings comes from a rule-only re-review, so it never lists AI findings: drop only what could be applied (had an exact fix).
|
|
98
|
+
const done = new Set((findings ?? []).filter((f) => picked.includes(text(f.id)) && f.suggested_fix).map((f) => text(f.id)));
|
|
99
|
+
setFindings((cur) => (cur ?? []).filter((f) => !done.has(text(f.id))));
|
|
100
|
+
setNote(applied === picked.length ? `Applied ${applied} fix${applied === 1 ? "" : "es"}.` : `Applied ${applied} of ${picked.length} selected. The rest have no exact fix: use Fix with AI.`);
|
|
101
|
+
setPicked([]);
|
|
102
|
+
if (applied > 0) onChanged?.();
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const review = (deep: boolean): void => ask({
|
|
106
|
+
tier: "billable",
|
|
107
|
+
title: deep ? "Review this page with AI" : "Quick check of this page",
|
|
108
|
+
confirmLabel: deep ? "Review with AI" : "Run quick check",
|
|
109
|
+
estimatedCredits: deep ? undefined : 0,
|
|
110
|
+
description: deep ? "One AI pass reads the page and lists what to improve. Uses credits; the API gives no price in advance. Nothing on the page changes." : "Runs the built-in checks only, no AI model: 0 credits. Nothing on the page changes.",
|
|
111
|
+
run: async () => { const res = await g8Post(`${base}/ai-review`, { deep }); setFindings(findingsOf(res.data)); setPicked([]); setNote(""); },
|
|
112
|
+
});
|
|
113
|
+
const fix = (f: Rec): void => ask({
|
|
114
|
+
tier: "billable",
|
|
115
|
+
title: `Fix "${text(f.title, "this finding")}" with AI`,
|
|
116
|
+
confirmLabel: "Fix with AI",
|
|
117
|
+
description: "One AI pass rewrites the affected part of the draft. Uses credits for this finding only.",
|
|
118
|
+
run: async () => { await g8Post(`${base}/ai-review/fix-with-ai`, { finding: f }); setFindings((cur) => (cur ?? []).filter((x) => text(x.id) !== text(f.id))); },
|
|
119
|
+
onDone: () => onChanged?.(),
|
|
120
|
+
});
|
|
121
|
+
const toggle = (id: string): void => setPicked((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]));
|
|
122
|
+
return (
|
|
123
|
+
<SectionCard title="Review" description="Find wording, brand and clarity problems, then apply the exact fixes."
|
|
124
|
+
actions={<div className="flex gap-1"><Button size="sm" variant="outline" onClick={() => review(false)}>Quick check</Button><Button size="sm" onClick={() => review(true)}>Review with AI</Button></div>}>
|
|
125
|
+
{findings === null ? <EmptyState compact title="No review yet" description="Run a review to see what could be better on this page." />
|
|
126
|
+
: findings.length === 0 ? <EmptyState compact title="Nothing to fix" description="The review found no remaining problems." />
|
|
127
|
+
: (
|
|
128
|
+
<div className="grid gap-2">
|
|
129
|
+
<ul className="grid gap-2">
|
|
130
|
+
{findings.map((f) => {
|
|
131
|
+
const id = text(f.id);
|
|
132
|
+
return (
|
|
133
|
+
<li key={id} className="flex items-start gap-3 rounded-md border p-3">
|
|
134
|
+
<input type="checkbox" aria-label={`Select ${text(f.title, id)}`} className="mt-1" checked={picked.includes(id)} onChange={() => toggle(id)} />
|
|
135
|
+
<div className="min-w-0 flex-1">
|
|
136
|
+
<div className="flex items-center gap-2"><span className="text-sm font-medium">{text(f.title, id)}</span><StatusPill tone={SEVERITY[text(f.severity).toLowerCase()] ?? "neutral"}>{text(f.severity, "note")}</StatusPill></div>
|
|
137
|
+
{text(f.description) ? <p className="text-muted-foreground text-xs">{text(f.description)}</p> : null}
|
|
138
|
+
{text(f.suggested_fix) ? <p className="text-xs">Suggested: {typeof f.suggested_fix === "string" ? f.suggested_fix : JSON.stringify(f.suggested_fix)}</p> : null}
|
|
139
|
+
</div>
|
|
140
|
+
<Button size="sm" variant="ghost" aria-label={`Fix ${text(f.title, id)} with AI`} onClick={() => fix(f)}>Fix with AI</Button>
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</ul>
|
|
145
|
+
<div className="flex items-center gap-2">
|
|
146
|
+
<Button size="sm" loading={apply.loading} disabled={picked.length === 0} onClick={() => void apply.mutate(undefined)}>Apply {picked.length || ""} selected (free)</Button>
|
|
147
|
+
<Button size="sm" variant="ghost" onClick={() => setPicked(findings.map((f) => text(f.id)))}>Select all</Button>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
{note ? <p role="status" className="mt-2 text-sm">{note}</p> : null}
|
|
152
|
+
{apply.error ? <ApiError error={apply.error} title="Could not apply the fixes" /> : null}
|
|
153
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
154
|
+
<ConfirmSpendDialog {...dialog} />
|
|
155
|
+
</SectionCard>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function SeoMetadata({ pageId, onChanged }: PageAiToolsProps) {
|
|
160
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
161
|
+
const [meta, setMeta] = useState<Rec | null>(null);
|
|
162
|
+
const use = useG8Mutation((description: string) => g8Patch(`/landing-pages/${encodeURIComponent(pageId)}`, { description }), { onSuccess: () => onChanged?.() });
|
|
163
|
+
const scalars = Object.entries(meta ?? {}).filter(([, v]) => typeof v === "string" || typeof v === "number" || (Array.isArray(v) && v.every((x) => typeof x === "string")));
|
|
164
|
+
const description = text(meta?.description) || text(meta?.meta_description);
|
|
165
|
+
return (
|
|
166
|
+
<SectionCard title="Search title and description" description="Write the title, description and keywords search engines show."
|
|
167
|
+
actions={<Button size="sm" onClick={() => ask({
|
|
168
|
+
tier: "billable", title: "Write search metadata with AI", confirmLabel: "Write metadata",
|
|
169
|
+
description: "One AI pass reads the page and proposes a title, description and keywords. Uses credits; the API gives no price in advance. Nothing is saved until you choose to.",
|
|
170
|
+
run: async () => { setMeta(asRec((await g8Post(`/landing-pages/${encodeURIComponent(pageId)}/generate-seo-metadata`)).data)); },
|
|
171
|
+
})}>Write with AI</Button>}>
|
|
172
|
+
{!meta ? <EmptyState compact title="Not generated yet" description="Generate a proposal, read it, then keep what you like." /> : (
|
|
173
|
+
<div className="grid gap-3">
|
|
174
|
+
<KeyValueList columns={1} items={scalars.map(([k, v]) => ({ label: k.replace(/_/g, " "), value: Array.isArray(v) ? v.join(", ") : String(v), wide: true }))} />
|
|
175
|
+
{description ? <div><Button size="sm" variant="outline" loading={use.loading} onClick={() => void use.mutate(description)}>Use as the page description</Button></div> : null}
|
|
176
|
+
{use.data ? <p role="status" className="text-sm">Description saved.</p> : null}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
{use.error ? <ApiError error={use.error} title="Could not save the description" /> : null}
|
|
180
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
181
|
+
<ConfirmSpendDialog {...dialog} />
|
|
182
|
+
</SectionCard>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function PageAiTools({ pageId, onChanged, className }: PageAiToolsProps) {
|
|
187
|
+
return (
|
|
188
|
+
<div className={className}>
|
|
189
|
+
<div className="grid gap-4">
|
|
190
|
+
<ChatEdit pageId={pageId} onChanged={onChanged} />
|
|
191
|
+
<Review pageId={pageId} onChanged={onChanged} />
|
|
192
|
+
<SeoMetadata pageId={pageId} onChanged={onChanged} />
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PageAnalytics: how landing pages perform. Visits, identified people and companies, form submissions and sign-ups
|
|
5
|
+
* for a window and a traffic source, per page and in total, plus who visited. Pass `pageId` to scope everything to
|
|
6
|
+
* one page (the page detail tab); omit it for the whole portfolio.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in the order the screen uses them (all read, free):
|
|
9
|
+
* 1. getLandingPageReportingOverview GET /landing-pages/reporting/overview?days&source&page_id
|
|
10
|
+
* 2. listLandingPageReportingPeople GET /landing-pages/reporting/people?cursor&limit&days&source&page_id (paged, "Load more")
|
|
11
|
+
* 3. listLandingPageReportingCompanies GET /landing-pages/reporting/companies?cursor&limit&days&source&page_id (paged, "Load more")
|
|
12
|
+
*
|
|
13
|
+
* The payloads are open-shaped in the contract: only values that are really numbers are shown, a missing one is a
|
|
14
|
+
* dash (never a zero). When graph8 cannot identify visitors right now it says so instead of showing an empty list.
|
|
15
|
+
*/
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
|
|
18
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
19
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
20
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
21
|
+
import { StatStrip } from "@/g8/components/screen/stat-strip";
|
|
22
|
+
import type { StatItem } from "@/g8/components/screen/stat-strip";
|
|
23
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
24
|
+
import { usePaginated } from "@/g8/hooks/use-paginated";
|
|
25
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
26
|
+
import { asRec, num, pageOf, rowsOf, shortDate, text } from "../lib/studio";
|
|
27
|
+
import type { Rec } from "../lib/studio";
|
|
28
|
+
import { MoreRows } from "./more-rows";
|
|
29
|
+
|
|
30
|
+
export interface PageAnalyticsProps {
|
|
31
|
+
/** Scope every number to one page. Omit for all pages. */
|
|
32
|
+
pageId?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SOURCES = ["all", "paid", "email", "organic", "referral", "direct"] as const;
|
|
37
|
+
const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
|
|
38
|
+
const PAGE = 50;
|
|
39
|
+
|
|
40
|
+
const count = (v: unknown): string => (num(v) == null ? "-" : (v as number).toLocaleString());
|
|
41
|
+
const rate = (v: unknown): string => (num(v) == null ? "-" : `${((v as number) * 100).toFixed(1)}%`);
|
|
42
|
+
|
|
43
|
+
function PeopleList({ query }: { query: Record<string, string | number | string[]> }) {
|
|
44
|
+
const list = usePaginated<Rec>(async (cursor, signal) => {
|
|
45
|
+
const res = await g8Get("/landing-pages/reporting/people", { ...query, cursor: cursor ? Number(cursor) : 0, limit: PAGE }, { signal });
|
|
46
|
+
const d = asRec(res.data);
|
|
47
|
+
return pageOf(rowsOf<Rec>(d, "people"), d.next_cursor ?? res.pagination?.next_cursor);
|
|
48
|
+
}, [JSON.stringify(query)]);
|
|
49
|
+
if (list.error) return <ApiError error={list.error} title="Could not load visitors" onRetry={() => void list.reload()} />;
|
|
50
|
+
if (list.loading) return <Skeleton className="h-24 w-full" aria-busy="true" />;
|
|
51
|
+
if (list.items.length === 0) return <EmptyState compact title="No identified people yet" description="People appear here once a visitor is matched to a contact, for example after they submit a form." />;
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
<Table>
|
|
55
|
+
<TableHeader><TableRow><TableHead>Person</TableHead><TableHead>Visits</TableHead><TableHead>Top source</TableHead><TableHead>Last seen</TableHead></TableRow></TableHeader>
|
|
56
|
+
<TableBody>
|
|
57
|
+
{list.items.map((p, i) => (
|
|
58
|
+
<TableRow key={text(p.contact_id) || i}>
|
|
59
|
+
<TableCell className="font-medium">{text(p.name, "Identified contact")}</TableCell>
|
|
60
|
+
<TableCell>{count(p.visits)}</TableCell>
|
|
61
|
+
<TableCell>{text(p.top_source) || "-"}</TableCell>
|
|
62
|
+
<TableCell>{shortDate(p.last_seen) || "-"}</TableCell>
|
|
63
|
+
</TableRow>
|
|
64
|
+
))}
|
|
65
|
+
</TableBody>
|
|
66
|
+
</Table>
|
|
67
|
+
<MoreRows shown={list.items.length} noun="people" hasMore={list.hasMore} loading={list.loadingMore} onMore={() => void list.loadMore()} />
|
|
68
|
+
</>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function CompaniesList({ query }: { query: Record<string, string | number | string[]> }) {
|
|
73
|
+
const list = usePaginated<Rec>(async (cursor, signal) => {
|
|
74
|
+
const res = await g8Get("/landing-pages/reporting/companies", { ...query, cursor: cursor ? Number(cursor) : 0, limit: PAGE }, { signal });
|
|
75
|
+
const d = asRec(res.data);
|
|
76
|
+
return pageOf(rowsOf<Rec>(d, "companies"), d.next_cursor ?? res.pagination?.next_cursor);
|
|
77
|
+
}, [JSON.stringify(query)]);
|
|
78
|
+
if (list.error) return <ApiError error={list.error} title="Could not load companies" onRetry={() => void list.reload()} />;
|
|
79
|
+
if (list.loading) return <Skeleton className="h-24 w-full" aria-busy="true" />;
|
|
80
|
+
if (list.items.length === 0) return <EmptyState compact title="No identified companies yet" description="Companies appear when a visit can be resolved to the organization behind it." />;
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<Table>
|
|
84
|
+
<TableHeader><TableRow><TableHead>Company</TableHead><TableHead>Visits</TableHead><TableHead>Top source</TableHead><TableHead>Last seen</TableHead></TableRow></TableHeader>
|
|
85
|
+
<TableBody>
|
|
86
|
+
{list.items.map((c, i) => (
|
|
87
|
+
<TableRow key={text(c.company_id) || i}>
|
|
88
|
+
<TableCell className="font-medium">{text(c.company_name) || text(c.name) || text(c.domain) || "Unknown company"}</TableCell>
|
|
89
|
+
<TableCell>{count(c.visits)}</TableCell>
|
|
90
|
+
<TableCell>{text(c.top_source) || "-"}</TableCell>
|
|
91
|
+
<TableCell>{shortDate(c.last_seen) || "-"}</TableCell>
|
|
92
|
+
</TableRow>
|
|
93
|
+
))}
|
|
94
|
+
</TableBody>
|
|
95
|
+
</Table>
|
|
96
|
+
<MoreRows shown={list.items.length} noun="companies" hasMore={list.hasMore} loading={list.loadingMore} onMore={() => void list.loadMore()} />
|
|
97
|
+
</>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function PageAnalytics({ pageId, className }: PageAnalyticsProps) {
|
|
102
|
+
const [days, setDays] = useState(30);
|
|
103
|
+
const [source, setSource] = useState<(typeof SOURCES)[number]>("all");
|
|
104
|
+
const query: Record<string, string | number | string[]> = { days, source, ...(pageId ? { page_id: [pageId] } : {}) };
|
|
105
|
+
const overview = useG8Query((signal) => g8Get("/landing-pages/reporting/overview", query, { signal }), [days, source, pageId]);
|
|
106
|
+
|
|
107
|
+
const o = asRec(overview.data?.data);
|
|
108
|
+
const totals = asRec(o.totals);
|
|
109
|
+
const pages = rowsOf<Rec>(o.pages);
|
|
110
|
+
const sources = rowsOf<Rec>(o.sources);
|
|
111
|
+
const stats: StatItem[] = [
|
|
112
|
+
{ label: "Page views", value: count(totals.page_views) },
|
|
113
|
+
{ label: "Unique visitors", value: count(totals.unique_visitors) },
|
|
114
|
+
{ label: "Identified people", value: o.people_resolution_available === false ? "-" : count(totals.identified_people), hint: o.people_resolution_available === false ? "Visitor identification is not available right now." : undefined },
|
|
115
|
+
{ label: "Identified companies", value: o.company_resolution_available === false ? "-" : count(totals.identified_companies), hint: o.company_resolution_available === false ? "Company matching is catching up. This is not zero." : undefined },
|
|
116
|
+
{ label: "Form submissions", value: count(totals.form_submissions) },
|
|
117
|
+
{ label: "Form conversion", value: rate(totals.form_conversion_rate) },
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className={className}>
|
|
122
|
+
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
123
|
+
<select aria-label="Reporting window" className={SELECT} value={days} onChange={(e) => setDays(Number(e.target.value))}>
|
|
124
|
+
{[7, 30, 90].map((d) => <option key={d} value={d}>Last {d} days</option>)}
|
|
125
|
+
</select>
|
|
126
|
+
<select aria-label="Traffic source" className={SELECT} value={source} onChange={(e) => setSource(e.target.value as (typeof SOURCES)[number])}>
|
|
127
|
+
{SOURCES.map((s) => <option key={s} value={s}>{s === "all" ? "All sources" : s[0].toUpperCase() + s.slice(1)}</option>)}
|
|
128
|
+
</select>
|
|
129
|
+
</div>
|
|
130
|
+
{overview.error ? <ApiError error={overview.error} title="Could not load page analytics" onRetry={() => void overview.refetch()} /> : (
|
|
131
|
+
<div className="grid gap-4">
|
|
132
|
+
<StatStrip items={stats} loading={overview.loading && !overview.data} />
|
|
133
|
+
{o.stale === true ? <p role="note" className="text-muted-foreground text-xs">These numbers may be a few minutes old.</p> : null}
|
|
134
|
+
{!pageId ? (
|
|
135
|
+
<SectionCard title="By page" flush>
|
|
136
|
+
{pages.length === 0 ? <EmptyState compact title="No published pages in this window" description="Only published pages with a URL are measured." /> : (
|
|
137
|
+
<Table>
|
|
138
|
+
<TableHeader><TableRow><TableHead>Page</TableHead><TableHead>Views</TableHead><TableHead>Visitors</TableHead><TableHead>Forms</TableHead><TableHead>Top source</TableHead></TableRow></TableHeader>
|
|
139
|
+
<TableBody>
|
|
140
|
+
{pages.map((p, i) => (
|
|
141
|
+
<TableRow key={text(p.id) || i}>
|
|
142
|
+
<TableCell className="font-medium">{text(p.name) || text(p.title) || text(p.id)}</TableCell>
|
|
143
|
+
<TableCell>{count(p.page_views)}</TableCell>
|
|
144
|
+
<TableCell>{count(p.unique_visitors)}</TableCell>
|
|
145
|
+
<TableCell>{count(p.form_submissions)}</TableCell>
|
|
146
|
+
<TableCell>{text(p.top_source) || "-"}</TableCell>
|
|
147
|
+
</TableRow>
|
|
148
|
+
))}
|
|
149
|
+
</TableBody>
|
|
150
|
+
</Table>
|
|
151
|
+
)}
|
|
152
|
+
</SectionCard>
|
|
153
|
+
) : null}
|
|
154
|
+
{sources.length > 0 ? (
|
|
155
|
+
<SectionCard title="Traffic sources" flush>
|
|
156
|
+
<Table>
|
|
157
|
+
<TableHeader><TableRow><TableHead>Source</TableHead><TableHead>Views</TableHead><TableHead>Visitors</TableHead></TableRow></TableHeader>
|
|
158
|
+
<TableBody>
|
|
159
|
+
{sources.slice(0, 10).map((s, i) => (
|
|
160
|
+
<TableRow key={i}><TableCell className="font-medium">{text(s.channel) || "Unknown"}</TableCell><TableCell>{count(s.page_views)}</TableCell><TableCell>{count(s.unique_visitors)}</TableCell></TableRow>
|
|
161
|
+
))}
|
|
162
|
+
</TableBody>
|
|
163
|
+
</Table>
|
|
164
|
+
</SectionCard>
|
|
165
|
+
) : null}
|
|
166
|
+
<SectionCard title="Identified people" description="Visitors matched to a contact.">
|
|
167
|
+
<PeopleList query={query} />
|
|
168
|
+
</SectionCard>
|
|
169
|
+
<SectionCard title="Identified companies" description="Organizations behind the visits.">
|
|
170
|
+
<CompaniesList query={query} />
|
|
171
|
+
</SectionCard>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PageClone: turn an existing web page into an editable landing page, from its public URL or from HTML you paste.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in the order the screen uses them:
|
|
7
|
+
* 1. cloneUrlToLandingPage POST /landing-pages/clone-url { url, name?, use_vision } (BILLABLE, confirmed)
|
|
8
|
+
* 2. cloneHtmlToLandingPage POST /landing-pages/clone-html { html, name?, source_url?, use_vision } (BILLABLE, confirmed)
|
|
9
|
+
*
|
|
10
|
+
* Both run AI passes; "match the look from a screenshot" is the expensive half and is on by default, as in the API.
|
|
11
|
+
* The API quotes no price in advance and the balance is not checked here, so the dialog says what runs. The new page
|
|
12
|
+
* is a draft: nothing is published. Pasted HTML is limited to 4 MiB (the proxy rejects more than 5 MiB).
|
|
13
|
+
*/
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import { Button, Input, Label, Textarea } from "@graph8/sdk/ui";
|
|
16
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
17
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
18
|
+
import { useConfirmAction } from "@/g8/components/confirmed-action";
|
|
19
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
20
|
+
import { g8Post } from "@/g8/lib/g8-client";
|
|
21
|
+
import { asRec, text } from "../lib/studio";
|
|
22
|
+
|
|
23
|
+
export interface PageCloneProps {
|
|
24
|
+
/** Called with the new page's id once the clone is made. */
|
|
25
|
+
onCreated?: (pageId: string) => void;
|
|
26
|
+
/** The button label. */
|
|
27
|
+
label?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const MAX_HTML_BYTES = 4 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
/** True for an http(s) URL with a host: what the clone endpoint can fetch. */
|
|
34
|
+
export function isPublicUrl(raw: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
const u = new URL(raw.trim());
|
|
37
|
+
return (u.protocol === "http:" || u.protocol === "https:") && u.hostname.includes(".");
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function PageClone({ onCreated, label = "Clone a page", className }: PageCloneProps) {
|
|
44
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
45
|
+
const [open, setOpen] = useState(false);
|
|
46
|
+
const [mode, setMode] = useState<"url" | "html">("url");
|
|
47
|
+
const [url, setUrl] = useState("");
|
|
48
|
+
const [html, setHtml] = useState("");
|
|
49
|
+
const [name, setName] = useState("");
|
|
50
|
+
const [vision, setVision] = useState(true);
|
|
51
|
+
const htmlBytes = new TextEncoder().encode(html).length;
|
|
52
|
+
const ready = mode === "url" ? isPublicUrl(url) : html.trim() !== "" && htmlBytes <= MAX_HTML_BYTES;
|
|
53
|
+
|
|
54
|
+
const reset = (): void => { setUrl(""); setHtml(""); setName(""); setVision(true); };
|
|
55
|
+
const go = (): void => ask({
|
|
56
|
+
tier: "billable",
|
|
57
|
+
title: mode === "url" ? `Clone ${url.trim()} into an editable page` : "Clone the pasted HTML into an editable page",
|
|
58
|
+
confirmLabel: "Clone page",
|
|
59
|
+
description: `Runs AI passes to rebuild the page as editable sections${vision ? ", including a screenshot pass so it looks like the original" : ""}. Uses credits; the API gives no price in advance and your balance is not checked here. The new page is a draft.`,
|
|
60
|
+
run: async () => {
|
|
61
|
+
const common = { name: name.trim() || undefined, use_vision: vision };
|
|
62
|
+
const res = mode === "url" ? await g8Post("/landing-pages/clone-url", { url: url.trim(), ...common }) : await g8Post("/landing-pages/clone-html", { html, ...common });
|
|
63
|
+
const id = text(asRec(res.data).id);
|
|
64
|
+
reset();
|
|
65
|
+
setOpen(false);
|
|
66
|
+
if (id) onCreated?.(id);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className={className}>
|
|
72
|
+
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>{label}</Button>
|
|
73
|
+
<FormSheet
|
|
74
|
+
open={open}
|
|
75
|
+
onOpenChange={setOpen}
|
|
76
|
+
title="Clone a page"
|
|
77
|
+
description="Rebuild a page you already have as an editable landing page. It starts as a draft."
|
|
78
|
+
width="lg"
|
|
79
|
+
footer={<><Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button><Button disabled={!ready} onClick={go}>Clone page</Button></>}
|
|
80
|
+
>
|
|
81
|
+
<div className="flex gap-1" role="tablist" aria-label="Clone source">
|
|
82
|
+
<Button size="sm" role="tab" aria-selected={mode === "url"} variant={mode === "url" ? "default" : "outline"} onClick={() => setMode("url")}>From a URL</Button>
|
|
83
|
+
<Button size="sm" role="tab" aria-selected={mode === "html"} variant={mode === "html" ? "default" : "outline"} onClick={() => setMode("html")}>From HTML</Button>
|
|
84
|
+
</div>
|
|
85
|
+
{mode === "url" ? (
|
|
86
|
+
<div><Label htmlFor="pc-url" className="mb-1">Page URL</Label><Input id="pc-url" type="url" placeholder="https://example.com/pricing" value={url} onChange={(e) => setUrl(e.target.value)} aria-invalid={url !== "" && !isPublicUrl(url)} />
|
|
87
|
+
{url !== "" && !isPublicUrl(url) ? <p role="alert" className="text-error-foreground mt-1 text-xs">Enter a full address starting with https://.</p> : null}</div>
|
|
88
|
+
) : (
|
|
89
|
+
<div><Label htmlFor="pc-html" className="mb-1">HTML</Label><Textarea id="pc-html" rows={10} className="font-mono text-xs" value={html} onChange={(e) => setHtml(e.target.value)} />
|
|
90
|
+
{htmlBytes > MAX_HTML_BYTES ? <p role="alert" className="text-error-foreground mt-1 text-xs">This is {(htmlBytes / 1048576).toFixed(1)} MiB. The limit is 4 MiB.</p> : null}</div>
|
|
91
|
+
)}
|
|
92
|
+
<div><Label htmlFor="pc-name" className="mb-1">Name (optional)</Label><Input id="pc-name" value={name} onChange={(e) => setName(e.target.value)} /></div>
|
|
93
|
+
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={vision} onChange={(e) => setVision(e.target.checked)} />Match the look from a screenshot (uses more credits)</label>
|
|
94
|
+
{failure ? <ApiError error={failure} title="The clone did not work" /> : null}
|
|
95
|
+
</FormSheet>
|
|
96
|
+
<ConfirmSpendDialog {...dialog} />
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PageDomains: where one landing page is live. Lists every URL the page is bound to, takes one off the internet
|
|
5
|
+
* without unpublishing the page, and publishes the page to another of your verified domains.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in the order the screen uses them:
|
|
8
|
+
* 1. listLandingPageBindings GET /landing-pages/{id}/bindings (read)
|
|
9
|
+
* 2. listLandingPageDomains GET /landing-pages/domains (read; the domains a page can be published on)
|
|
10
|
+
* 3. publishLandingPage POST /landing-pages/{id}/publish { domain_id, subdomain, path } (external, confirmed: a public URL)
|
|
11
|
+
* 4. removeLandingPageBinding DELETE /landing-pages/{id}/bindings?hostname&path (destructive, confirmed: that URL stops resolving)
|
|
12
|
+
* 5. exportLandingPage GET /landing-pages/{id}/export (read; downloads the page as one self-contained HTML file)
|
|
13
|
+
*
|
|
14
|
+
* Publishing to a domain needs the domain to be connected for hosting (the shared default domain is published from the
|
|
15
|
+
* Edit tab). A domain that is not ready is listed but cannot be chosen, with the reason. A URL already used by another
|
|
16
|
+
* page is refused by the API; nothing is taken over from here.
|
|
17
|
+
*/
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import { Download } from "lucide-react";
|
|
20
|
+
import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
|
|
21
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
22
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
23
|
+
import { useConfirmAction } from "@/g8/components/confirmed-action";
|
|
24
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
25
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
26
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
27
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
28
|
+
import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
29
|
+
import { asRec, num, rowsOf, shortDate, text } from "../lib/studio";
|
|
30
|
+
import type { Rec } from "../lib/studio";
|
|
31
|
+
|
|
32
|
+
export interface PageDomainsProps {
|
|
33
|
+
pageId: string;
|
|
34
|
+
/** Called after a URL was added or removed, so the page list and status refresh. */
|
|
35
|
+
onChanged?: () => void;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
|
|
40
|
+
|
|
41
|
+
/** The slash-led path the API stores: "" and "/" are the root. */
|
|
42
|
+
export function normalizePath(raw: string): string {
|
|
43
|
+
const p = raw.trim().replace(/\/+$/, "");
|
|
44
|
+
return p ? (p.startsWith("/") ? p : `/${p}`) : "/";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A subdomain label: lowercase letters, digits and hyphens, not starting or ending with a hyphen. Empty means the apex. */
|
|
48
|
+
export function validSubdomain(raw: string): boolean {
|
|
49
|
+
const s = raw.trim();
|
|
50
|
+
return s === "" || s === "@" || /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(s);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function PageDomains({ pageId, onChanged, className }: PageDomainsProps) {
|
|
54
|
+
const base = `/landing-pages/${encodeURIComponent(pageId)}`;
|
|
55
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
56
|
+
const bindings = useG8Query((signal) => g8Get(`${base}/bindings`, undefined, { signal }), [pageId]);
|
|
57
|
+
const domains = useG8Query((signal) => g8Get("/landing-pages/domains", undefined, { signal }), []);
|
|
58
|
+
const [domainId, setDomainId] = useState("");
|
|
59
|
+
const [subdomain, setSubdomain] = useState("");
|
|
60
|
+
const [path, setPath] = useState("");
|
|
61
|
+
|
|
62
|
+
const exportHtml = useG8Mutation(async () => {
|
|
63
|
+
const d = asRec((await g8Get(`${base}/export`)).data);
|
|
64
|
+
const html = text(d.html) || text(d.html_output);
|
|
65
|
+
if (!html) throw new Error("This page has no HTML to download yet.");
|
|
66
|
+
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
|
|
67
|
+
const a = document.createElement("a");
|
|
68
|
+
a.href = url;
|
|
69
|
+
a.download = `${(text(d.name) || pageId).replace(/[^a-z0-9-_]+/gi, "-").toLowerCase()}.html`;
|
|
70
|
+
a.click();
|
|
71
|
+
URL.revokeObjectURL(url);
|
|
72
|
+
});
|
|
73
|
+
const changed = (): void => { void bindings.refetch(); onChanged?.(); };
|
|
74
|
+
const rows = rowsOf<Rec>(bindings.data?.data, "bindings");
|
|
75
|
+
const primary = asRec(asRec(bindings.data?.data).primary);
|
|
76
|
+
const available = rowsOf<Rec>(domains.data?.data, "domains");
|
|
77
|
+
const chosen = available.find((d) => String(d.id) === domainId);
|
|
78
|
+
const publishable = available.filter((d) => num(d.id) != null && d.inboxkit_connected === true);
|
|
79
|
+
const formOk = Boolean(chosen) && validSubdomain(subdomain);
|
|
80
|
+
|
|
81
|
+
const publish = (): void => {
|
|
82
|
+
if (!chosen) return;
|
|
83
|
+
const host = [subdomain.trim() && subdomain.trim() !== "@" ? subdomain.trim() : "", text(chosen.domain)].filter(Boolean).join(".");
|
|
84
|
+
const url = `https://${host}${normalizePath(path)}`;
|
|
85
|
+
ask({
|
|
86
|
+
tier: "external",
|
|
87
|
+
title: `Publish this page at ${url}`,
|
|
88
|
+
confirmLabel: "Publish",
|
|
89
|
+
description: "The page goes live at this address and anyone with the link can see it. A URL already used by another page is refused, nothing is replaced.",
|
|
90
|
+
run: () => g8Post(`${base}/publish`, { domain_id: Number(chosen.id), subdomain: subdomain.trim() === "@" ? "" : subdomain.trim(), path: normalizePath(path) }),
|
|
91
|
+
onDone: () => { setSubdomain(""); setPath(""); changed(); },
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const remove = (b: Rec): void => {
|
|
96
|
+
const hostname = text(b.hostname);
|
|
97
|
+
const bindingPath = text(b.path, "/");
|
|
98
|
+
ask({
|
|
99
|
+
tier: "destructive",
|
|
100
|
+
title: `Take down ${hostname}${bindingPath === "/" ? "" : bindingPath}`,
|
|
101
|
+
confirmLabel: "Remove URL",
|
|
102
|
+
affected: 1,
|
|
103
|
+
description: "This URL stops serving the page. Links to it in ads and emails will 404. The page and its other URLs stay; DNS records are not touched.",
|
|
104
|
+
tolerateStatus: [404],
|
|
105
|
+
run: () => g8Delete(`${base}/bindings`, { query: { hostname, path: bindingPath } }),
|
|
106
|
+
onDone: changed,
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={className}>
|
|
112
|
+
<div className="grid gap-4">
|
|
113
|
+
<SectionCard title="Live URLs" description="Every address that serves this page."
|
|
114
|
+
actions={<Button size="sm" variant="outline" loading={exportHtml.loading} onClick={() => void exportHtml.mutate(undefined)}><Download className="size-4" />Download HTML</Button>}>
|
|
115
|
+
{exportHtml.error ? <ApiError error={exportHtml.error} title="Could not download the page" /> : null}
|
|
116
|
+
{bindings.error ? <ApiError error={bindings.error} title="Could not load the URLs" onRetry={() => void bindings.refetch()} /> : bindings.loading && !bindings.data ? (
|
|
117
|
+
<Skeleton className="h-16 w-full" aria-busy="true" />
|
|
118
|
+
) : rows.length === 0 ? (
|
|
119
|
+
<EmptyState compact title="No custom URLs" description={text(primary.published_url) ? `Live at ${text(primary.published_url)}.` : "This page is not published to a custom domain yet."} />
|
|
120
|
+
) : (
|
|
121
|
+
<ul className="grid gap-2">
|
|
122
|
+
{rows.map((b) => (
|
|
123
|
+
<li key={`${text(b.hostname)}${text(b.path)}`} className="flex items-center justify-between gap-3 rounded-md border p-3">
|
|
124
|
+
<div className="min-w-0">
|
|
125
|
+
<span className="block truncate text-sm font-medium">{text(b.published_url) || `${text(b.hostname)}${text(b.path)}`}</span>
|
|
126
|
+
<span className="text-muted-foreground block text-xs">{b.published_at ? `Published ${shortDate(b.published_at)}` : "Published"}</span>
|
|
127
|
+
</div>
|
|
128
|
+
<Button size="sm" variant="ghost" aria-label={`Remove ${text(b.hostname)}`} onClick={() => remove(b)}>Remove</Button>
|
|
129
|
+
</li>
|
|
130
|
+
))}
|
|
131
|
+
</ul>
|
|
132
|
+
)}
|
|
133
|
+
</SectionCard>
|
|
134
|
+
|
|
135
|
+
<SectionCard title="Publish to a domain" description="Choose one of your connected domains, then an optional subdomain and path.">
|
|
136
|
+
{domains.error ? <ApiError error={domains.error} title="Could not load your domains" onRetry={() => void domains.refetch()} /> : domains.loading && !domains.data ? (
|
|
137
|
+
<Skeleton className="h-16 w-full" aria-busy="true" />
|
|
138
|
+
) : publishable.length === 0 ? (
|
|
139
|
+
<EmptyState compact title="No connected domains" description="Connect a domain for hosting first. The shared default domain is published from the Edit tab." />
|
|
140
|
+
) : (
|
|
141
|
+
<form className="grid gap-3 sm:grid-cols-3" onSubmit={(e) => { e.preventDefault(); if (formOk) publish(); }}>
|
|
142
|
+
<div>
|
|
143
|
+
<Label htmlFor="pd-domain" className="mb-1">Domain</Label>
|
|
144
|
+
<select id="pd-domain" className={`${SELECT} w-full`} value={domainId} onChange={(e) => setDomainId(e.target.value)}>
|
|
145
|
+
<option value="">Choose a domain</option>
|
|
146
|
+
{publishable.map((d) => <option key={String(d.id)} value={String(d.id)}>{text(d.domain)}</option>)}
|
|
147
|
+
</select>
|
|
148
|
+
</div>
|
|
149
|
+
<div>
|
|
150
|
+
<Label htmlFor="pd-sub" className="mb-1">Subdomain (optional)</Label>
|
|
151
|
+
<Input id="pd-sub" value={subdomain} placeholder="www or empty for the root" onChange={(e) => setSubdomain(e.target.value)} aria-invalid={!validSubdomain(subdomain)} />
|
|
152
|
+
</div>
|
|
153
|
+
<div>
|
|
154
|
+
<Label htmlFor="pd-path" className="mb-1">Path (optional)</Label>
|
|
155
|
+
<Input id="pd-path" value={path} placeholder="/pricing" onChange={(e) => setPath(e.target.value)} />
|
|
156
|
+
</div>
|
|
157
|
+
{!validSubdomain(subdomain) ? <p role="alert" className="text-error-foreground text-xs sm:col-span-3">A subdomain can only hold letters, numbers and hyphens.</p> : null}
|
|
158
|
+
<div className="sm:col-span-3"><Button type="submit" disabled={!formOk}>Publish to this domain</Button></div>
|
|
159
|
+
</form>
|
|
160
|
+
)}
|
|
161
|
+
</SectionCard>
|
|
162
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
163
|
+
</div>
|
|
164
|
+
<ConfirmSpendDialog {...dialog} />
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|