@graph8/sdk 0.362.0 → 0.370.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 +14 -10
- package/blocks/_foundation/README.md +64 -1
- package/blocks/_foundation/block.json +79 -6
- package/blocks/_foundation/components/api-error.tsx +9 -7
- package/blocks/_foundation/components/brand-provider.tsx +23 -0
- package/blocks/_foundation/components/confirmed-action.tsx +90 -0
- package/blocks/_foundation/components/connect-graph8.tsx +280 -0
- package/blocks/_foundation/components/screen/index.ts +1 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- 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-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-paginated.ts +120 -0
- package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
- package/blocks/_foundation/lib/g8-cache.ts +454 -0
- package/blocks/_foundation/lib/g8-client.ts +1 -1
- package/blocks/_foundation/lib/plural.ts +23 -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 +14 -0
- package/blocks/agents/block.json +33 -1
- package/blocks/agents/components/agent-builder.tsx +13 -8
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
- package/blocks/agents/components/agent-run-detail.tsx +102 -0
- package/blocks/agents/components/agent-runs.tsx +16 -7
- package/blocks/agents/components/agents-screen.tsx +184 -0
- package/blocks/analytics/README.md +13 -0
- package/blocks/analytics/block.json +45 -1
- package/blocks/analytics/components/analytics-screen.tsx +209 -0
- package/blocks/analytics/components/leaderboard.tsx +14 -7
- package/blocks/analytics/components/report-builder.tsx +23 -9
- package/blocks/analytics/components/report-detail.tsx +84 -0
- package/blocks/analytics/components/trend-chart.tsx +62 -0
- package/blocks/appointments/block.json +6 -2
- package/blocks/appointments/components/availability-editor.tsx +17 -5
- 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-list.tsx +9 -5
- package/blocks/appointments/components/meetings-screen.tsx +7 -4
- 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 +16 -5
- package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
- package/blocks/campaigns/components/campaign-list.tsx +7 -3
- package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
- package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
- package/blocks/campaigns/components/icp-detail.tsx +154 -0
- package/blocks/campaigns/components/icp-editor.tsx +15 -15
- package/blocks/campaigns/components/persona-detail.tsx +172 -0
- package/blocks/campaigns/components/persona-editor.tsx +14 -11
- package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
- 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/coach-screen.tsx +222 -0
- package/blocks/copilot/README.md +13 -0
- package/blocks/copilot/block.json +14 -1
- 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 +7 -5
- package/blocks/crm/README.md +28 -0
- package/blocks/crm/block.json +58 -2
- package/blocks/crm/components/companies-screen.tsx +345 -0
- package/blocks/crm/components/company-detail.tsx +8 -6
- package/blocks/crm/components/company-table.tsx +11 -4
- package/blocks/crm/components/contact-detail.tsx +6 -3
- package/blocks/crm/components/contact-table.tsx +10 -3
- package/blocks/crm/components/contacts-screen.tsx +359 -0
- package/blocks/crm/components/dedupe-review.tsx +7 -5
- package/blocks/crm/components/import-wizard.tsx +4 -2
- package/blocks/crm/components/notes-panel.tsx +4 -4
- package/blocks/crm/components/record-form.tsx +5 -3
- package/blocks/crm/components/task-list.tsx +4 -4
- package/blocks/crm/components/tasks-screen.tsx +379 -0
- package/blocks/crm/hooks/use-custom-fields.ts +6 -1
- package/blocks/crm/hooks/use-page-query.ts +7 -1
- package/blocks/crm/lib/crm-types.ts +12 -2
- package/blocks/crm/lib/screen-parts.tsx +105 -0
- package/blocks/deals/block.json +9 -3
- package/blocks/deals/components/deal-detail.tsx +23 -11
- package/blocks/deals/components/deal-related.tsx +1 -1
- package/blocks/deals/components/deals-screen.tsx +10 -5
- package/blocks/deals/components/stage-editor.tsx +4 -4
- package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/dialer/README.md +34 -2
- package/blocks/dialer/block.json +67 -1
- package/blocks/dialer/components/call-contact-button.tsx +252 -0
- package/blocks/dialer/components/call-log-screen.tsx +205 -0
- package/blocks/dialer/components/call-log.tsx +7 -3
- package/blocks/dialer/components/call-queue.tsx +1 -1
- package/blocks/dialer/components/dialer-screen.tsx +319 -0
- package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
- package/blocks/dialer/components/live-calls-table.tsx +7 -3
- package/blocks/dialer/components/session-start-form.tsx +4 -2
- package/blocks/dialer/components/softphone.tsx +3 -1
- package/blocks/enrichment/README.md +14 -2
- package/blocks/enrichment/block.json +32 -4
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
- package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
- 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 +288 -0
- package/blocks/enrichment/components/waterfall-config.tsx +39 -10
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
- package/blocks/forms/block.json +32 -18
- package/blocks/forms/components/form-builder.tsx +3 -1
- package/blocks/forms/components/form-embed.tsx +3 -1
- package/blocks/forms/components/forms-screen.tsx +5 -2
- package/blocks/forms/components/submissions-table.tsx +3 -1
- package/blocks/inbox/README.md +27 -3
- package/blocks/inbox/block.json +14 -2
- package/blocks/inbox/components/inbox-list.tsx +7 -3
- package/blocks/inbox/components/inbox-screen.tsx +294 -27
- package/blocks/inbox/components/reply-composer.tsx +65 -9
- package/blocks/inbox/components/tag-and-assign.tsx +4 -2
- 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 +9 -0
- package/blocks/integrations/block.json +46 -1
- package/blocks/integrations/components/crm-detail.tsx +100 -0
- package/blocks/integrations/components/integration-list.tsx +9 -4
- package/blocks/integrations/components/integrations-screen.tsx +135 -0
- package/blocks/integrations/components/sync-status.tsx +5 -3
- package/blocks/integrations/components/webhook-detail.tsx +160 -0
- package/blocks/integrations/components/webhook-manager.tsx +13 -8
- package/blocks/integrations/lib/integrations-utils.ts +6 -1
- package/blocks/intent/README.md +14 -0
- package/blocks/intent/block.json +27 -1
- package/blocks/intent/components/intent-feed.tsx +5 -2
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
- package/blocks/intent/components/intent-screen.tsx +265 -0
- package/blocks/intent/components/keyword-detail.tsx +124 -0
- package/blocks/lifecycle/block.json +13 -8
- 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 +13 -0
- package/blocks/lists/block.json +39 -1
- package/blocks/lists/components/add-to-list-button.tsx +3 -2
- package/blocks/lists/components/list-picker.tsx +5 -1
- package/blocks/lists/components/list-table.tsx +11 -5
- package/blocks/lists/components/lists-screen.tsx +344 -0
- package/blocks/lists/components/suppression-manager.tsx +6 -2
- package/blocks/lists/components/suppressions-screen.tsx +255 -0
- package/blocks/mailboxes/README.md +17 -0
- package/blocks/mailboxes/block.json +65 -17
- package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
- package/blocks/mailboxes/components/domain-detail.tsx +142 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
- package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
- package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
- package/blocks/mailboxes/components/warmup-health.tsx +4 -2
- package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
- package/blocks/newsletter/block.json +40 -24
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
- package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
- package/blocks/nurture/block.json +28 -14
- package/blocks/nurture/components/nurture-builder.tsx +3 -1
- package/blocks/nurture/components/nurture-enroll.tsx +6 -3
- package/blocks/nurture/components/nurture-form.tsx +23 -3
- package/blocks/nurture/components/nurture-screen.tsx +10 -6
- package/blocks/nurture/lib/nurture-types.ts +1 -1
- package/blocks/objects/README.md +17 -0
- package/blocks/objects/block.json +29 -16
- package/blocks/objects/components/object-not-found.tsx +3 -1
- package/blocks/objects/components/object-record-detail.tsx +2 -1
- package/blocks/objects/components/object-schema-editor.tsx +3 -1
- package/blocks/objects/components/object-table.tsx +13 -3
- package/blocks/objects/components/objects-screen.tsx +147 -0
- package/blocks/objects/lib/object-types.ts +1 -1
- package/blocks/pages/block.json +28 -23
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +9 -4
- package/blocks/pages/components/page-list.tsx +2 -1
- package/blocks/quotes/block.json +17 -12
- package/blocks/quotes/components/product-picker.tsx +3 -1
- package/blocks/quotes/components/quote-related.tsx +5 -2
- package/blocks/radar/README.md +10 -0
- package/blocks/radar/block.json +41 -1
- package/blocks/radar/components/competitor-detail.tsx +117 -0
- package/blocks/radar/components/competitor-tracker.tsx +3 -1
- 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 +49 -9
- package/blocks/sequencer/block.json +99 -19
- package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
- package/blocks/sequencer/components/sequence-builder.tsx +211 -46
- package/blocks/sequencer/components/sequence-detail.tsx +259 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
- package/blocks/sequencer/components/sequence-list.tsx +33 -8
- package/blocks/sequencer/components/sequence-stats.tsx +3 -2
- package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
- package/blocks/sequencer/components/sequences-screen.tsx +159 -0
- package/blocks/sequencer/components/step-editor.tsx +3 -1
- package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
- 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 +11 -0
- package/blocks/social-ads/block.json +101 -2
- 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 +188 -0
- package/blocks/social-ads/components/audience-sync.tsx +11 -7
- package/blocks/social-ads/components/campaign-detail.tsx +133 -0
- package/blocks/social-ads/components/creative-detail.tsx +150 -0
- package/blocks/social-ads/components/creative-gallery.tsx +15 -5
- package/blocks/social-ads/components/post-composer.tsx +3 -1
- package/blocks/social-ads/components/post-detail.tsx +160 -0
- package/blocks/social-ads/components/social-post-list.tsx +11 -3
- package/blocks/social-ads/components/social-screen.tsx +160 -0
- package/blocks/visitors/README.md +10 -0
- package/blocks/visitors/block.json +25 -1
- package/blocks/visitors/components/snippet-install.tsx +3 -1
- package/blocks/visitors/components/visitor-company-card.tsx +2 -2
- package/blocks/visitors/components/visitor-detail.tsx +105 -0
- package/blocks/visitors/components/visitor-feed.tsx +11 -2
- 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 +7 -4
- package/blocks/webchat/components/conversation-list.tsx +7 -3
- 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 +4 -2
- package/blocks/workbench/components/workbench-grid.tsx +9 -3
- package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
- package/blocks/workbench/components/workbench-screen.tsx +192 -0
- package/blocks/workbench/components/workbench-sources.tsx +194 -0
- 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 +3 -1
- package/blocks/workflows/components/workflow-list.tsx +13 -6
- package/blocks/workflows/components/workflows-screen.tsx +193 -0
- package/dist/cli.js +2709 -1721
- 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 +113 -0
- package/dist/types/blocks/compose.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +22 -19
- package/dist/types/blocks/doctor.d.ts +5 -1
- package/dist/types/blocks/navfile.d.ts +35 -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 +2 -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.json +41 -16
- package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
- package/stacks/apollo.json +118 -36
- package/stacks/calendly.json +60 -0
- package/stacks/clay/pages/companies.tsx.tmpl +15 -0
- package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
- package/stacks/clay.json +59 -26
- package/stacks/gong.json +81 -35
- package/stacks/hootsuite.json +99 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
- package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
- package/stacks/hubspot.json +167 -66
- package/stacks/intercom.json +48 -20
- package/stacks/mailchimp.json +52 -33
- package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
- package/stacks/outreach.json +88 -25
- package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
- package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
- package/stacks/pipedrive.json +115 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
- package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
- package/stacks/salesforce.json +81 -31
- package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +102 -0
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +40 -9
- package/templates/next-app/app/layout.tsx.tmpl +7 -2
- package/templates/next-app/components/app-shell.tsx.tmpl +99 -89
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EnrichmentScreen: the finished enrichment page, in four tabs (`?tab=`).
|
|
5
|
+
* - Enrich a list: pick a list, tick contacts, run a confirmed bulk enrichment (preflight, price, progress, cancel);
|
|
6
|
+
* open a contact to see its record and look it up or verify its email.
|
|
7
|
+
* - Look up: find one person or company by email, LinkedIn, name and domain (2 credits only when found) and verify
|
|
8
|
+
* an email address (1 credit). Nothing is written to a record without a tick.
|
|
9
|
+
* - Pipelines: the saved provider waterfalls of a list, create and delete them.
|
|
10
|
+
* - Providers: which providers graph8 funds, which need your own key, and what each action costs.
|
|
11
|
+
*
|
|
12
|
+
* Composes: ScreenLayout, StatStrip, SplitView, DetailPanel, SectionCard, KeyValueList, StatusPill (screen kit),
|
|
13
|
+
* BulkEnrichPanel, WaterfallConfig, EnrichButton, EmailVerifyBadge, ContactTable / ContactDetail (crm), ListPicker (lists).
|
|
14
|
+
* Provider keys are added in graph8 settings (no API), so the Providers tab links out for them.
|
|
15
|
+
*
|
|
16
|
+
* SDK operations (inside the parts it composes): listEnrichmentProviders, preflightEnrichment (read),
|
|
17
|
+
* startEnrichment (billable, ConfirmSpendDialog), getEnrichmentJob, cancelEnrichmentJob, lookupPerson / lookupCompany
|
|
18
|
+
* (billable, confirmed), verifyEmail (billable, confirmed), listWaterfallConfigs, createDefaultWaterfallConfig (billable,
|
|
19
|
+
* confirmed), deleteWaterfallConfig (destructive, confirmed), listContacts, getContact.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useEffect, useState } from "react";
|
|
23
|
+
import type { ReactNode } from "react";
|
|
24
|
+
import { Building2, ExternalLink, KeyRound, Sparkles, UserSearch } from "lucide-react";
|
|
25
|
+
import { Button, Input, Label } from "@graph8/sdk/ui";
|
|
26
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
27
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
28
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
29
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
30
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
31
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
32
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
33
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
34
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
35
|
+
import { ContactDetail } from "@/g8/crm/components/contact-detail";
|
|
36
|
+
import { ContactTable } from "@/g8/crm/components/contact-table";
|
|
37
|
+
import type { ContactRecord } from "@/g8/crm/lib/crm-types";
|
|
38
|
+
import { ListPicker } from "@/g8/lists/components/list-picker";
|
|
39
|
+
import { plural } from "@/g8/lib/plural";
|
|
40
|
+
import { useProviders } from "../hooks/use-providers";
|
|
41
|
+
import { CREDITS_PER_EMAIL_VERIFICATION, CREDITS_PER_FOUND_LOOKUP } from "../lib/enrichment-types";
|
|
42
|
+
import { BulkEnrichPanel } from "./bulk-enrich-panel";
|
|
43
|
+
import { EmailVerifyBadge } from "./email-verify-badge";
|
|
44
|
+
import { EnrichButton } from "./enrich-button";
|
|
45
|
+
import { EnrichmentJobDetail } from "./enrichment-job-detail";
|
|
46
|
+
import { WaterfallConfig } from "./waterfall-config";
|
|
47
|
+
import type { WaterfallConfigSummary } from "../lib/enrichment-types";
|
|
48
|
+
import { useBrand, brandUrl } from "@/g8/hooks/use-brand";
|
|
49
|
+
|
|
50
|
+
export interface EnrichmentScreenProps {
|
|
51
|
+
toolbarActions?: ReactNode;
|
|
52
|
+
/** Extra buttons in the opened contact's header. */
|
|
53
|
+
renderDetailActions?: (contact: ContactRecord) => ReactNode;
|
|
54
|
+
extraDetailTabs?: { id: string; label: string; render: (contact: ContactRecord) => ReactNode }[];
|
|
55
|
+
/** Where provider keys are managed. Default: the graph8 app. */
|
|
56
|
+
providerSettingsUrl?: string;
|
|
57
|
+
className?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const TABS = [{ id: "list", label: "Enrich a list" }, { id: "lookup", label: "Look up" }, { id: "pipelines", label: "Pipelines" }, { id: "jobs", label: "Jobs" }, { id: "providers", label: "Providers" }];
|
|
61
|
+
const FIELD = "flex flex-col gap-1";
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
const JOBS_KEY = "g8.enrichment.jobs";
|
|
65
|
+
function loadJobs(): string[] {
|
|
66
|
+
try { const v = JSON.parse(window.sessionStorage.getItem(JOBS_KEY) ?? "[]"); return Array.isArray(v) ? v.filter((x) => typeof x === "string") : []; } catch { return []; }
|
|
67
|
+
}
|
|
68
|
+
function rememberJobs(ids: string[]): void {
|
|
69
|
+
try { window.sessionStorage.setItem(JOBS_KEY, JSON.stringify(ids.slice(0, 30))); } catch { /* private mode: the list lives for this page only */ }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function PipelineDetail({ config, onClose, onExpand, onCollapse }: { config: WaterfallConfigSummary; onClose: () => void; onExpand?: () => void; onCollapse?: () => void }) {
|
|
73
|
+
const brand = useBrand();
|
|
74
|
+
return (
|
|
75
|
+
<DetailPanel onExpand={onExpand} onCollapse={onCollapse} title={config.name} subtitle={`Fills ${config.field_to_enrich ?? "a field"} in ${plural(config.step_count, "step")}`} onClose={onClose}
|
|
76
|
+
badges={config.is_active === false ? <StatusPill tone="neutral" dot>Inactive</StatusPill> : <StatusPill tone="positive" dot>Active</StatusPill>}>
|
|
77
|
+
<div className="flex flex-col gap-4 p-1">
|
|
78
|
+
{config.missing_credentials.length ? <p role="alert" className="text-sm text-amber-700">No key for {config.missing_credentials.join(", ")}: those steps are skipped when it runs. Add the keys in {brand.linkLabel} settings.</p> : null}
|
|
79
|
+
<ol className="flex flex-col gap-2" aria-label="Pipeline steps">
|
|
80
|
+
{(config.providers_detail.length ? config.providers_detail : config.providers.map((p) => ({ provider: p, is_system_provider: false, credential_source: "system" as const, credential_ready: true }))).map((p, i) => (
|
|
81
|
+
<li key={`${p.provider}-${i}`} className="flex items-center gap-2 text-sm">
|
|
82
|
+
<span className="w-6 text-muted-foreground">{i + 1}.</span><span className="w-32 font-medium">{p.provider}</span>
|
|
83
|
+
<StatusPill tone={p.credential_ready ? "positive" : "warning"} dot>{p.credential_source === "byok" ? "Your key" : `${brand.linkLabel} credits`}{p.credential_ready ? "" : ", not ready"}</StatusPill>
|
|
84
|
+
</li>
|
|
85
|
+
))}
|
|
86
|
+
</ol>
|
|
87
|
+
<KeyValueList columns={2} items={[
|
|
88
|
+
{ label: "Scope", value: config.is_global ? "All lists" : config.list_id != null ? `List #${config.list_id}` : "" },
|
|
89
|
+
{ label: "Pipeline id", value: config.column_id, mono: true, wide: true },
|
|
90
|
+
]} />
|
|
91
|
+
<p className="text-xs text-muted-foreground">Delete it from the list on the left. Values it already wrote stay on the contacts.</p>
|
|
92
|
+
</div>
|
|
93
|
+
</DetailPanel>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function JobsTab({ jobs, jobParam, setJobParam }: { jobs: string[]; jobParam: string; setJobParam: (v: string | null) => void }) {
|
|
98
|
+
const [typed, setTyped] = useState("");
|
|
99
|
+
const [view, setView] = useUrlState("view");
|
|
100
|
+
const full = view === "full";
|
|
101
|
+
const list = (
|
|
102
|
+
<div className="flex flex-col gap-3 p-1">
|
|
103
|
+
<SectionCard title="Open a job" description="Paste a job id from the API or an earlier run.">
|
|
104
|
+
<form className="flex gap-2" onSubmit={(e) => { e.preventDefault(); if (typed.trim()) setJobParam(typed.trim()); }}>
|
|
105
|
+
<Input aria-label="Job id" value={typed} onChange={(e) => setTyped(e.target.value)} placeholder="Job id" />
|
|
106
|
+
<Button type="submit" size="sm" variant="outline" disabled={!typed.trim()}>Open</Button>
|
|
107
|
+
</form>
|
|
108
|
+
</SectionCard>
|
|
109
|
+
<SectionCard title="Started in this session" flush>
|
|
110
|
+
{jobs.length === 0 ? <EmptyState title="No jobs yet" description="Enrich contacts and the job appears here, with live progress." /> : (
|
|
111
|
+
<ul className="divide-y">{jobs.map((id) => (
|
|
112
|
+
<li key={id}><button type="button" className={`w-full px-4 py-2 text-left text-sm font-mono ${id === jobParam ? "bg-muted/50" : ""}`} onClick={() => setJobParam(id)}>{id}</button></li>
|
|
113
|
+
))}</ul>
|
|
114
|
+
)}
|
|
115
|
+
</SectionCard>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
return (
|
|
119
|
+
<SplitView expanded={full} onExpandedChange={(e) => setView(e ? "full" : null)} list={list} detailOpen={Boolean(jobParam)} onCloseDetail={() => setJobParam(null)} detailTitle="Enrichment job"
|
|
120
|
+
detail={jobParam ? <DetailPanel key={jobParam} onExpand={full ? undefined : () => setView("full")} onCollapse={full ? () => setView(null) : undefined} title="Enrichment job" subtitle={jobParam} onClose={() => setJobParam(null)}><EnrichmentJobDetail jobId={jobParam} /></DetailPanel> : null} />
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ListRail({ listId, onPick }: { listId: number | null; onPick: (id: number) => void }) {
|
|
125
|
+
return (
|
|
126
|
+
<SectionCard title="List" description="The contacts must be on a list.">
|
|
127
|
+
<ListPicker value={listId} allowCreate={false} onChange={(l) => onPick(l.id)} />
|
|
128
|
+
</SectionCard>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function LookupTab() {
|
|
133
|
+
const [email, setEmail] = useState("");
|
|
134
|
+
const [linkedin, setLinkedin] = useState("");
|
|
135
|
+
const [first, setFirst] = useState("");
|
|
136
|
+
const [last, setLast] = useState("");
|
|
137
|
+
const [domain, setDomain] = useState("");
|
|
138
|
+
const [cDomain, setCDomain] = useState("");
|
|
139
|
+
const [cName, setCName] = useState("");
|
|
140
|
+
const [verifyEmail, setVerifyEmail] = useState("");
|
|
141
|
+
const [contactRecord, setContactRecord] = useState("");
|
|
142
|
+
const [companyRecord, setCompanyRecord] = useState("");
|
|
143
|
+
const [applied, setApplied] = useState<string | null>(null);
|
|
144
|
+
const person = { email: email.trim() || undefined, linkedin_url: linkedin.trim() || undefined, first_name: first.trim() || undefined, last_name: last.trim() || undefined, company_domain: domain.trim() || undefined };
|
|
145
|
+
const company = { domain: cDomain.trim() || undefined, name: cName.trim() || undefined };
|
|
146
|
+
return (
|
|
147
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
148
|
+
<SectionCard title="Find a person" description={`${CREDITS_PER_FOUND_LOOKUP} credits, only when a match is found.`} actions={<UserSearch className="size-4 text-muted-foreground" aria-hidden />}>
|
|
149
|
+
<div className="flex flex-col gap-3">
|
|
150
|
+
<div className={FIELD}><Label htmlFor="lk-email">Work email</Label><Input id="lk-email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
|
151
|
+
<div className={FIELD}><Label htmlFor="lk-li">LinkedIn URL</Label><Input id="lk-li" value={linkedin} onChange={(e) => setLinkedin(e.target.value)} /></div>
|
|
152
|
+
<div className="grid grid-cols-2 gap-2">
|
|
153
|
+
<div className={FIELD}><Label htmlFor="lk-first">First name</Label><Input id="lk-first" value={first} onChange={(e) => setFirst(e.target.value)} /></div>
|
|
154
|
+
<div className={FIELD}><Label htmlFor="lk-last">Last name</Label><Input id="lk-last" value={last} onChange={(e) => setLast(e.target.value)} /></div>
|
|
155
|
+
</div>
|
|
156
|
+
<div className={FIELD}><Label htmlFor="lk-domain">Company domain</Label><Input id="lk-domain" placeholder="acme.com" value={domain} onChange={(e) => setDomain(e.target.value)} /></div>
|
|
157
|
+
<p className="text-xs text-muted-foreground">Use an email, a LinkedIn URL, or first name, last name and company domain together.</p>
|
|
158
|
+
<div className={FIELD}><Label htmlFor="lk-rec">Apply to contact id (optional)</Label><Input id="lk-rec" inputMode="numeric" className="w-40" value={contactRecord} onChange={(e) => setContactRecord(e.target.value)} /></div>
|
|
159
|
+
<div className="flex items-center gap-2">
|
|
160
|
+
<EnrichButton kind="person" lookup={person} record={Number(contactRecord) > 0 ? { id: Number(contactRecord) } : undefined} label="Find person" variant="default" size="default" onApplied={() => setApplied(`Updated contact ${contactRecord}.`)} />
|
|
161
|
+
{applied ? <span role="status" className="text-sm">{applied}</span> : null}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</SectionCard>
|
|
165
|
+
<div className="flex flex-col gap-4">
|
|
166
|
+
<SectionCard title="Find a company" description={`${CREDITS_PER_FOUND_LOOKUP} credits, only when a match is found.`} actions={<Building2 className="size-4 text-muted-foreground" aria-hidden />}>
|
|
167
|
+
<div className="flex flex-col gap-3">
|
|
168
|
+
<div className={FIELD}><Label htmlFor="lk-cdomain">Domain</Label><Input id="lk-cdomain" placeholder="acme.com" value={cDomain} onChange={(e) => setCDomain(e.target.value)} /></div>
|
|
169
|
+
<div className={FIELD}><Label htmlFor="lk-cname">Name</Label><Input id="lk-cname" value={cName} onChange={(e) => setCName(e.target.value)} /></div>
|
|
170
|
+
<div className={FIELD}><Label htmlFor="lk-crec">Apply to company id (optional)</Label><Input id="lk-crec" inputMode="numeric" className="w-40" value={companyRecord} onChange={(e) => setCompanyRecord(e.target.value)} /></div>
|
|
171
|
+
<div><EnrichButton kind="company" lookup={company} record={Number(companyRecord) > 0 ? { id: Number(companyRecord) } : undefined} label="Find company" variant="default" size="default" /></div>
|
|
172
|
+
</div>
|
|
173
|
+
</SectionCard>
|
|
174
|
+
<SectionCard title="Verify an email" description={`${CREDITS_PER_EMAIL_VERIFICATION} credit per check.`}>
|
|
175
|
+
<div className="flex flex-col gap-3">
|
|
176
|
+
<div className={FIELD}><Label htmlFor="vf-email">Email address</Label><Input id="vf-email" value={verifyEmail} onChange={(e) => setVerifyEmail(e.target.value)} /></div>
|
|
177
|
+
<EmailVerifyBadge key={verifyEmail.trim()} email={verifyEmail.trim()} />
|
|
178
|
+
</div>
|
|
179
|
+
</SectionCard>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function ProvidersTab({ settingsUrl }: { settingsUrl: string }) {
|
|
186
|
+
const brand = useBrand();
|
|
187
|
+
const catalog = useProviders();
|
|
188
|
+
if (catalog.error) return <ApiError error={catalog.error} title="Could not load providers" onRetry={() => void catalog.refetch()} />;
|
|
189
|
+
if (!catalog.loading && catalog.providers.length === 0) return <EmptyState title="No providers" description="The provider catalog is empty for this workspace." />;
|
|
190
|
+
return (
|
|
191
|
+
<SectionCard title="Providers" description={`Providers on ${brand.linkLabel}'s key spend credits when they run. Providers on your own key cost no ${brand.linkLabel} credits.`}
|
|
192
|
+
actions={<a className="inline-flex items-center gap-1 text-sm underline" href={settingsUrl} target="_blank" rel="noreferrer"><KeyRound className="size-4" aria-hidden />Manage keys in {brand.linkLabel}<ExternalLink className="size-3" aria-hidden /></a>}>
|
|
193
|
+
<ul className="divide-y">
|
|
194
|
+
{catalog.providers.map((p) => (
|
|
195
|
+
<li key={p.provider} className="flex flex-wrap items-center gap-3 py-2" data-testid="provider-row">
|
|
196
|
+
<span className="w-36 text-sm font-medium">{p.provider}</span>
|
|
197
|
+
{p.byok_configured ? <StatusPill tone="positive" dot>Your key set</StatusPill> : p.system_funded ? <StatusPill tone="info" dot>{brand.linkLabel} funded</StatusPill> : <StatusPill tone="warning" dot>Needs your key</StatusPill>}
|
|
198
|
+
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{p.actions.join(", ")}</span>
|
|
199
|
+
<span className="text-xs tabular-nums text-muted-foreground">{Object.entries(p.credits_per_row).map(([k, v]) => `${k} ${v}`).join(" / ")}</span>
|
|
200
|
+
</li>
|
|
201
|
+
))}
|
|
202
|
+
</ul>
|
|
203
|
+
</SectionCard>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function EnrichmentScreen({ toolbarActions, renderDetailActions, extraDetailTabs, providerSettingsUrl: providerSettingsUrlProp, className }: EnrichmentScreenProps) {
|
|
208
|
+
const brand = useBrand();
|
|
209
|
+
const providerSettingsUrl = providerSettingsUrlProp ?? brandUrl(brand, "/settings");
|
|
210
|
+
const [tab, setTab] = useUrlState("tab", "list");
|
|
211
|
+
const [listParam, setListParam] = useUrlState("list");
|
|
212
|
+
const [picked, setPicked] = useState<number[]>([]);
|
|
213
|
+
const [opened, setOpened] = useState<ContactRecord | null>(null);
|
|
214
|
+
const catalog = useProviders(tab === "providers");
|
|
215
|
+
const [jobParam, setJobParam] = useUrlState("job");
|
|
216
|
+
const [pipelineParam, setPipelineParam] = useUrlState("pipeline");
|
|
217
|
+
const [view, setView] = useUrlState("view");
|
|
218
|
+
const full = view === "full";
|
|
219
|
+
const [jobs, setJobs] = useState<string[]>([]);
|
|
220
|
+
useEffect(() => setJobs(loadJobs()), []);
|
|
221
|
+
const started = (id: string) => setJobs((cur) => { const next = [id, ...cur.filter((x) => x !== id)]; rememberJobs(next); return next; });
|
|
222
|
+
const listId = listParam ? Number(listParam) : null;
|
|
223
|
+
const pick = (id: number) => { setListParam(String(id)); setPicked([]); setOpened(null); setPipelineParam(null); };
|
|
224
|
+
|
|
225
|
+
const stats = tab === "providers" && catalog.providers.length > 0 ? [
|
|
226
|
+
{ label: "Providers", value: catalog.providers.length },
|
|
227
|
+
{ label: `${brand.linkLabel} funded`, value: catalog.providers.filter((p) => p.system_funded).length },
|
|
228
|
+
{ label: "Your keys set", value: catalog.providers.filter((p) => p.byok_configured).length },
|
|
229
|
+
] : undefined;
|
|
230
|
+
|
|
231
|
+
let body: ReactNode;
|
|
232
|
+
// WaterfallConfig owns the pipelines request and hands the result up, so the detail view needs no second fetch.
|
|
233
|
+
const [loaded, setLoaded] = useState<{ listId: number | null; configs: WaterfallConfigSummary[] }>({ listId: null, configs: [] });
|
|
234
|
+
const openPipeline = (loaded.listId === listId ? loaded.configs : []).find((c) => c.column_id === pipelineParam);
|
|
235
|
+
if (tab === "lookup") body = <LookupTab />;
|
|
236
|
+
else if (tab === "jobs") body = <JobsTab jobs={jobs} jobParam={jobParam} setJobParam={setJobParam} />;
|
|
237
|
+
else if (tab === "providers") body = <ProvidersTab settingsUrl={providerSettingsUrl} />;
|
|
238
|
+
else {
|
|
239
|
+
body = (
|
|
240
|
+
<div className="grid gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
|
241
|
+
<ListRail listId={listId} onPick={pick} />
|
|
242
|
+
{listId == null ? (
|
|
243
|
+
<EmptyState icon={<Sparkles className="size-6" aria-hidden />} title="Choose a list" description={tab === "pipelines" ? "Pipelines belong to a list. Pick one to see and create them." : "Pick a list, tick the contacts to fill in, and enrich them."} />
|
|
244
|
+
) : tab === "pipelines" ? (
|
|
245
|
+
<SplitView
|
|
246
|
+
list={<WaterfallConfig key={listId} listId={listId} selectedId={pipelineParam || null} onOpen={(c) => setPipelineParam(c.column_id)} onLoaded={(configs) => setLoaded({ listId, configs })} />}
|
|
247
|
+
expanded={full} onExpandedChange={(e) => setView(e ? "full" : null)} detailOpen={openPipeline != null} onCloseDetail={() => setPipelineParam(null)} detailTitle="Pipeline"
|
|
248
|
+
detail={openPipeline ? <PipelineDetail config={openPipeline} onClose={() => setPipelineParam(null)} onExpand={full ? undefined : () => setView("full")} onCollapse={full ? () => setView(null) : undefined} /> : null} />
|
|
249
|
+
) : (
|
|
250
|
+
<div className="grid min-w-0 gap-4 xl:grid-cols-2">
|
|
251
|
+
<ContactTable key={listId} listId={listId} selectable pageSize={10} onSelectionChange={(ids) => setPicked(ids)} onOpen={setOpened} />
|
|
252
|
+
<div className="flex min-w-0 flex-col gap-4">
|
|
253
|
+
<SectionCard title={`Enrich ${picked.length} selected`} description="Fill work email or phones with a provider waterfall. You confirm the price first.">
|
|
254
|
+
{picked.length === 0 ? <p className="text-sm text-muted-foreground">Tick contacts on the left.</p> : <BulkEnrichPanel key={listId} listId={listId} contactIds={picked} onStarted={started} />}
|
|
255
|
+
</SectionCard>
|
|
256
|
+
{opened ? (
|
|
257
|
+
<DetailPanel key={opened.id} title={opened.full_name ?? ([opened.first_name, opened.last_name].filter(Boolean).join(" ") || `Contact #${opened.id}`)} subtitle={opened.job_title} onClose={() => setOpened(null)}>
|
|
258
|
+
<ContactDetail contactId={opened.id} extraTabs={extraDetailTabs}
|
|
259
|
+
renderActions={(c) => (<>
|
|
260
|
+
<EnrichButton kind="person" record={c as ContactRecord & Record<string, unknown>} lookup={{ email: c.work_email ?? undefined, linkedin_url: c.linkedin_url ?? undefined, first_name: c.first_name ?? undefined, last_name: c.last_name ?? undefined, company_domain: c.company_domain ?? undefined }} />
|
|
261
|
+
<EmailVerifyBadge email={c.work_email} />
|
|
262
|
+
{renderDetailActions?.(c)}
|
|
263
|
+
</>)} />
|
|
264
|
+
</DetailPanel>
|
|
265
|
+
) : null}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<ScreenLayout
|
|
275
|
+
className={className}
|
|
276
|
+
title="Enrichment"
|
|
277
|
+
description="Fill in what you do not know about your contacts: emails, phones and company facts. Every paid step shows its price first."
|
|
278
|
+
icon={<Sparkles className="size-5" aria-hidden />}
|
|
279
|
+
actions={toolbarActions}
|
|
280
|
+
tabs={TABS}
|
|
281
|
+
activeTab={TABS.some((t) => t.id === tab) ? tab : "list"}
|
|
282
|
+
onTabChange={setTab}
|
|
283
|
+
stats={stats}
|
|
284
|
+
>
|
|
285
|
+
{body}
|
|
286
|
+
</ScreenLayout>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
* 1. listWaterfallConfigs GET /enrichment/waterfall/configs?list_id (read) pipelines runnable on the list
|
|
9
9
|
* 2. listEnrichmentProviders GET /enrichment/providers (read) graph8-funded vs own key, credits per action
|
|
10
10
|
* 3. createDefaultWaterfallConfig POST /enrichment/waterfall/configs { list_id, name, field, providers, skip_existing_values }
|
|
11
|
-
* (billable tier) goes through ConfirmSpendDialog first
|
|
11
|
+
* (billable tier) goes through ConfirmSpendDialog first. With `field` (always sent here) it is idempotent per field and
|
|
12
|
+
* list: when the list already has a pipeline for that field the API returns it with `created: false` and its own
|
|
13
|
+
* providers, whatever name and providers were sent. The form then says so instead of claiming a new pipeline.
|
|
12
14
|
* 4. deleteWaterfallConfig DELETE /enrichment/waterfall/configs/{column_id} (destructive, ConfirmSpendDialog)
|
|
13
15
|
*
|
|
14
16
|
* Providers on graph8's system key spend credits when the pipeline RUNS; providers on the org's own key (BYOK) do not.
|
|
15
17
|
* A BYOK provider without a key is listed under `missing_credentials` and is skipped at run time.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
|
-
import { useState } from "react";
|
|
20
|
+
import { useEffect, useRef, useState } from "react";
|
|
19
21
|
import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
|
|
20
22
|
import { ApiError } from "@/g8/components/api-error";
|
|
21
23
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
@@ -23,36 +25,57 @@ import { EmptyState } from "@/g8/components/empty-state";
|
|
|
23
25
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
24
26
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
25
27
|
import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
28
|
+
import { plural } from "@/g8/lib/plural";
|
|
26
29
|
import { useProviders } from "../hooks/use-providers";
|
|
27
30
|
import { TARGET_FIELDS } from "../lib/enrichment-types";
|
|
28
31
|
import type { TargetField, WaterfallConfigSummary } from "../lib/enrichment-types";
|
|
32
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
29
33
|
|
|
30
34
|
export interface WaterfallConfigProps {
|
|
31
35
|
listId: number;
|
|
32
36
|
/** Called with the pipeline's column_id after create or delete (for example to refresh a BulkEnrichPanel). */
|
|
33
37
|
onChanged?: () => void;
|
|
38
|
+
/** Makes each pipeline's name open a detail view in your layout. Omit it and the list is unchanged. */
|
|
39
|
+
onOpen?: (config: WaterfallConfigSummary) => void;
|
|
40
|
+
/** Highlights the open pipeline (its column_id). */
|
|
41
|
+
selectedId?: string | null;
|
|
42
|
+
/** Called with the loaded pipelines each time they load or reload, so a screen can show one in a detail view without a second request. */
|
|
43
|
+
onLoaded?: (configs: WaterfallConfigSummary[]) => void;
|
|
34
44
|
className?: string;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
|
-
export function WaterfallConfig({ listId, onChanged, className }: WaterfallConfigProps) {
|
|
47
|
+
export function WaterfallConfig({ listId, onChanged, onOpen, selectedId, onLoaded, className }: WaterfallConfigProps) {
|
|
48
|
+
const brand = useBrand();
|
|
38
49
|
const [name, setName] = useState("");
|
|
39
50
|
const [field, setField] = useState<TargetField>("work_email");
|
|
40
51
|
const [order, setOrder] = useState<string[]>([]);
|
|
41
52
|
const [skipExisting, setSkipExisting] = useState(true);
|
|
42
53
|
const [confirmCreate, setConfirmCreate] = useState(false);
|
|
43
54
|
const [deleting, setDeleting] = useState<WaterfallConfigSummary | null>(null);
|
|
55
|
+
// The answer to the last create when the API reused the list's pipeline for that field instead of creating one.
|
|
56
|
+
const [reused, setReused] = useState<{ name?: string; field: TargetField; providers: string[]; asked: string[] } | null>(null);
|
|
44
57
|
const catalog = useProviders();
|
|
45
58
|
|
|
46
59
|
const configs = useG8Query((signal) => g8Get<{ configs: WaterfallConfigSummary[] }>("/enrichment/waterfall/configs", { list_id: listId }, { signal }), [listId]);
|
|
47
60
|
const create = useG8Mutation(
|
|
48
|
-
() => g8Post<{ column_id: string; created: boolean }>("/enrichment/waterfall/configs", { list_id: listId, name: name.trim(), field, providers: order, skip_existing_values: skipExisting }),
|
|
49
|
-
{
|
|
61
|
+
() => g8Post<{ column_id: string; created: boolean; name?: string; providers?: string[] }>("/enrichment/waterfall/configs", { list_id: listId, name: name.trim(), field, providers: order, skip_existing_values: skipExisting }),
|
|
62
|
+
{
|
|
63
|
+
onSuccess: (res) => {
|
|
64
|
+
setReused(res.data.created === false ? { name: res.data.name, field, providers: res.data.providers ?? [], asked: order } : null);
|
|
65
|
+
setConfirmCreate(false); setName(""); setOrder([]); void configs.refetch(); onChanged?.();
|
|
66
|
+
},
|
|
67
|
+
// A failed create must not leave the previous answer's "Already exists, reused" next to the error.
|
|
68
|
+
onError: () => setReused(null),
|
|
69
|
+
},
|
|
50
70
|
);
|
|
51
71
|
const remove = useG8Mutation((columnId: string) => g8Delete(`/enrichment/waterfall/configs/${columnId}`), {
|
|
52
72
|
onSuccess: () => { setDeleting(null); void configs.refetch(); onChanged?.(); },
|
|
53
73
|
});
|
|
54
74
|
|
|
55
75
|
const items = configs.data?.data.configs ?? [];
|
|
76
|
+
const loadedRef = useRef(onLoaded);
|
|
77
|
+
loadedRef.current = onLoaded;
|
|
78
|
+
useEffect(() => { if (configs.data) loadedRef.current?.(configs.data.data.configs ?? []); }, [configs.data]);
|
|
56
79
|
const byName = new Map(catalog.providers.map((p) => [p.provider, p]));
|
|
57
80
|
const systemUsed = order.filter((p) => byName.get(p)?.system_funded && !byName.get(p)?.byok_configured);
|
|
58
81
|
const move = (p: string, d: number) => {
|
|
@@ -73,12 +96,12 @@ export function WaterfallConfig({ listId, onChanged, className }: WaterfallConfi
|
|
|
73
96
|
{!configs.loading && !configs.error && items.length === 0 ? <EmptyState title="No pipelines yet" description="Create one below, or let the first enrichment run create a default." /> : null}
|
|
74
97
|
<ul className="flex flex-col gap-2">
|
|
75
98
|
{items.map((c) => (
|
|
76
|
-
<li key={c.column_id} className=
|
|
99
|
+
<li key={c.column_id} className={`rounded-md border p-3 ${selectedId === c.column_id ? "bg-muted/50" : ""}`} data-testid="pipeline">
|
|
77
100
|
<div className="flex items-center justify-between gap-2">
|
|
78
|
-
<p className="text-sm font-medium">{c.name}{c.is_global ? <span className="ml-2 text-xs text-muted-foreground">all lists</span> : null}</p>
|
|
101
|
+
<p className="text-sm font-medium">{onOpen ? <button type="button" className="underline-offset-2 hover:underline" onClick={() => onOpen(c)}>{c.name}</button> : c.name}{c.is_global ? <span className="ml-2 text-xs text-muted-foreground">all lists</span> : null}</p>
|
|
79
102
|
<Button variant="ghost" size="sm" onClick={() => setDeleting(c)}>Delete</Button>
|
|
80
103
|
</div>
|
|
81
|
-
<p className="text-xs text-muted-foreground">{c.field_to_enrich ?? "field"}: {c.providers.join(" > ") || "no providers"} ({c.step_count
|
|
104
|
+
<p className="text-xs text-muted-foreground">{c.field_to_enrich ?? "field"}: {c.providers.join(" > ") || "no providers"} ({plural(c.step_count, "step")})</p>
|
|
82
105
|
{c.missing_credentials.length ? <p role="alert" className="mt-1 text-xs text-amber-700">Skipped at run time, no key: {c.missing_credentials.join(", ")}</p> : null}
|
|
83
106
|
</li>
|
|
84
107
|
))}
|
|
@@ -100,7 +123,7 @@ export function WaterfallConfig({ listId, onChanged, className }: WaterfallConfi
|
|
|
100
123
|
{order.map((p, i) => (
|
|
101
124
|
<li key={p} className="flex items-center gap-2 text-sm">
|
|
102
125
|
<span className="w-6 text-muted-foreground">{i + 1}.</span><span className="w-32 font-medium">{p}</span>
|
|
103
|
-
<span className="text-xs text-muted-foreground">{byName.get(p)?.byok_configured ? "your key, free" : byName.get(p)?.system_funded ?
|
|
126
|
+
<span className="text-xs text-muted-foreground">{byName.get(p)?.byok_configured ? "your key, free" : byName.get(p)?.system_funded ? `${brand.linkLabel} credits` : "needs your key"}</span>
|
|
104
127
|
<Button variant="ghost" size="sm" aria-label={`Move ${p} up`} disabled={i === 0} onClick={() => move(p, -1)}>Up</Button>
|
|
105
128
|
<Button variant="ghost" size="sm" aria-label={`Move ${p} down`} disabled={i === order.length - 1} onClick={() => move(p, 1)}>Down</Button>
|
|
106
129
|
<Button variant="ghost" size="sm" aria-label={`Remove ${p}`} onClick={() => setOrder(order.filter((x) => x !== p))}>Remove</Button>
|
|
@@ -114,11 +137,17 @@ export function WaterfallConfig({ listId, onChanged, className }: WaterfallConfi
|
|
|
114
137
|
</div>
|
|
115
138
|
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={skipExisting} onChange={(e) => setSkipExisting(e.target.checked)} /> Skip contacts that already have a value</label>
|
|
116
139
|
{create.error ? <ApiError error={create.error} title="Could not create the pipeline" /> : null}
|
|
140
|
+
{reused ? (
|
|
141
|
+
<p role="status" className="text-sm text-muted-foreground">
|
|
142
|
+
Already exists, reused: this list already has a {TARGET_FIELDS.find((f) => f.value === reused.field)?.label.toLowerCase() ?? reused.field} pipeline{reused.name ? ` ("${reused.name}")` : ""}, so no new one was created.
|
|
143
|
+
{reused.providers.length && reused.providers.join() !== reused.asked.join() ? ` It keeps its providers: ${reused.providers.join(" > ")}. Delete it first to use ${reused.asked.join(" > ")}.` : ""}
|
|
144
|
+
</p>
|
|
145
|
+
) : null}
|
|
117
146
|
<div className="flex justify-end"><Button disabled={!name.trim() || order.length === 0} onClick={() => setConfirmCreate(true)}>Create pipeline</Button></div>
|
|
118
147
|
</section>
|
|
119
148
|
|
|
120
149
|
<ConfirmSpendDialog open={confirmCreate} tier="billable" title={`Create pipeline "${name.trim()}"`}
|
|
121
|
-
description={systemUsed.length ? `Running it uses
|
|
150
|
+
description={systemUsed.length ? `Running it uses ${brand.linkLabel} credits for ${systemUsed.join(", ")}; providers on your own key cost nothing. Creating the pipeline itself does not run it.` : `All providers use your own keys, so running it costs no ${brand.linkLabel} credits. Creating the pipeline does not run it.`}
|
|
122
151
|
confirmLabel="Create" confirming={create.loading} onConfirm={() => void create.mutate(undefined)} onCancel={() => setConfirmCreate(false)} />
|
|
123
152
|
<ConfirmSpendDialog open={deleting != null} tier="destructive" title={`Delete pipeline "${deleting?.name ?? ""}"`} description="Values it already wrote stay on the contacts."
|
|
124
153
|
affected={1} confirming={remove.loading} onConfirm={() => deleting && void remove.mutate(deleting.column_id)} onCancel={() => setDeleting(null)} />
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @title Create a waterfall pipeline for a list and see which providers are ready
|
|
3
3
|
* @when Setting up "try provider A, then B" enrichment for a column once, then reusing it. Check the provider
|
|
4
4
|
* catalog first: graph8-funded providers spend credits when the pipeline runs, providers on your own key do not,
|
|
5
|
-
* and a provider without a key is skipped. Creating is idempotent by (list_id, name).
|
|
5
|
+
* and a provider without a key is skipped. Creating with `field` is idempotent per field and list: if the list already has a pipeline for that field it is returned with `created: false` and keeps its own providers, whatever name or providers you send (delete it first to change them). Without `field`, a pipeline is reused by (list_id, name).
|
|
6
6
|
*/
|
|
7
7
|
import { g8 } from "@graph8/sdk";
|
|
8
8
|
|
|
@@ -17,7 +17,8 @@ export async function createPipeline(listId: number) {
|
|
|
17
17
|
const res = await g8.api.enrichment.createDefaultWaterfallConfig({
|
|
18
18
|
body: { list_id: listId, name: "Work email waterfall", field: "work_email", providers: ["prospeo", "dropcontact"], skip_existing_values: true },
|
|
19
19
|
});
|
|
20
|
-
|
|
20
|
+
// created === false: the list already had a work-email pipeline; it was returned as is (check res.data.providers).
|
|
21
|
+
return { columnId: res.data.column_id, created: res.data.created, providers: res.data.providers, missing: res.data.missing_credentials };
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export async function pipelinesOf(listId: number) {
|
package/blocks/forms/block.json
CHANGED
|
@@ -15,25 +15,25 @@
|
|
|
15
15
|
"crm"
|
|
16
16
|
],
|
|
17
17
|
"operations": [
|
|
18
|
-
"list_forms_forms_get",
|
|
19
|
-
"get_form_overview_forms__form_key__overview_get",
|
|
20
|
-
"update_form_registry_forms__form_key__patch",
|
|
21
|
-
"pause_form_delivery_forms__form_key__delivery_pause_post",
|
|
22
|
-
"resume_form_delivery_forms__form_key__delivery_resume_post",
|
|
23
|
-
"get_form_fields_forms__form_key__fields_get",
|
|
24
18
|
"get_contact_form_fields_contacts_form_fields_get",
|
|
25
|
-
"
|
|
19
|
+
"get_form_fields_forms__form_key__fields_get",
|
|
20
|
+
"get_form_overview_forms__form_key__overview_get",
|
|
26
21
|
"get_form_route_recommendations_forms__form_key__route_recommendations_get",
|
|
27
|
-
"preview_form_mapping_forms__form_key__mapping_preview_post",
|
|
28
|
-
"save_form_mapping_forms__form_key__mapping_save_post",
|
|
29
|
-
"test_form_route_forms__form_key__route_test_post",
|
|
30
|
-
"get_org_snippet_snippet_get",
|
|
31
|
-
"get_form_template_snippet_form_get",
|
|
32
|
-
"search_form_submissions_forms__form_key__submissions_post",
|
|
33
22
|
"get_form_submission_trace_forms__form_key__submissions__submission_id__trace_get",
|
|
34
|
-
"
|
|
23
|
+
"get_form_template_snippet_form_get",
|
|
24
|
+
"get_org_snippet_snippet_get",
|
|
35
25
|
"get_site_audit_forms_site_audit_get",
|
|
26
|
+
"list_forms_forms_get",
|
|
27
|
+
"list_lists_lists_get",
|
|
28
|
+
"pause_form_delivery_forms__form_key__delivery_pause_post",
|
|
29
|
+
"preview_form_mapping_forms__form_key__mapping_preview_post",
|
|
36
30
|
"request_site_audit_scan_forms_site_audit_scan_post",
|
|
31
|
+
"resume_form_delivery_forms__form_key__delivery_resume_post",
|
|
32
|
+
"retry_form_delivery_forms_deliveries__receipt_id__retry_post",
|
|
33
|
+
"save_form_mapping_forms__form_key__mapping_save_post",
|
|
34
|
+
"search_form_submissions_forms__form_key__submissions_post",
|
|
35
|
+
"test_form_route_forms__form_key__route_test_post",
|
|
36
|
+
"update_form_registry_forms__form_key__patch",
|
|
37
37
|
"update_site_audit_finding_forms_site_audit_findings__finding_id__patch"
|
|
38
38
|
],
|
|
39
39
|
"components": [
|
|
@@ -41,8 +41,21 @@
|
|
|
41
41
|
"name": "FormsScreen",
|
|
42
42
|
"file": "components/forms-screen.tsx",
|
|
43
43
|
"purpose": "Finished forms workspace: captured forms with health, detail (overview, destination and fields, submissions), install code and site audit tabs.",
|
|
44
|
-
"operations": [
|
|
45
|
-
|
|
44
|
+
"operations": [
|
|
45
|
+
"get_form_overview_forms__form_key__overview_get",
|
|
46
|
+
"get_form_submission_trace_forms__form_key__submissions__submission_id__trace_get",
|
|
47
|
+
"list_forms_forms_get",
|
|
48
|
+
"pause_form_delivery_forms__form_key__delivery_pause_post",
|
|
49
|
+
"resume_form_delivery_forms__form_key__delivery_resume_post",
|
|
50
|
+
"retry_form_delivery_forms_deliveries__receipt_id__retry_post",
|
|
51
|
+
"search_form_submissions_forms__form_key__submissions_post",
|
|
52
|
+
"update_form_registry_forms__form_key__patch"
|
|
53
|
+
],
|
|
54
|
+
"tiers": [
|
|
55
|
+
"read",
|
|
56
|
+
"write",
|
|
57
|
+
"external"
|
|
58
|
+
]
|
|
46
59
|
},
|
|
47
60
|
{
|
|
48
61
|
"name": "FormBuilder",
|
|
@@ -97,9 +110,10 @@
|
|
|
97
110
|
"file": "components/submissions-table.tsx",
|
|
98
111
|
"purpose": "Paged submissions with delivery state, a per-lead trace and a confirmed retry for failed deliveries.",
|
|
99
112
|
"operations": [
|
|
100
|
-
"search_form_submissions_forms__form_key__submissions_post",
|
|
101
113
|
"get_form_submission_trace_forms__form_key__submissions__submission_id__trace_get",
|
|
102
|
-
"
|
|
114
|
+
"list_forms_forms_get",
|
|
115
|
+
"retry_form_delivery_forms_deliveries__receipt_id__retry_post",
|
|
116
|
+
"search_form_submissions_forms__form_key__submissions_post"
|
|
103
117
|
],
|
|
104
118
|
"tiers": [
|
|
105
119
|
"read",
|
|
@@ -28,6 +28,7 @@ import { STATUS_LABEL, formLabel, formStatus, pageUrlText, pickItems } from "../
|
|
|
28
28
|
import type { FormListItem, FormOverview, FormRef } from "../lib/form-types";
|
|
29
29
|
import { FormMappingEditor } from "./form-mapping-editor";
|
|
30
30
|
import { SubmissionsTable } from "./submissions-table";
|
|
31
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
31
32
|
|
|
32
33
|
export interface FormBuilderProps {
|
|
33
34
|
/** Form to open first. */
|
|
@@ -83,6 +84,7 @@ function FormHeader({ form, onChanged }: { form: FormListItem; onChanged: () =>
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
export function FormBuilder({ initialForm, onSelectForm }: FormBuilderProps) {
|
|
87
|
+
const brand = useBrand();
|
|
86
88
|
const [page, setPage] = useState(1);
|
|
87
89
|
const [searchText, setSearchText] = useState("");
|
|
88
90
|
const [search, setSearch] = useState("");
|
|
@@ -109,7 +111,7 @@ export function FormBuilder({ initialForm, onSelectForm }: FormBuilderProps) {
|
|
|
109
111
|
<Button type="submit" size="sm" variant="outline">Search</Button>
|
|
110
112
|
</form>
|
|
111
113
|
{forms.loading && !forms.data ? <div className="grid gap-2" aria-busy="true"><Skeleton className="h-12 w-full" /><Skeleton className="h-12 w-full" /></div> : items.length === 0 ? (
|
|
112
|
-
<EmptyState title={search ? "No forms match" : "No forms captured yet"} description={search ? "Try another name." :
|
|
114
|
+
<EmptyState title={search ? "No forms match" : "No forms captured yet"} description={search ? "Try another name." : `Install the ${brand.linkLabel} snippet on your site (see FormEmbed). Forms appear here after their first submission.`} />
|
|
113
115
|
) : (
|
|
114
116
|
<ul className="grid gap-1">
|
|
115
117
|
{items.map((f) => {
|
|
@@ -15,6 +15,7 @@ import { Button, Card, CardContent, CardHeader, CardTitle, Label, Skeleton } fro
|
|
|
15
15
|
import { ApiError } from "@/g8/components/api-error";
|
|
16
16
|
import { g8Get } from "@/g8/lib/g8-client";
|
|
17
17
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
18
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
18
19
|
|
|
19
20
|
export interface FormEmbedProps {
|
|
20
21
|
/** Stream (site) to read the snippet for. Omit for the organization's main stream. */
|
|
@@ -55,6 +56,7 @@ function CodeBlock({ label, code }: { label: string; code: string }) {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export function FormEmbed({ streamId }: FormEmbedProps) {
|
|
59
|
+
const brand = useBrand();
|
|
58
60
|
const [stream, setStream] = useState<string | undefined>(streamId);
|
|
59
61
|
const snippet = useG8Query((signal) => g8Get<Snippet>("/snippet", { stream_id: stream }, { signal }).then((r) => r.data), [stream]);
|
|
60
62
|
const template = useG8Query((signal) => g8Get<{ html?: string }>("/snippet/form", { stream_id: stream }, { signal }).then((r) => r.data), [stream]);
|
|
@@ -80,7 +82,7 @@ export function FormEmbed({ streamId }: FormEmbedProps) {
|
|
|
80
82
|
<Card>
|
|
81
83
|
<CardHeader><CardTitle>1. Install the tracking snippet</CardTitle></CardHeader>
|
|
82
84
|
<CardContent className="grid gap-3">
|
|
83
|
-
<p className="text-muted-foreground text-sm">Put this on every page that has a form.
|
|
85
|
+
<p className="text-muted-foreground text-sm">Put this on every page that has a form. {brand.linkLabel} then detects the forms and records submissions. Allowed domains: {s?.domains?.join(", ") || "any"}.</p>
|
|
84
86
|
{s?.script_tag ? <CodeBlock label="Script tag" code={s.script_tag} /> : null}
|
|
85
87
|
{s?.react_snippet ? <CodeBlock label="React component" code={s.react_snippet} /> : null}
|
|
86
88
|
<p className="text-muted-foreground text-xs">Write key: <code>{s?.write_key}</code> (public, safe in page source).</p>
|
|
@@ -47,6 +47,8 @@ import { FormEmbed } from "./form-embed";
|
|
|
47
47
|
import { FormMappingEditor } from "./form-mapping-editor";
|
|
48
48
|
import { SiteAuditPanel } from "./site-audit-panel";
|
|
49
49
|
import { SubmissionsTable } from "./submissions-table";
|
|
50
|
+
import { pluralWord } from "@/g8/lib/plural";
|
|
51
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
50
52
|
|
|
51
53
|
export interface FormsScreenProps {
|
|
52
54
|
renderRowActions?: (form: FormListItem) => ReactNode;
|
|
@@ -204,6 +206,7 @@ function SubmissionDetail({ form, submission, onChanged }: { form: FormRef; subm
|
|
|
204
206
|
}
|
|
205
207
|
|
|
206
208
|
export function FormsScreen({ renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, className }: FormsScreenProps) {
|
|
209
|
+
const brand = useBrand();
|
|
207
210
|
const [tabParam, setTabParam] = useUrlState("tab", "forms");
|
|
208
211
|
const tab: Tab = tabParam === "install" || tabParam === "audit" ? tabParam : "forms";
|
|
209
212
|
const setTab = (next: Tab): void => setTabParam(next === "forms" ? null : next);
|
|
@@ -253,7 +256,7 @@ export function FormsScreen({ renderRowActions, renderDetailActions, extraDetail
|
|
|
253
256
|
<EmptyState
|
|
254
257
|
icon={<ClipboardList className="size-8" />}
|
|
255
258
|
title={search || health ? "No forms match" : "No forms captured yet"}
|
|
256
|
-
description={search || health ? "Try another name or health." :
|
|
259
|
+
description={search || health ? "Try another name or health." : `Install the ${brand.linkLabel} snippet on your site. Forms appear here after their first submission.`}
|
|
257
260
|
action={!search && !health ? <Button onClick={() => setTab("install")}>Get the install code</Button> : undefined}
|
|
258
261
|
/>
|
|
259
262
|
) : (
|
|
@@ -267,7 +270,7 @@ export function FormsScreen({ renderRowActions, renderDetailActions, extraDetail
|
|
|
267
270
|
<span className="block truncate font-medium">{formLabel(f)}</span>
|
|
268
271
|
<span className="text-muted-foreground block truncate text-xs">{pageUrlText(f.page_url) || f.form_key}</span>
|
|
269
272
|
<span className="text-muted-foreground block text-xs">
|
|
270
|
-
{(f.total_submissions ?? 0).toLocaleString()} {(f.total_submissions ?? 0
|
|
273
|
+
{(f.total_submissions ?? 0).toLocaleString()} {pluralWord(f.total_submissions ?? 0, "submission")}{f.failed_submissions ? ` · ${f.failed_submissions} failed` : ""}{f.target_list_name ? ` · to ${f.target_list_name}` : ""}
|
|
271
274
|
</span>
|
|
272
275
|
</button>
|
|
273
276
|
<StatusPill tone={TONE[status]}>{STATUS_LABEL[status]}</StatusPill>
|