@graph8/sdk 0.362.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 +33 -10
- package/blocks/_foundation/README.md +78 -1
- package/blocks/_foundation/block.json +123 -6
- package/blocks/_foundation/components/api-error.tsx +9 -7
- package/blocks/_foundation/components/brand-provider.tsx +23 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
- package/blocks/_foundation/components/confirmed-action.tsx +90 -0
- package/blocks/_foundation/components/connect-graph8.tsx +280 -0
- 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 +2 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/split-view.tsx +1 -1
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +15 -97
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
- package/blocks/_foundation/hooks/use-brand.ts +52 -0
- package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
- package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
- package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
- package/blocks/_foundation/hooks/use-paginated.ts +120 -0
- package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
- package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
- package/blocks/_foundation/lib/g8-cache.ts +454 -0
- package/blocks/_foundation/lib/g8-client.ts +1 -1
- package/blocks/_foundation/lib/paging.ts +57 -0
- package/blocks/_foundation/lib/plural.ts +23 -0
- package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
- package/blocks/_foundation/server/connect-route.ts +15 -0
- package/blocks/_foundation/server/connect.ts +127 -0
- package/blocks/_foundation/server/local-key.ts +70 -0
- package/blocks/_foundation/server/proxy.ts +103 -3
- package/blocks/_foundation/server/route.ts +16 -4
- package/blocks/agents/README.md +93 -2
- package/blocks/agents/block.json +343 -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 +29 -10
- package/blocks/agents/components/agent-conversations.tsx +143 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +4 -3
- package/blocks/agents/components/agent-operators.tsx +164 -0
- package/blocks/agents/components/agent-run-detail.tsx +119 -0
- package/blocks/agents/components/agent-runs.tsx +19 -10
- 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 +226 -0
- 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/README.md +13 -0
- package/blocks/analytics/block.json +45 -1
- package/blocks/analytics/components/analytics-screen.tsx +222 -0
- package/blocks/analytics/components/kpi-tiles.tsx +1 -1
- package/blocks/analytics/components/leaderboard.tsx +14 -7
- package/blocks/analytics/components/report-builder.tsx +38 -11
- package/blocks/analytics/components/report-detail.tsx +84 -0
- package/blocks/analytics/components/trend-chart.tsx +62 -0
- package/blocks/appointments/README.md +6 -0
- package/blocks/appointments/block.json +33 -2
- package/blocks/appointments/components/availability-editor.tsx +17 -5
- package/blocks/appointments/components/booking-page.tsx +2 -2
- package/blocks/appointments/components/event-type-editor.tsx +2 -1
- package/blocks/appointments/components/event-type-related.tsx +4 -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 +38 -18
- package/blocks/appointments/components/meetings-screen.tsx +7 -4
- package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
- package/blocks/appointments/components/scheduling-screen.tsx +2 -1
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +17 -0
- package/blocks/campaigns/README.md +41 -11
- package/blocks/campaigns/block.json +105 -42
- package/blocks/campaigns/components/campaign-assets.tsx +17 -6
- package/blocks/campaigns/components/campaign-launch-step.tsx +114 -26
- package/blocks/campaigns/components/campaign-list.tsx +7 -3
- package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
- package/blocks/campaigns/components/campaign-wizard.tsx +52 -14
- package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
- package/blocks/campaigns/components/icp-detail.tsx +163 -0
- package/blocks/campaigns/components/icp-editor.tsx +22 -17
- package/blocks/campaigns/components/persona-detail.tsx +181 -0
- package/blocks/campaigns/components/persona-editor.tsx +19 -11
- package/blocks/campaigns/lib/campaigns-utils.ts +138 -5
- package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
- package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
- package/blocks/coach/README.md +10 -0
- package/blocks/coach/block.json +16 -1
- package/blocks/coach/components/call-scorecard.tsx +7 -7
- package/blocks/coach/components/coach-screen.tsx +222 -0
- 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/README.md +13 -0
- package/blocks/copilot/block.json +14 -1
- package/blocks/copilot/components/copilot-memory.tsx +6 -2
- package/blocks/copilot/components/copilot-panel.tsx +14 -5
- package/blocks/copilot/components/copilot-screen.tsx +148 -0
- package/blocks/copilot/components/copilot-team-chat.tsx +13 -6
- package/blocks/crm/README.md +34 -0
- package/blocks/crm/block.json +107 -2
- package/blocks/crm/components/companies-screen.tsx +359 -0
- package/blocks/crm/components/company-detail.tsx +10 -8
- package/blocks/crm/components/company-table.tsx +11 -4
- package/blocks/crm/components/contact-detail.tsx +10 -7
- package/blocks/crm/components/contact-lookup-link.tsx +44 -0
- package/blocks/crm/components/contact-table.tsx +10 -3
- package/blocks/crm/components/contacts-screen.tsx +377 -0
- package/blocks/crm/components/dedupe-review.tsx +13 -6
- package/blocks/crm/components/import-wizard.tsx +7 -9
- package/blocks/crm/components/list-select.tsx +20 -0
- package/blocks/crm/components/notes-panel.tsx +4 -4
- package/blocks/crm/components/record-engagement-tab.tsx +103 -0
- package/blocks/crm/components/record-form.tsx +6 -4
- package/blocks/crm/components/task-list.tsx +4 -4
- package/blocks/crm/components/tasks-screen.tsx +399 -0
- package/blocks/crm/hooks/use-all-lists.ts +18 -0
- package/blocks/crm/hooks/use-custom-fields.ts +6 -1
- package/blocks/crm/hooks/use-page-query.ts +9 -1
- package/blocks/crm/lib/crm-types.ts +26 -2
- package/blocks/crm/lib/read-lists.ts +16 -0
- package/blocks/crm/lib/screen-parts.tsx +105 -0
- package/blocks/deals/README.md +24 -6
- package/blocks/deals/block.json +160 -5
- 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 +25 -13
- 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 +31 -10
- package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
- package/blocks/deals/components/deals-screen.tsx +66 -26
- 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 +5 -5
- package/blocks/deals/hooks/use-pipeline-deals.ts +21 -4
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/deals/lib/deal-extras.ts +156 -0
- package/blocks/dialer/README.md +45 -4
- package/blocks/dialer/block.json +84 -3
- package/blocks/dialer/components/call-contact-button.tsx +269 -0
- package/blocks/dialer/components/call-log-screen.tsx +208 -0
- package/blocks/dialer/components/call-log.tsx +23 -7
- package/blocks/dialer/components/call-queue.tsx +4 -4
- package/blocks/dialer/components/dialer-screen.tsx +361 -0
- package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
- package/blocks/dialer/components/disposition-panel.tsx +1 -1
- package/blocks/dialer/components/live-calls-table.tsx +7 -3
- package/blocks/dialer/components/record-calls-tab.tsx +101 -0
- package/blocks/dialer/components/session-start-form.tsx +61 -12
- package/blocks/dialer/components/softphone.tsx +4 -2
- package/blocks/enrichment/README.md +29 -7
- package/blocks/enrichment/block.json +49 -9
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +83 -29
- package/blocks/enrichment/components/email-verify-badge.tsx +31 -11
- package/blocks/enrichment/components/enrich-button.tsx +32 -9
- package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +341 -0
- package/blocks/enrichment/components/waterfall-config.tsx +66 -17
- package/blocks/enrichment/lib/enrichment-types.ts +39 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
- package/blocks/forms/README.md +8 -0
- package/blocks/forms/block.json +139 -18
- 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 +4 -2
- package/blocks/forms/components/form-embed.tsx +4 -2
- 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 +56 -19
- 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 +17 -6
- package/blocks/forms/lib/forms-extra.ts +67 -0
- package/blocks/inbox/README.md +32 -3
- package/blocks/inbox/block.json +29 -2
- package/blocks/inbox/components/inbox-list.tsx +7 -3
- package/blocks/inbox/components/inbox-screen.tsx +294 -27
- package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
- package/blocks/inbox/components/reply-composer.tsx +65 -9
- package/blocks/inbox/components/tag-and-assign.tsx +4 -2
- package/blocks/inbox/components/thread-badge.tsx +2 -2
- package/blocks/inbox/components/thread-view.tsx +1 -1
- package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
- package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
- package/blocks/inbox/lib/inbox-types.ts +47 -1
- package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
- package/blocks/integrations/README.md +63 -1
- package/blocks/integrations/block.json +234 -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 +119 -0
- 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 +13 -8
- package/blocks/integrations/components/integrations-screen.tsx +152 -0
- 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 +11 -9
- package/blocks/integrations/components/sync-triggers.tsx +134 -0
- package/blocks/integrations/components/webhook-detail.tsx +179 -0
- package/blocks/integrations/components/webhook-manager.tsx +43 -25
- 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 +11 -1
- package/blocks/integrations/lib/load-more.tsx +141 -0
- package/blocks/intent/README.md +14 -0
- package/blocks/intent/block.json +27 -1
- package/blocks/intent/components/account-intent-score.tsx +5 -5
- package/blocks/intent/components/intent-feed.tsx +13 -10
- package/blocks/intent/components/intent-keyword-manager.tsx +16 -12
- package/blocks/intent/components/intent-screen.tsx +265 -0
- package/blocks/intent/components/keyword-detail.tsx +124 -0
- package/blocks/intent/lib/intent-utils.ts +4 -4
- package/blocks/lifecycle/block.json +13 -8
- 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/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +3 -1
- package/blocks/lifecycle/lib/lifecycle.ts +1 -1
- package/blocks/lists/README.md +20 -1
- package/blocks/lists/block.json +69 -3
- package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
- package/blocks/lists/components/add-to-list-button.tsx +20 -9
- package/blocks/lists/components/list-picker.tsx +17 -3
- package/blocks/lists/components/list-table.tsx +12 -6
- package/blocks/lists/components/lists-screen.tsx +346 -0
- package/blocks/lists/components/record-lists-tab.tsx +99 -0
- package/blocks/lists/components/suppression-manager.tsx +33 -8
- package/blocks/lists/components/suppressions-screen.tsx +264 -0
- package/blocks/mailboxes/README.md +17 -0
- package/blocks/mailboxes/block.json +69 -17
- package/blocks/mailboxes/components/connect-mailbox.tsx +32 -21
- package/blocks/mailboxes/components/domain-detail.tsx +148 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +20 -14
- package/blocks/mailboxes/components/mailbox-detail.tsx +157 -0
- package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
- package/blocks/mailboxes/components/mailboxes-screen.tsx +228 -0
- package/blocks/mailboxes/components/warmup-health.tsx +24 -22
- package/blocks/mailboxes/lib/mailboxes-utils.ts +26 -5
- package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
- package/blocks/newsletter/README.md +12 -0
- package/blocks/newsletter/block.json +157 -24
- package/blocks/newsletter/components/issue-scheduler.tsx +4 -3
- 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 +39 -14
- 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/block.json +28 -14
- package/blocks/nurture/components/nurture-builder.tsx +13 -3
- package/blocks/nurture/components/nurture-enroll.tsx +12 -10
- package/blocks/nurture/components/nurture-form.tsx +27 -10
- package/blocks/nurture/components/nurture-screen.tsx +26 -11
- package/blocks/nurture/components/nurture-stats.tsx +5 -4
- package/blocks/nurture/lib/nurture-types.ts +1 -1
- package/blocks/objects/README.md +18 -1
- package/blocks/objects/block.json +42 -21
- package/blocks/objects/components/attribute-field-input.tsx +14 -1
- package/blocks/objects/components/object-not-found.tsx +3 -1
- package/blocks/objects/components/object-record-detail.tsx +10 -2
- package/blocks/objects/components/object-schema-editor.tsx +4 -2
- package/blocks/objects/components/object-table.tsx +13 -3
- package/blocks/objects/components/objects-screen.tsx +147 -0
- package/blocks/objects/components/record-roles-panel.tsx +15 -5
- package/blocks/objects/lib/object-types.ts +1 -1
- package/blocks/pages/README.md +14 -0
- package/blocks/pages/block.json +140 -23
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +9 -4
- 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-list.tsx +2 -1
- 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 +65 -13
- package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
- package/blocks/quotes/components/product-picker.tsx +3 -1
- 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 +8 -4
- package/blocks/quotes/components/quote-viewer.tsx +7 -2
- package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
- package/blocks/radar/README.md +10 -0
- package/blocks/radar/block.json +41 -1
- package/blocks/radar/components/competitor-battle-card.tsx +1 -1
- package/blocks/radar/components/competitor-detail.tsx +117 -0
- package/blocks/radar/components/competitor-tracker.tsx +3 -1
- package/blocks/radar/components/radar-overview.tsx +4 -4
- package/blocks/radar/components/radar-report.tsx +4 -2
- package/blocks/radar/components/radar-screen.tsx +214 -0
- package/blocks/radar/components/report-detail.tsx +104 -0
- package/blocks/search/README.md +10 -0
- package/blocks/search/block.json +16 -1
- package/blocks/search/components/filter-builder.tsx +3 -3
- package/blocks/search/components/prospect-results.tsx +5 -3
- package/blocks/search/components/prospect-search-screen.tsx +235 -0
- package/blocks/search/components/saved-searches.tsx +4 -1
- package/blocks/search/hooks/use-filter-options.ts +20 -6
- package/blocks/search/lib/search-types.ts +2 -0
- package/blocks/sequencer/README.md +55 -9
- package/blocks/sequencer/block.json +126 -19
- package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +11 -5
- package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
- package/blocks/sequencer/components/sequence-builder.tsx +224 -60
- package/blocks/sequencer/components/sequence-detail.tsx +261 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +116 -0
- package/blocks/sequencer/components/sequence-list.tsx +34 -9
- package/blocks/sequencer/components/sequence-stats.tsx +11 -10
- package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
- package/blocks/sequencer/components/sequences-screen.tsx +162 -0
- package/blocks/sequencer/components/step-editor.tsx +14 -12
- package/blocks/sequencer/lib/sequencer-utils.ts +133 -10
- package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
- package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
- package/blocks/social-ads/README.md +19 -0
- package/blocks/social-ads/block.json +233 -1
- package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
- package/blocks/social-ads/components/ads-screen.tsx +152 -0
- package/blocks/social-ads/components/audience-sync-detail.tsx +194 -0
- package/blocks/social-ads/components/audience-sync.tsx +17 -9
- package/blocks/social-ads/components/campaign-ads.tsx +166 -0
- package/blocks/social-ads/components/campaign-detail.tsx +138 -0
- 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 +155 -0
- package/blocks/social-ads/components/creative-gallery.tsx +35 -7
- 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 +7 -3
- package/blocks/social-ads/components/post-detail.tsx +163 -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-post-list.tsx +11 -3
- package/blocks/social-ads/components/social-screen.tsx +187 -0
- 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/README.md +10 -0
- package/blocks/visitors/block.json +25 -1
- package/blocks/visitors/components/snippet-install.tsx +21 -10
- package/blocks/visitors/components/traffic-chart.tsx +4 -4
- package/blocks/visitors/components/visitor-company-card.tsx +26 -16
- package/blocks/visitors/components/visitor-detail.tsx +105 -0
- package/blocks/visitors/components/visitor-feed.tsx +16 -7
- package/blocks/visitors/components/visitors-screen.tsx +166 -0
- package/blocks/webchat/README.md +10 -0
- package/blocks/webchat/block.json +21 -7
- package/blocks/webchat/components/chat-widget-config.tsx +8 -5
- package/blocks/webchat/components/conversation-list.tsx +8 -4
- package/blocks/webchat/components/webchat-screen.tsx +245 -0
- package/blocks/workbench/README.md +11 -0
- package/blocks/workbench/block.json +48 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +5 -3
- package/blocks/workbench/components/run-progress.tsx +1 -1
- package/blocks/workbench/components/workbench-grid.tsx +10 -4
- package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
- package/blocks/workbench/components/workbench-screen.tsx +202 -0
- package/blocks/workbench/components/workbench-sources.tsx +194 -0
- package/blocks/workbench/components/workbench-table-list.tsx +14 -2
- package/blocks/workflows/README.md +13 -0
- package/blocks/workflows/block.json +28 -9
- package/blocks/workflows/components/run-history.tsx +31 -9
- package/blocks/workflows/components/trigger-config.tsx +3 -1
- package/blocks/workflows/components/workflow-editor.tsx +4 -2
- package/blocks/workflows/components/workflow-list.tsx +13 -6
- package/blocks/workflows/components/workflows-screen.tsx +193 -0
- package/dist/cli.js +3109 -1977
- package/dist/errors.d.mts +34 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +46 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.mjs +21 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.browser.js +11 -5
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +11 -5
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +164 -25
- package/dist/index.d.ts +164 -25
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +10 -4
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +10 -4
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +142 -5
- package/dist/react.d.ts +142 -5
- package/dist/react.js +10 -4
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +10 -4
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/add.d.ts +11 -4
- package/dist/types/blocks/brand.d.ts +122 -0
- package/dist/types/blocks/compose.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +27 -20
- package/dist/types/blocks/doctor.d.ts +6 -1
- package/dist/types/blocks/flows.d.ts +7 -1
- package/dist/types/blocks/navfile.d.ts +35 -0
- package/dist/types/blocks/orgBrand.d.ts +34 -0
- package/dist/types/blocks/paths.d.ts +4 -0
- package/dist/types/blocks/prefixes.d.ts +37 -0
- package/dist/types/blocks/registry.d.ts +17 -0
- package/dist/types/blocks/screens.d.ts +53 -0
- package/dist/types/cli.d.ts +5 -0
- package/dist/types/errors.d.ts +33 -0
- package/dist/types/http.d.ts +2 -18
- package/dist/types/version.d.ts +2 -2
- package/docs/agents.md +15 -4
- package/docs/api/README.md +2 -2
- package/docs/api/by-path.md +1 -0
- package/docs/api/desk.d.ts +98 -2
- package/docs/api/desk.md +123 -1
- package/docs/api/inbox.d.ts +10 -2
- package/docs/api/inbox.md +10 -2
- package/docs/api/seo.d.ts +19 -1
- package/docs/api/seo.md +24 -1
- package/package.json +16 -2
- 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 +55 -17
- package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
- package/stacks/apollo/pages/companies.tsx.tmpl +33 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +51 -0
- 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 +147 -37
- 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 +64 -0
- package/stacks/clay/pages/companies.tsx.tmpl +31 -0
- package/stacks/clay/pages/contacts.tsx.tmpl +31 -0
- package/stacks/clay/pages/lists.tsx.tmpl +12 -0
- package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
- package/stacks/clay.json +73 -27
- 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 +98 -35
- 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 +104 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +26 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +45 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +20 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +103 -0
- 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/quotes.tsx.tmpl +11 -0
- package/stacks/hubspot/pages/sequences.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
- package/stacks/hubspot.json +181 -64
- 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 +52 -20
- 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 +68 -34
- 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 +46 -0
- 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 +106 -25
- package/stacks/pipedrive/pages/companies.tsx.tmpl +23 -0
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +27 -0
- package/stacks/pipedrive/pages/deals.tsx.tmpl +20 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +103 -0
- package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
- package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
- package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
- package/stacks/pipedrive.json +117 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +26 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +32 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +20 -0
- package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
- package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
- package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
- package/stacks/salesforce.json +85 -31
- package/stacks/zoominfo/pages/companies.tsx.tmpl +31 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +31 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +12 -0
- package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +115 -0
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +40 -9
- package/templates/next-app/app/globals.css.tmpl +349 -64
- package/templates/next-app/app/layout.tsx.tmpl +7 -2
- package/templates/next-app/components/app-shell.tsx.tmpl +104 -88
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AccountNps: how the people at one company scored you.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. listCompanies, through CompanyPicker read find the company by name (skipped when you pass `companyId`)
|
|
8
|
+
* 2. getCompanySurveyNps (GET /feedback/companies/{company_id}/nps) read account NPS, split, coverage and each contact's latest score
|
|
9
|
+
* Pass `companyId` to show a company you already have open (for example in a company page). The account score is hidden by the API when there are too few answers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { Label, Skeleton } from "@graph8/sdk/ui";
|
|
14
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
15
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
16
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
17
|
+
import { StatStrip } from "@/g8/components/screen/stat-strip";
|
|
18
|
+
import { CompanyPicker } from "@/g8/pickers/components/company-picker";
|
|
19
|
+
import type { CompanyOption } from "@/g8/pickers/components/company-picker";
|
|
20
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
21
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
|
+
import { plural } from "@/g8/lib/plural";
|
|
23
|
+
import { bandOf, fullName, percent, scoreText, when } from "../lib/surveys-types";
|
|
24
|
+
|
|
25
|
+
interface CompanyNps {
|
|
26
|
+
company_id: number;
|
|
27
|
+
nps_score?: number | null;
|
|
28
|
+
suppressed: boolean;
|
|
29
|
+
promoters: number;
|
|
30
|
+
passives: number;
|
|
31
|
+
detractors: number;
|
|
32
|
+
total_respondents: number;
|
|
33
|
+
coverage: { rate: number; responded: number; total_contacts: number };
|
|
34
|
+
contacts: { contact_id: number; first_name?: string | null; last_name?: string | null; score?: number | null; classification?: string | null; responded?: boolean; last_responded_at?: string | null }[];
|
|
35
|
+
}
|
|
36
|
+
export interface AccountNpsProps { companyId?: number; className?: string }
|
|
37
|
+
|
|
38
|
+
export function AccountNps({ companyId, className }: AccountNpsProps) {
|
|
39
|
+
const [picked, setPicked] = useState<CompanyOption | null>(null);
|
|
40
|
+
const id = companyId ?? picked?.id;
|
|
41
|
+
const nps = useG8Query((signal) => g8Get<CompanyNps>(`/feedback/companies/${id}/nps`, {}, { signal }).then((r) => r.data), [id], { enabled: id != null, key: ["surveys", "company-nps", id ?? ""] });
|
|
42
|
+
const d = nps.data;
|
|
43
|
+
return (
|
|
44
|
+
<SectionCard title="Account NPS" description="How the people at one company scored you." className={className}>
|
|
45
|
+
<div className="grid gap-3">
|
|
46
|
+
{companyId == null ? <div><Label htmlFor="g8-sva-company" className="mb-1">Company</Label><CompanyPicker id="g8-sva-company" value={picked} onChange={setPicked} /></div> : null}
|
|
47
|
+
{id == null ? <EmptyState compact title="Choose a company" description="Its NPS, coverage and each contact's latest score appear here." /> : nps.error ? <ApiError error={nps.error} title="Could not load the account NPS" onRetry={() => void nps.refetch()} /> : !d ? <Skeleton className="h-24 w-full" aria-busy="true" /> : (
|
|
48
|
+
<>
|
|
49
|
+
<StatStrip items={[
|
|
50
|
+
{ label: "Account NPS", value: d.suppressed ? "-" : scoreText(d.nps_score, 0), hint: d.suppressed ? "Hidden: too few answers to publish a score." : `${plural(d.total_respondents, "person", "people")} answered.` },
|
|
51
|
+
{ label: "Coverage", value: percent(d.coverage.responded, d.coverage.total_contacts), hint: `${d.coverage.responded} of ${d.coverage.total_contacts} contacts answered.` },
|
|
52
|
+
{ label: "Split", value: `${d.promoters} / ${d.passives} / ${d.detractors}`, hint: "Promoters, passives, detractors." },
|
|
53
|
+
]} />
|
|
54
|
+
{d.contacts.length === 0 ? <p className="text-muted-foreground text-sm">No contact at this company has answered.</p> : (
|
|
55
|
+
<ul className="grid gap-1 text-sm">
|
|
56
|
+
{d.contacts.map((c) => (
|
|
57
|
+
<li key={c.contact_id} className="flex items-center justify-between gap-2 rounded-md border p-2">
|
|
58
|
+
<span>{fullName(c)}</span>
|
|
59
|
+
<span className="text-muted-foreground text-xs">{c.responded && c.score != null ? `${c.score} · ${bandOf("nps", c.score, c.classification)} · ${when(c.last_responded_at)}` : "No answer yet"}</span>
|
|
60
|
+
</li>
|
|
61
|
+
))}
|
|
62
|
+
</ul>
|
|
63
|
+
)}
|
|
64
|
+
</>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
</SectionCard>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ContactSurveyHistory: every survey answer one person has given, newest first.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. listContacts, through ContactPicker read find the person by name (skipped when you pass `contactId`)
|
|
8
|
+
* 2. getContactSurveyHistory (GET /feedback/contacts/{contact_id}/history) read their answers across all surveys
|
|
9
|
+
* Pass `contactId` to show a person you already have open (for example in a contact page).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { Label, Skeleton } from "@graph8/sdk/ui";
|
|
14
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
15
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
16
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
17
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
18
|
+
import { ContactPicker } from "@/g8/pickers/components/contact-picker";
|
|
19
|
+
import type { ContactOption } from "@/g8/pickers/components/contact-picker";
|
|
20
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
21
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
|
+
import { bandOf, when } from "../lib/surveys-types";
|
|
23
|
+
|
|
24
|
+
interface ContactHistory { contact_id: number; responses: { survey_id: string; survey_name?: string | null; type: string; score: number; classification?: string | null; channel?: string | null; responded_at?: string | null }[] }
|
|
25
|
+
|
|
26
|
+
export interface ContactSurveyHistoryProps { contactId?: number; className?: string }
|
|
27
|
+
|
|
28
|
+
export function ContactSurveyHistory({ contactId, className }: ContactSurveyHistoryProps) {
|
|
29
|
+
const [picked, setPicked] = useState<ContactOption | null>(null);
|
|
30
|
+
const id = contactId ?? picked?.id;
|
|
31
|
+
const history = useG8Query((signal) => g8Get<ContactHistory>(`/feedback/contacts/${id}/history`, {}, { signal }).then((r) => r.data), [id], { enabled: id != null, key: ["surveys", "contact-history", id ?? ""] });
|
|
32
|
+
const rows = history.data?.responses ?? [];
|
|
33
|
+
return (
|
|
34
|
+
<SectionCard title="A person's survey history" description="Every survey this person has answered, newest first." className={className}>
|
|
35
|
+
<div className="grid gap-3">
|
|
36
|
+
{contactId == null ? <div><Label htmlFor="g8-sva-contact" className="mb-1">Contact</Label><ContactPicker id="g8-sva-contact" value={picked} onChange={setPicked} /></div> : null}
|
|
37
|
+
{id == null ? <EmptyState compact title="Choose a contact" description="Their answers across all surveys appear here." /> : history.error ? <ApiError error={history.error} title="Could not load the history" onRetry={() => void history.refetch()} /> : !history.data ? <Skeleton className="h-16 w-full" aria-busy="true" /> : rows.length === 0 ? (
|
|
38
|
+
<p className="text-muted-foreground text-sm">This person has not answered a survey.</p>
|
|
39
|
+
) : (
|
|
40
|
+
<ul className="grid gap-1 text-sm">
|
|
41
|
+
{rows.map((r, i) => (
|
|
42
|
+
<li key={`${r.survey_id}:${i}`} className="flex items-center justify-between gap-2 rounded-md border p-2">
|
|
43
|
+
<span className="min-w-0 truncate">{r.survey_name || r.type.toUpperCase()} <span className="text-muted-foreground text-xs">{when(r.responded_at)}</span></span>
|
|
44
|
+
<span className="flex items-center gap-2">Score {r.score}<StatusPill tone={r.classification === "promoter" || r.classification === "satisfied" ? "positive" : r.classification === "detractor" || r.classification === "dissatisfied" ? "negative" : "neutral"}>{bandOf(r.type, r.score, r.classification)}</StatusPill></span>
|
|
45
|
+
</li>
|
|
46
|
+
))}
|
|
47
|
+
</ul>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
</SectionCard>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OrgSummary: org-wide NPS and CSAT across every survey, optionally for one audience list, with a rollup per survey.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. getSurveyOrgSummary (GET /feedback/org-summary?segment&bucket) read headline scores, split and per-survey rollup
|
|
8
|
+
* 2. listLists (GET /lists), through ListSelect read the optional audience filter
|
|
9
|
+
* A dash is "not enough answers", never a zero score.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { Skeleton } from "@graph8/sdk/ui";
|
|
14
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
15
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
16
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
17
|
+
import { StatStrip } from "@/g8/components/screen/stat-strip";
|
|
18
|
+
import { ListSelect } from "@/g8/crm/components/list-select";
|
|
19
|
+
import type { ListSelectOption } from "@/g8/crm/components/list-select";
|
|
20
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
21
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
|
+
import { plural } from "@/g8/lib/plural";
|
|
23
|
+
import { BUCKETS, scoreText } from "../lib/surveys-types";
|
|
24
|
+
import type { Bucket, OrgSummary as OrgSummaryData } from "../lib/surveys-types";
|
|
25
|
+
|
|
26
|
+
const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
|
|
27
|
+
|
|
28
|
+
export interface OrgSummaryProps {
|
|
29
|
+
/** Called with a survey id when a row of the rollup is chosen. */
|
|
30
|
+
onOpenSurvey?: (surveyId: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function OrgSummary({ onOpenSurvey }: OrgSummaryProps) {
|
|
34
|
+
const [bucket, setBucket] = useState<Bucket>("month");
|
|
35
|
+
const [segment, setSegment] = useState<ListSelectOption | null>(null);
|
|
36
|
+
const org = useG8Query(
|
|
37
|
+
(signal) => g8Get<OrgSummaryData>("/feedback/org-summary", { bucket, segment: segment ? String(segment.id) : undefined }, { signal }).then((r) => r.data),
|
|
38
|
+
[bucket, segment?.id],
|
|
39
|
+
{ key: ["surveys", "summary", "org", bucket, segment?.id ?? ""] },
|
|
40
|
+
);
|
|
41
|
+
const d = org.data;
|
|
42
|
+
return (
|
|
43
|
+
<div className="grid gap-4">
|
|
44
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
45
|
+
<div className="min-w-56">
|
|
46
|
+
<label htmlFor="g8-svo-segment" className="text-muted-foreground mb-1 block text-sm">Audience</label>
|
|
47
|
+
<ListSelect id="g8-svo-segment" label="Audience" emptyOption="All contacts" value={segment?.id ?? ""} valueLabel={segment?.title} selectClassName={SELECT} onChange={setSegment} />
|
|
48
|
+
</div>
|
|
49
|
+
<div>
|
|
50
|
+
<label htmlFor="g8-svo-bucket" className="text-muted-foreground mb-1 block text-sm">Trend by</label>
|
|
51
|
+
<select id="g8-svo-bucket" className={SELECT} value={bucket} onChange={(e) => setBucket(e.target.value as Bucket)}>{BUCKETS.map((b) => <option key={b.value} value={b.value}>{b.label}</option>)}</select>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
{org.error ? <ApiError error={org.error} title="Could not load the overview" onRetry={() => void org.refetch()} /> : !d ? <Skeleton className="h-32 w-full" aria-busy="true" /> : (
|
|
55
|
+
<>
|
|
56
|
+
<StatStrip
|
|
57
|
+
items={[
|
|
58
|
+
{ label: "Org NPS", value: scoreText(d.org_nps.nps_score, 0), hint: `${plural(d.org_nps.total_respondents, "answer")}. A dash means too few answers.` },
|
|
59
|
+
{ label: "Promoters / passives / detractors", value: `${d.org_nps.promoters} / ${d.org_nps.passives} / ${d.org_nps.detractors}` },
|
|
60
|
+
{ label: "Org CSAT", value: scoreText(d.org_csat.csat_avg, 1), hint: `${plural(d.org_csat.total_respondents, "answer")}, scored 1 to 5.` },
|
|
61
|
+
{ label: "In-app CSAT", value: scoreText(d.in_app_csat_avg, 1), hint: `${plural(d.in_app_respondents ?? 0, "team answer")}.` },
|
|
62
|
+
]}
|
|
63
|
+
/>
|
|
64
|
+
<SectionCard title="By survey" description={plural(d.per_survey.length, "survey")} flush>
|
|
65
|
+
{d.per_survey.length === 0 ? <EmptyState title="No surveys yet" description="Create a survey to start collecting scores." compact /> : (
|
|
66
|
+
<ul className="grid">
|
|
67
|
+
{d.per_survey.map((p) => (
|
|
68
|
+
<li key={p.survey_id} className="flex flex-wrap items-center justify-between gap-2 border-t p-3 text-sm first:border-t-0">
|
|
69
|
+
{onOpenSurvey ? <button type="button" className="min-w-0 text-left font-medium underline-offset-2 hover:underline" aria-label={`Open ${p.name ?? p.type}`} onClick={() => onOpenSurvey(p.survey_id)}>{p.name || p.type.toUpperCase()}</button> : <span className="font-medium">{p.name || p.type.toUpperCase()}</span>}
|
|
70
|
+
<span className="text-muted-foreground text-xs">
|
|
71
|
+
{p.type === "nps" ? `NPS ${scoreText(p.nps_score, 0)}` : `CSAT ${scoreText(p.csat_avg, 1)}`} · {plural(p.total_respondents, "answer")}{p.sent_count != null ? ` of ${p.sent_count.toLocaleString()} asked` : ""}
|
|
72
|
+
</span>
|
|
73
|
+
</li>
|
|
74
|
+
))}
|
|
75
|
+
</ul>
|
|
76
|
+
)}
|
|
77
|
+
</SectionCard>
|
|
78
|
+
</>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SendingDomains: the domains surveys may be emailed from, with their verification state, and a way to verify a new one.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. listSurveySendingDomains (GET /feedback/sending-domains?include_unverified=true) read every domain with its state
|
|
8
|
+
* 2. verifySurveyDomain (POST /feedback/sending-domains/verify) external starts verification and returns the DNS records to publish: ConfirmSpendDialog
|
|
9
|
+
* 3. surveyDomainStatus (GET /feedback/sending-domains/status?domain) read checks whether the domain has verified yet (this is what promotes it to the verified list)
|
|
10
|
+
* A domain only turns verified after the DNS records are published at your DNS provider; this panel cannot do that for you.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState } from "react";
|
|
14
|
+
import { Globe, RefreshCw } from "lucide-react";
|
|
15
|
+
import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
|
|
16
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
17
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
18
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
19
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
20
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
21
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
22
|
+
import type { Tone } from "@/g8/components/screen/status-pill";
|
|
23
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
24
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
25
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
26
|
+
import type { SendingDomain } from "../lib/surveys-types";
|
|
27
|
+
|
|
28
|
+
const TONE: Record<SendingDomain["status"], Tone> = { verified: "positive", pending: "warning", not_set_up: "neutral" };
|
|
29
|
+
const LABEL: Record<SendingDomain["status"], string> = { verified: "verified", pending: "waiting for DNS", not_set_up: "not set up" };
|
|
30
|
+
const DOMAIN_RE = /^(?=.{4,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i;
|
|
31
|
+
|
|
32
|
+
/** The open payload the API returns for a verification: records (when present) as rows, every other scalar as a fact. */
|
|
33
|
+
function Payload({ data }: { data: Record<string, unknown> }) {
|
|
34
|
+
const records = Array.isArray(data.records) ? (data.records as Record<string, unknown>[]) : Array.isArray(data.dns_records) ? (data.dns_records as Record<string, unknown>[]) : [];
|
|
35
|
+
const scalars = Object.entries(data).filter(([, v]) => v != null && typeof v !== "object");
|
|
36
|
+
return (
|
|
37
|
+
<div className="grid gap-3">
|
|
38
|
+
{scalars.length ? <KeyValueList columns={2} items={scalars.map(([k, v]) => ({ label: k.replace(/_/g, " "), value: String(v) }))} /> : null}
|
|
39
|
+
{records.length ? (
|
|
40
|
+
<div>
|
|
41
|
+
<h3 className="mb-1 text-sm font-medium">Publish these DNS records</h3>
|
|
42
|
+
<ul className="grid gap-1 text-sm">{records.map((r, i) => <li key={i} className="rounded-md border p-2 font-mono text-xs break-all">{Object.entries(r).filter(([, v]) => v != null && typeof v !== "object").map(([k, v]) => `${k}: ${String(v)}`).join(" ")}</li>)}</ul>
|
|
43
|
+
</div>
|
|
44
|
+
) : null}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function SendingDomains() {
|
|
50
|
+
const [domain, setDomain] = useState("");
|
|
51
|
+
const [confirm, setConfirm] = useState(false);
|
|
52
|
+
const [result, setResult] = useState<{ domain: string; data: Record<string, unknown> } | null>(null);
|
|
53
|
+
const [checking, setChecking] = useState<string | null>(null);
|
|
54
|
+
|
|
55
|
+
const domains = useG8Query((signal) => g8Get<SendingDomain[]>("/feedback/sending-domains", { include_unverified: true }, { signal }).then((r) => r.data ?? []), [], { key: ["surveys", "domains", "all"] });
|
|
56
|
+
const clean = domain.trim().toLowerCase();
|
|
57
|
+
const valid = DOMAIN_RE.test(clean);
|
|
58
|
+
|
|
59
|
+
const verify = useG8Mutation(async (): Promise<Record<string, unknown>> => (await g8Post<Record<string, unknown>>("/feedback/sending-domains/verify", { domain: clean })).data ?? {}, {
|
|
60
|
+
onSuccess: (data) => { setConfirm(false); setResult({ domain: clean, data }); setDomain(""); },
|
|
61
|
+
invalidates: [["surveys", "domains"]],
|
|
62
|
+
});
|
|
63
|
+
const check = useG8Mutation(async (d: string) => {
|
|
64
|
+
setChecking(d);
|
|
65
|
+
try {
|
|
66
|
+
return { domain: d, data: (await g8Get<Record<string, unknown>>("/feedback/sending-domains/status", { domain: d })).data ?? {} };
|
|
67
|
+
} finally {
|
|
68
|
+
setChecking(null);
|
|
69
|
+
}
|
|
70
|
+
}, { onSuccess: (r) => setResult(r), invalidates: [["surveys", "domains"]] });
|
|
71
|
+
|
|
72
|
+
const rows = domains.data ?? [];
|
|
73
|
+
return (
|
|
74
|
+
<div className="grid gap-4">
|
|
75
|
+
<SectionCard title="Sending domains" description="A survey email must come from an address on a verified domain.">
|
|
76
|
+
{domains.error ? <ApiError error={domains.error} title="Could not load sending domains" onRetry={() => void domains.refetch()} /> : domains.loading && !domains.data ? <Skeleton className="h-16 w-full" aria-busy="true" /> : rows.length === 0 ? (
|
|
77
|
+
<EmptyState icon={<Globe className="size-8" />} title="No sending domains yet" description="Verify a domain below, publish its DNS records, then check its status." />
|
|
78
|
+
) : (
|
|
79
|
+
<ul className="grid gap-2">
|
|
80
|
+
{rows.map((d) => (
|
|
81
|
+
<li key={d.id} className="flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 text-sm">
|
|
82
|
+
<span className="font-medium">{d.domain}</span>
|
|
83
|
+
<span className="flex items-center gap-2">
|
|
84
|
+
<StatusPill tone={TONE[d.status]}>{LABEL[d.status]}</StatusPill>
|
|
85
|
+
{d.status !== "verified" ? <Button size="sm" variant="outline" aria-label={`Check status of ${d.domain}`} loading={checking === d.domain} disabled={checking !== null} onClick={() => void check.mutate(d.domain)}><RefreshCw className="size-4" />Check status</Button> : null}
|
|
86
|
+
</span>
|
|
87
|
+
</li>
|
|
88
|
+
))}
|
|
89
|
+
</ul>
|
|
90
|
+
)}
|
|
91
|
+
{check.error ? <div className="mt-2"><ApiError error={check.error} title="Could not check the domain" /></div> : null}
|
|
92
|
+
</SectionCard>
|
|
93
|
+
<SectionCard title="Verify a domain" description="Starts verification and shows the DNS records to publish at your DNS provider.">
|
|
94
|
+
<form className="flex flex-wrap items-end gap-2" onSubmit={(e) => { e.preventDefault(); if (valid) setConfirm(true); }}>
|
|
95
|
+
<div className="min-w-64 flex-1"><Label htmlFor="g8-svd-domain" className="mb-1">Domain</Label><Input id="g8-svd-domain" value={domain} placeholder="feedback.yourcompany.com" onChange={(e) => setDomain(e.target.value)} /></div>
|
|
96
|
+
<Button type="submit" disabled={!valid}>Verify domain</Button>
|
|
97
|
+
</form>
|
|
98
|
+
{domain.trim() && !valid ? <p role="alert" className="text-error mt-1 text-xs">Enter a domain such as feedback.yourcompany.com, without http:// or an address.</p> : null}
|
|
99
|
+
</SectionCard>
|
|
100
|
+
{result ? <SectionCard title={`Result for ${result.domain}`}><Payload data={result.data} /></SectionCard> : null}
|
|
101
|
+
<ConfirmSpendDialog
|
|
102
|
+
open={confirm}
|
|
103
|
+
tier="external"
|
|
104
|
+
title={`Start verifying ${clean}?`}
|
|
105
|
+
description={<><p>This registers the domain for survey email and returns DNS records to publish. It does not send any mail. Running it again re-reads the records.</p>{verify.error ? <ApiError error={verify.error} title="Could not start verification" /> : null}</>}
|
|
106
|
+
confirmLabel="Verify"
|
|
107
|
+
confirming={verify.loading}
|
|
108
|
+
onCancel={() => setConfirm(false)}
|
|
109
|
+
onConfirm={() => void verify.mutate(undefined)}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SurveyForm: create a survey (name, type, question, channel, comment capture, thank-you line, optional scale) or edit one.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. getSurvey (GET /feedback/surveys/{survey_id}) read edit only: the FULL record is read before the form shows
|
|
8
|
+
* 2. createSurvey (POST /feedback/surveys) write a survey that is open but sends nothing until you send it
|
|
9
|
+
* 3. updateSurvey (PATCH /feedback/surveys/{survey_id}) write name, question, thank-you line and channel; only changed fields
|
|
10
|
+
* The type, scale and comment mode are fixed at creation (the API's update takes none of them); to change one, create a
|
|
11
|
+
* new survey. After a save the survey's cache entries and the survey list are refreshed, so reopening the form shows
|
|
12
|
+
* what was saved. Nothing here reaches people: sending is SurveySend's job, behind a confirmation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState } from "react";
|
|
16
|
+
import { Button, Input, Label, Skeleton, Textarea } from "@graph8/sdk/ui";
|
|
17
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
18
|
+
import { g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
19
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
20
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
21
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
22
|
+
import { COMMENT_MODES, DELIVERY_CHANNELS, SURVEY_TYPES, channelLabel, scaleOf, surveyDiff, typeLabel } from "../lib/surveys-types";
|
|
23
|
+
import type { CommentMode, DeliveryChannel, Survey, SurveyType } from "../lib/surveys-types";
|
|
24
|
+
|
|
25
|
+
export interface SurveyFormProps {
|
|
26
|
+
/** Edit this survey; omit to create a new one. */
|
|
27
|
+
survey?: Survey;
|
|
28
|
+
onSaved: (survey: Survey) => void;
|
|
29
|
+
onCancel?: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SELECT = "border-input bg-background h-9 w-full rounded-md border px-2 text-sm disabled:opacity-50";
|
|
33
|
+
|
|
34
|
+
/** Edit mode reads the full survey first: a list row may omit fields, and saving a form seeded from it would blank them. */
|
|
35
|
+
export function SurveyForm({ survey, onSaved, onCancel }: SurveyFormProps) {
|
|
36
|
+
const full = useG8Query(
|
|
37
|
+
(signal) => g8Get<Survey>(`/feedback/surveys/${encodeURIComponent(survey!.id)}`, {}, { signal }).then((r) => r.data),
|
|
38
|
+
[survey?.id],
|
|
39
|
+
{ enabled: Boolean(survey), key: ["surveys", "detail", survey?.id ?? ""], placeholderData: survey },
|
|
40
|
+
);
|
|
41
|
+
if (!survey) return <SurveyFields onSaved={onSaved} onCancel={onCancel} />;
|
|
42
|
+
if (full.error && (!full.data || full.isPlaceholderData)) return <ApiError error={full.error} title="Could not load this survey" onRetry={() => void full.refetch()} />;
|
|
43
|
+
if (!full.data || full.isPlaceholderData || full.data.id !== survey.id) return <Skeleton className="h-40 w-full" aria-label="Loading survey" />;
|
|
44
|
+
return <SurveyFields key={full.data.id} survey={full.data} onSaved={onSaved} onCancel={onCancel} />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function SurveyFields({ survey, onSaved, onCancel }: SurveyFormProps) {
|
|
48
|
+
const brand = useBrand();
|
|
49
|
+
const editing = Boolean(survey);
|
|
50
|
+
const [name, setName] = useState(survey?.name ?? "");
|
|
51
|
+
const [type, setType] = useState<SurveyType>(survey?.type === "csat" ? "csat" : "nps");
|
|
52
|
+
const [question, setQuestion] = useState(survey?.question_text ?? "");
|
|
53
|
+
const [thanks, setThanks] = useState(survey?.thank_you_copy ?? "");
|
|
54
|
+
const [channel, setChannel] = useState<DeliveryChannel>((survey?.delivery_channel as DeliveryChannel) ?? "email");
|
|
55
|
+
const [comment, setComment] = useState<CommentMode>("none");
|
|
56
|
+
const defaults = scaleOf(type);
|
|
57
|
+
const [min, setMin] = useState<string>("");
|
|
58
|
+
const [max, setMax] = useState<string>("");
|
|
59
|
+
const lo = min === "" ? defaults.min : Number(min);
|
|
60
|
+
const hi = max === "" ? defaults.max : Number(max);
|
|
61
|
+
const scaleOk = Number.isInteger(lo) && Number.isInteger(hi) && lo < hi;
|
|
62
|
+
const suggestion = type === "nps" ? `How likely are you to recommend ${brand.name} to a friend or colleague?` : "How satisfied are you with your experience?";
|
|
63
|
+
const valid = name.trim() !== "" && (editing ? question.trim() !== "" : scaleOk);
|
|
64
|
+
|
|
65
|
+
const save = useG8Mutation(async (): Promise<Survey> => {
|
|
66
|
+
if (survey) {
|
|
67
|
+
const diff = surveyDiff(survey, { name, question_text: question, thank_you_copy: thanks, delivery_channel: channel });
|
|
68
|
+
if (Object.keys(diff).length === 0) return survey;
|
|
69
|
+
return (await g8Patch<Survey>(`/feedback/surveys/${encodeURIComponent(survey.id)}`, diff)).data;
|
|
70
|
+
}
|
|
71
|
+
const body: Record<string, unknown> = { name: name.trim(), type, question_text: question.trim() || suggestion, delivery_channel: channel, comment_mode: comment };
|
|
72
|
+
if (thanks.trim()) body.thank_you_copy = thanks.trim();
|
|
73
|
+
if (lo !== defaults.min) body.scale_min = lo;
|
|
74
|
+
if (hi !== defaults.max) body.scale_max = hi;
|
|
75
|
+
return (await g8Post<Survey>("/feedback/surveys", body)).data;
|
|
76
|
+
}, { onSuccess: onSaved, invalidates: (saved) => [["surveys", "list"], ["surveys", "detail", saved.id], ["surveys", "summary"]] });
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<form className="grid gap-3" onSubmit={(e) => { e.preventDefault(); if (valid) void save.mutate(undefined); }}>
|
|
80
|
+
<div><Label htmlFor="g8-sv-name" className="mb-1">Name</Label><Input id="g8-sv-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Quarterly relationship NPS" /></div>
|
|
81
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
82
|
+
<div>
|
|
83
|
+
<Label htmlFor="g8-sv-type" className="mb-1">Survey type</Label>
|
|
84
|
+
<select id="g8-sv-type" className={SELECT} value={type} disabled={editing} onChange={(e) => { setType(e.target.value as SurveyType); setMin(""); setMax(""); }}>
|
|
85
|
+
{SURVEY_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
86
|
+
</select>
|
|
87
|
+
<p className="text-muted-foreground mt-1 text-xs">{editing ? `${typeLabel({ type })} is fixed once a survey exists.` : SURVEY_TYPES.find((t) => t.value === type)?.hint}</p>
|
|
88
|
+
</div>
|
|
89
|
+
<div>
|
|
90
|
+
<Label htmlFor="g8-sv-channel" className="mb-1">Where it is shown</Label>
|
|
91
|
+
<select id="g8-sv-channel" className={SELECT} value={channel} onChange={(e) => setChannel(e.target.value as DeliveryChannel)}>
|
|
92
|
+
{DELIVERY_CHANNELS.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
|
93
|
+
</select>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<div>
|
|
97
|
+
<Label htmlFor="g8-sv-question" className="mb-1">Question</Label>
|
|
98
|
+
<Textarea id="g8-sv-question" rows={2} value={question} placeholder={editing ? undefined : suggestion} onChange={(e) => setQuestion(e.target.value)} />
|
|
99
|
+
{!editing ? <p className="text-muted-foreground mt-1 text-xs">Left blank, the standard {typeLabel({ type })} question above is used.</p> : null}
|
|
100
|
+
</div>
|
|
101
|
+
<div><Label htmlFor="g8-sv-thanks" className="mb-1">Thank-you line (optional)</Label><Textarea id="g8-sv-thanks" rows={2} value={thanks} onChange={(e) => setThanks(e.target.value)} /></div>
|
|
102
|
+
{editing ? (
|
|
103
|
+
<p className="text-muted-foreground text-sm">
|
|
104
|
+
Answers are scored {survey!.scale_min} to {survey!.scale_max}. Comment capture ({COMMENT_MODES.find((m) => m.value === survey!.comment_mode)?.label ?? "score only"}) and the scale are fixed once a survey exists; create a new survey to change them. Shown through: {channelLabel(survey!.delivery_channel)}.
|
|
105
|
+
</p>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
108
|
+
<div>
|
|
109
|
+
<Label htmlFor="g8-sv-comment" className="mb-1">Comments</Label>
|
|
110
|
+
<select id="g8-sv-comment" className={SELECT} value={comment} onChange={(e) => setComment(e.target.value as CommentMode)}>
|
|
111
|
+
{COMMENT_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
|
112
|
+
</select>
|
|
113
|
+
</div>
|
|
114
|
+
<div><Label htmlFor="g8-sv-min" className="mb-1">Lowest score</Label><Input id="g8-sv-min" type="number" value={min} placeholder={String(defaults.min)} onChange={(e) => setMin(e.target.value)} /></div>
|
|
115
|
+
<div><Label htmlFor="g8-sv-max" className="mb-1">Highest score</Label><Input id="g8-sv-max" type="number" value={max} placeholder={String(defaults.max)} onChange={(e) => setMax(e.target.value)} /></div>
|
|
116
|
+
{!scaleOk ? <p role="alert" className="text-error text-xs sm:col-span-3">The lowest score must be a whole number below the highest.</p> : null}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
{save.error ? <ApiError error={save.error} title="Could not save the survey" /> : null}
|
|
120
|
+
<div className="flex gap-2">
|
|
121
|
+
<Button type="submit" loading={save.loading} disabled={!valid}>{editing ? "Save changes" : "Create survey"}</Button>
|
|
122
|
+
{onCancel ? <Button type="button" variant="outline" onClick={onCancel}>Cancel</Button> : null}
|
|
123
|
+
</div>
|
|
124
|
+
</form>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SurveyResponses: every answer to one survey, with search, a band filter, a detail sheet per answer and a CSV export.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* 1. getSurveyResponses (GET /feedback/surveys/{survey_id}/responses) read contact answers (one unpaged read: the API returns every answer)
|
|
8
|
+
* 2. getSurveyAppUserResponses (GET /feedback/surveys/{survey_id}/app-user-responses) read answers from your own team through the in-app widget
|
|
9
|
+
* The export is built in the browser from the rows on screen (filters applied): the API has no export endpoint.
|
|
10
|
+
* Nothing here writes or reaches anyone.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState } from "react";
|
|
14
|
+
import type { ReactNode } from "react";
|
|
15
|
+
import { Download } from "lucide-react";
|
|
16
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
17
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
18
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
19
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
20
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
21
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
22
|
+
import type { Tone } from "@/g8/components/screen/status-pill";
|
|
23
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
24
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
25
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
26
|
+
import { plural } from "@/g8/lib/plural";
|
|
27
|
+
import { bandOf, downloadText, fullName, isNps, responsesCsv, slugName, when } from "../lib/surveys-types";
|
|
28
|
+
import type { AppUserResponseRow, ResponseRow, Survey } from "../lib/surveys-types";
|
|
29
|
+
|
|
30
|
+
export interface SurveyResponsesProps {
|
|
31
|
+
survey: Pick<Survey, "id" | "type" | "name">;
|
|
32
|
+
/** Render a link to the person behind an answer (for example to your contact page). */
|
|
33
|
+
renderContact?: (row: ResponseRow) => ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
|
|
37
|
+
const BAND_TONE: Record<string, Tone> = { promoter: "positive", satisfied: "positive", passive: "neutral", neutral: "neutral", detractor: "negative", dissatisfied: "negative" };
|
|
38
|
+
|
|
39
|
+
export function SurveyResponses({ survey, renderContact }: SurveyResponsesProps) {
|
|
40
|
+
const [who, setWho] = useState<"contacts" | "team">("contacts");
|
|
41
|
+
const [search, setSearch] = useState("");
|
|
42
|
+
const [band, setBand] = useState("");
|
|
43
|
+
const [open, setOpen] = useState<ResponseRow | null>(null);
|
|
44
|
+
const nps = isNps(survey);
|
|
45
|
+
|
|
46
|
+
const contacts = useG8Query((signal) => g8Get<{ responses?: ResponseRow[] }>(`/feedback/surveys/${encodeURIComponent(survey.id)}/responses`, {}, { signal }).then((r) => r.data?.responses ?? []), [survey.id], { key: ["surveys", "responses", survey.id] });
|
|
47
|
+
const team = useG8Query((signal) => g8Get<{ responses?: AppUserResponseRow[] }>(`/feedback/surveys/${encodeURIComponent(survey.id)}/app-user-responses`, {}, { signal }).then((r) => r.data?.responses ?? []), [survey.id], { key: ["surveys", "app-responses", survey.id], enabled: who === "team" });
|
|
48
|
+
|
|
49
|
+
const q = search.trim().toLowerCase();
|
|
50
|
+
const rows = (contacts.data ?? [])
|
|
51
|
+
.filter((r) => (!band || bandOf(survey.type, r.score, r.classification) === band) && (!q || `${fullName(r)} ${r.comment ?? ""}`.toLowerCase().includes(q)))
|
|
52
|
+
.sort((a, b) => (b.responded_at ?? "").localeCompare(a.responded_at ?? ""));
|
|
53
|
+
const bands = nps ? ["promoter", "passive", "detractor"] : ["satisfied", "neutral", "dissatisfied"];
|
|
54
|
+
|
|
55
|
+
const header = (
|
|
56
|
+
<Toolbar
|
|
57
|
+
search={who === "contacts" ? { value: search, onChange: setSearch, placeholder: "Search answers" } : undefined}
|
|
58
|
+
filters={(
|
|
59
|
+
<>
|
|
60
|
+
<select aria-label="Whose answers" className={SELECT} value={who} onChange={(e) => setWho(e.target.value as "contacts" | "team")}>
|
|
61
|
+
<option value="contacts">Contacts</option><option value="team">Your team (in-app)</option>
|
|
62
|
+
</select>
|
|
63
|
+
{who === "contacts" ? (
|
|
64
|
+
<select aria-label="Answer band" className={SELECT} value={band} onChange={(e) => setBand(e.target.value)}>
|
|
65
|
+
<option value="">All answers</option>{bands.map((b) => <option key={b} value={b}>{b[0].toUpperCase() + b.slice(1)}s</option>)}
|
|
66
|
+
</select>
|
|
67
|
+
) : null}
|
|
68
|
+
</>
|
|
69
|
+
)}
|
|
70
|
+
actions={who === "contacts" ? <Button size="sm" variant="outline" disabled={rows.length === 0} onClick={() => downloadText(`${slugName(survey.name)}-responses.csv`, responsesCsv(survey.type, rows))}><Download className="size-4" />Export {plural(rows.length, "answer")} (CSV)</Button> : undefined}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (who === "team") {
|
|
75
|
+
const t = team.data ?? [];
|
|
76
|
+
return (
|
|
77
|
+
<div className="grid gap-3">
|
|
78
|
+
{header}
|
|
79
|
+
{team.error ? <ApiError error={team.error} title="Could not load team answers" onRetry={() => void team.refetch()} /> : team.loading && !team.data ? <Skeleton className="h-20 w-full" aria-busy="true" /> : t.length === 0 ? (
|
|
80
|
+
<EmptyState title="No in-app answers" description="Answers from signed-in team members appear here when the survey is shown in-app." />
|
|
81
|
+
) : (
|
|
82
|
+
<ul className="grid gap-2">{t.map((r, i) => (
|
|
83
|
+
<li key={`${r.app_user_id}:${i}`} className="rounded-md border p-3 text-sm">
|
|
84
|
+
<p className="font-medium">Score {r.score} <span className="text-muted-foreground text-xs font-normal">{when(r.responded_at)}</span></p>
|
|
85
|
+
{r.comment ? <p className="mt-1 whitespace-pre-wrap">{r.comment}</p> : null}
|
|
86
|
+
</li>
|
|
87
|
+
))}</ul>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
<div className="grid gap-3">
|
|
94
|
+
{header}
|
|
95
|
+
{contacts.error ? <ApiError error={contacts.error} title="Could not load the answers" onRetry={() => void contacts.refetch()} /> : contacts.loading && !contacts.data ? (
|
|
96
|
+
<div aria-busy="true" className="grid gap-2"><Skeleton className="h-12 w-full" /><Skeleton className="h-12 w-full" /><Skeleton className="h-12 w-full" /></div>
|
|
97
|
+
) : (contacts.data ?? []).length === 0 ? <EmptyState title="No answers yet" description="Send the survey, or wait for people to answer." /> : rows.length === 0 ? (
|
|
98
|
+
<EmptyState title="No answers match" description="Try another search or band." />
|
|
99
|
+
) : (
|
|
100
|
+
<>
|
|
101
|
+
<p role="status" className="text-muted-foreground text-xs">{rows.length === (contacts.data ?? []).length ? `All ${plural(rows.length, "answer")}` : `${rows.length.toLocaleString()} of ${plural((contacts.data ?? []).length, "answer")}`}</p>
|
|
102
|
+
<ul className="grid gap-2">
|
|
103
|
+
{rows.map((r) => {
|
|
104
|
+
const b = bandOf(survey.type, r.score, r.classification);
|
|
105
|
+
return (
|
|
106
|
+
<li key={r.contact_id} className="flex items-center justify-between gap-3 rounded-md border p-3">
|
|
107
|
+
<button type="button" className="min-w-0 flex-1 text-left" aria-label={`Open answer from ${fullName(r)}`} onClick={() => setOpen(r)}>
|
|
108
|
+
<span className="block truncate font-medium">{fullName(r)} <span className="text-muted-foreground font-normal">scored {r.score}</span></span>
|
|
109
|
+
<span className="text-muted-foreground block truncate text-xs">{r.comment || "No comment"} · {when(r.responded_at)}</span>
|
|
110
|
+
</button>
|
|
111
|
+
<StatusPill tone={BAND_TONE[b] ?? "neutral"}>{b}</StatusPill>
|
|
112
|
+
</li>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</ul>
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
<FormSheet open={open !== null} onOpenChange={(o) => { if (!o) setOpen(null); }} title={open ? fullName(open) : "Answer"} description={`Answer to ${survey.name}`}>
|
|
119
|
+
{open ? (
|
|
120
|
+
<div className="grid gap-4">
|
|
121
|
+
<KeyValueList
|
|
122
|
+
columns={2}
|
|
123
|
+
items={[
|
|
124
|
+
{ label: "Score", value: String(open.score) },
|
|
125
|
+
{ label: "Band", value: bandOf(survey.type, open.score, open.classification) },
|
|
126
|
+
{ label: "Answered", value: when(open.responded_at) },
|
|
127
|
+
{ label: "Channel", value: open.channel || "" },
|
|
128
|
+
{ label: "Times answered", value: String(open.response_count ?? 1) },
|
|
129
|
+
{ label: "Trend", value: open.trend || "" },
|
|
130
|
+
{ label: "Comment", value: open.comment || "", wide: true },
|
|
131
|
+
]}
|
|
132
|
+
/>
|
|
133
|
+
{open.history && open.history.length > 1 ? (
|
|
134
|
+
<div>
|
|
135
|
+
<h3 className="mb-1 text-sm font-medium">Earlier scores</h3>
|
|
136
|
+
<ul className="grid gap-1 text-sm">{open.history.map((h, i) => <li key={i}>{h.score} <span className="text-muted-foreground text-xs">{when(h.responded_at)}</span></li>)}</ul>
|
|
137
|
+
</div>
|
|
138
|
+
) : null}
|
|
139
|
+
{renderContact?.(open)}
|
|
140
|
+
</div>
|
|
141
|
+
) : null}
|
|
142
|
+
</FormSheet>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|