@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,265 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IntentScreen: the finished buyer-intent product page (Apollo / 6sense "intent"). Two tabs:
|
|
5
|
+
* Accounts accounts showing intent, strongest first (IntentFeed), a look-back window filter, a keyword scope chip,
|
|
6
|
+
* and the selected account's detail panel (signals, trend, top keywords, buying-committee coverage).
|
|
7
|
+
* Keywords track keywords and competitor domains (IntentKeywordManager). Each keyword opens its complete detail screen
|
|
8
|
+
* (KeywordDetail: fields, accounts showing intent for it, companies behind it, stop tracking; `?id=`).
|
|
9
|
+
* Every detail also opens as a full page with `?view=full`.
|
|
10
|
+
*
|
|
11
|
+
* User flows: see who is showing intent, filter to high intent, change the window, open an account (deep link
|
|
12
|
+
* `?id=<company id>`), read its buying committee, look up who visited a URL, track keywords, preview and track a
|
|
13
|
+
* competitor domain, list the companies behind a keyword, stop tracking (destructive, confirmed).
|
|
14
|
+
*
|
|
15
|
+
* SDK operations: none of its own. Composes IntentFeed (read), AccountIntentScore / useAccountIntent (read),
|
|
16
|
+
* IntentKeywordManager (billable + destructive, every call confirmed) and one read of listAbmCompanies for the stat strip.
|
|
17
|
+
* Prerequisite: ABM intent enabled for the workspace (a 403 is explained inline), and something to measure.
|
|
18
|
+
*/
|
|
19
|
+
import { useState } from "react";
|
|
20
|
+
import type { ReactNode } from "react";
|
|
21
|
+
import { Button } from "@graph8/sdk/ui";
|
|
22
|
+
import { Radar, Tags, X } from "lucide-react";
|
|
23
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
24
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
25
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
26
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
27
|
+
import type { StatItem } from "@/g8/components/screen/stat-strip";
|
|
28
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
29
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
30
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
31
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
32
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
33
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
34
|
+
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
35
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
36
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
37
|
+
import { BarList } from "@/g8/components/screen/bar-list";
|
|
38
|
+
import { useAccountIntent } from "../hooks/use-account-intent";
|
|
39
|
+
import { formatDate, rowsOf, totalOf } from "../lib/intent-utils";
|
|
40
|
+
import type { AbmAccount, IntentKeywordRow } from "../lib/intent-utils";
|
|
41
|
+
import { AccountIntentScore } from "./account-intent-score";
|
|
42
|
+
import { IntentFeed } from "./intent-feed";
|
|
43
|
+
import { IntentKeywordManager } from "./intent-keyword-manager";
|
|
44
|
+
import { KeywordDetail } from "./keyword-detail";
|
|
45
|
+
|
|
46
|
+
export interface IntentScreenProps {
|
|
47
|
+
/** Extra buttons for the toolbar (for example "Add to list" glue from another block). */
|
|
48
|
+
toolbarActions?: ReactNode;
|
|
49
|
+
/** Buttons shown in the account detail header. */
|
|
50
|
+
renderDetailActions?: (account: AbmAccount) => ReactNode;
|
|
51
|
+
/** Extra tabs in the account detail panel. */
|
|
52
|
+
extraDetailTabs?: { id: string; label: string; render: (account: AbmAccount) => ReactNode }[];
|
|
53
|
+
className?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const WINDOWS = [7, 30, 90] as const;
|
|
57
|
+
type Tab = "accounts" | "keywords";
|
|
58
|
+
|
|
59
|
+
export function strengthTone(strength: string | null | undefined): "positive" | "warning" | "neutral" {
|
|
60
|
+
const s = (strength ?? "").toLowerCase();
|
|
61
|
+
if (s.includes("strong") || s === "high") return "positive";
|
|
62
|
+
if (s.includes("moderate") || s === "medium") return "warning";
|
|
63
|
+
return "neutral";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface PanelProps {
|
|
67
|
+
onClose: () => void;
|
|
68
|
+
onExpand?: () => void;
|
|
69
|
+
onCollapse?: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function AccountDetail({ account, panel, props }: { account: AbmAccount; panel: PanelProps; props: IntentScreenProps }) {
|
|
73
|
+
const name = account.company_name ?? account.company_domain ?? "Unknown company";
|
|
74
|
+
const keywords = (account.top_keywords ?? []).map((k) => ({ label: k.keyword ?? "keyword", value: k.signal_count ?? 0 }));
|
|
75
|
+
const trend = typeof account.trend_pct === "number" ? `${account.trend_pct > 0 ? "+" : ""}${Math.round(account.trend_pct)}%` : "No trend yet";
|
|
76
|
+
const tabs = [
|
|
77
|
+
{
|
|
78
|
+
id: "signals",
|
|
79
|
+
label: "Signals",
|
|
80
|
+
content: (
|
|
81
|
+
<div className="space-y-4">
|
|
82
|
+
<KeyValueList
|
|
83
|
+
columns={2}
|
|
84
|
+
items={[
|
|
85
|
+
{ label: "Domain", value: account.company_domain ?? "-" },
|
|
86
|
+
{ label: "Industry", value: account.industry ?? "-" },
|
|
87
|
+
{ label: "Employees", value: account.employee_count ?? "-" },
|
|
88
|
+
{ label: "Signals", value: account.total_signal_count ?? 0 },
|
|
89
|
+
{ label: "Trend", value: trend },
|
|
90
|
+
{ label: "Last signal", value: formatDate(account.latest_signal_date) },
|
|
91
|
+
]}
|
|
92
|
+
/>
|
|
93
|
+
<SectionCard title="Top keywords" description="Where this account's interest comes from.">
|
|
94
|
+
{keywords.length ? <BarList items={keywords} /> : <p className="text-sm text-muted-foreground">No keyword breakdown for this account.</p>}
|
|
95
|
+
</SectionCard>
|
|
96
|
+
</div>
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "committee",
|
|
101
|
+
label: "Buying committee",
|
|
102
|
+
content:
|
|
103
|
+
account.company_id != null ? (
|
|
104
|
+
<AccountIntentScore companyIds={[account.company_id]} />
|
|
105
|
+
) : (
|
|
106
|
+
<p className="text-sm text-muted-foreground">This account has no company id to score.</p>
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
...(props.extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(account) })),
|
|
110
|
+
];
|
|
111
|
+
return (
|
|
112
|
+
<DetailPanel
|
|
113
|
+
title={name}
|
|
114
|
+
subtitle={account.company_domain ?? undefined}
|
|
115
|
+
avatar={<EntityAvatar name={name} imageUrl={account.logo_url ?? undefined} kind="company" size="lg" />}
|
|
116
|
+
badges={
|
|
117
|
+
<>
|
|
118
|
+
<StatusPill tone={strengthTone(account.account_strength)}>{account.account_strength ?? "unscored"}</StatusPill>
|
|
119
|
+
{account.is_high_intent ? <StatusPill tone="negative" dot>High intent</StatusPill> : null}
|
|
120
|
+
</>
|
|
121
|
+
}
|
|
122
|
+
actions={props.renderDetailActions?.(account)}
|
|
123
|
+
tabs={tabs}
|
|
124
|
+
onClose={panel.onClose}
|
|
125
|
+
onExpand={panel.onExpand}
|
|
126
|
+
onCollapse={panel.onCollapse}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function useStats(days: number): { items: StatItem[] | undefined; loading: boolean } {
|
|
132
|
+
const q = useG8Query((signal) => g8Get("/intent/abm/companies", { days, page: 1, limit: 100 }, { signal }), [days]);
|
|
133
|
+
if (q.error || !q.data) return { items: undefined, loading: q.loading };
|
|
134
|
+
// The ABM payload is open-shaped in the contract: only report what the rows actually carry.
|
|
135
|
+
const rows = rowsOf<AbmAccount>(q.data);
|
|
136
|
+
const total = totalOf(q.data) ?? rows.length;
|
|
137
|
+
const items: StatItem[] = [{ label: "Accounts with intent", value: total.toLocaleString(), hint: `last ${days} days` }];
|
|
138
|
+
if (rows.some((r) => typeof r.is_high_intent === "boolean")) {
|
|
139
|
+
const high = rows.filter((r) => r.is_high_intent).length;
|
|
140
|
+
items.push({ label: "High intent", value: high, tone: high ? "negative" : "neutral", hint: "in the first 100" });
|
|
141
|
+
}
|
|
142
|
+
if (rows.some((r) => typeof r.account_strength === "string")) {
|
|
143
|
+
const strong = rows.filter((r) => strengthTone(r.account_strength) === "positive").length;
|
|
144
|
+
items.push({ label: "Strong accounts", value: strong, tone: strong ? "positive" : "neutral", hint: "in the first 100" });
|
|
145
|
+
}
|
|
146
|
+
return { loading: q.loading, items };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function IntentScreen(props: IntentScreenProps) {
|
|
150
|
+
const [tab, setTab] = useUrlState("tab", "accounts");
|
|
151
|
+
const [id, setId] = useUrlState("id", "");
|
|
152
|
+
const [view, setView] = useUrlState("view", "");
|
|
153
|
+
const [days, setDays] = useState<number>(30);
|
|
154
|
+
const [selected, setSelected] = useState<AbmAccount | null>(null);
|
|
155
|
+
const [keyword, setKeyword] = useState<IntentKeywordRow | null>(null);
|
|
156
|
+
// `?kw=<keyword id>` scopes the account feed to one tracked keyword (chip in the toolbar).
|
|
157
|
+
const [scopeId, setScopeId] = useUrlState("kw", "");
|
|
158
|
+
const [scopeLabel, setScopeLabel] = useState<string | null>(null);
|
|
159
|
+
const [removed, setRemoved] = useState<string[]>([]);
|
|
160
|
+
const stats = useStats(days);
|
|
161
|
+
const active: Tab = tab === "keywords" ? "keywords" : "accounts";
|
|
162
|
+
const expanded = view === "full" && Boolean(id);
|
|
163
|
+
// A non-numeric `?id=` opens nothing instead of asking for account NaN.
|
|
164
|
+
const idNumber = id && Number.isFinite(Number(id)) ? Number(id) : null;
|
|
165
|
+
const deepLinked = useAccountIntent(active === "accounts" && idNumber != null && !selected ? [idNumber] : [], days);
|
|
166
|
+
const account = selected ?? (active === "accounts" && idNumber != null && deepLinked.rollups[id] ? { ...deepLinked.rollups[id], company_id: idNumber } : null);
|
|
167
|
+
// A deep-linked account with no signal in this window (or a bad id) gets a not-found state instead of a silent no-op.
|
|
168
|
+
const accountMissing = active === "accounts" && Boolean(id) && !account && (idNumber == null || (!deepLinked.loading && !deepLinked.error));
|
|
169
|
+
const accountFailed = active === "accounts" && Boolean(id) && !account && idNumber != null && Boolean(deepLinked.error);
|
|
170
|
+
|
|
171
|
+
const close = () => {
|
|
172
|
+
setSelected(null);
|
|
173
|
+
setKeyword(null);
|
|
174
|
+
setId("");
|
|
175
|
+
setView("");
|
|
176
|
+
};
|
|
177
|
+
const panel: PanelProps = {
|
|
178
|
+
onClose: close,
|
|
179
|
+
onExpand: expanded ? undefined : () => setView("full"),
|
|
180
|
+
onCollapse: expanded ? () => setView("") : undefined,
|
|
181
|
+
};
|
|
182
|
+
const showAccounts = (k: IntentKeywordRow) => {
|
|
183
|
+
close();
|
|
184
|
+
setScopeId(k.id);
|
|
185
|
+
setScopeLabel(k.keyword);
|
|
186
|
+
setTab("accounts");
|
|
187
|
+
};
|
|
188
|
+
const openAccount = (a: AbmAccount) => {
|
|
189
|
+
setTab("accounts");
|
|
190
|
+
setKeyword(null);
|
|
191
|
+
setSelected(a);
|
|
192
|
+
setId(String(a.company_id ?? ""));
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<ScreenLayout
|
|
197
|
+
title="Intent"
|
|
198
|
+
description="Accounts researching what you sell, and the keywords and competitors you track to find them."
|
|
199
|
+
icon={<Radar className="size-5" />}
|
|
200
|
+
actions={
|
|
201
|
+
<Button onClick={() => { setTab("keywords"); close(); }}>
|
|
202
|
+
<Tags className="mr-1.5 size-4" /> Track keywords
|
|
203
|
+
</Button>
|
|
204
|
+
}
|
|
205
|
+
tabs={[
|
|
206
|
+
{ id: "accounts", label: "Accounts" },
|
|
207
|
+
{ id: "keywords", label: "Keywords" },
|
|
208
|
+
]}
|
|
209
|
+
activeTab={active}
|
|
210
|
+
onTabChange={(next) => {
|
|
211
|
+
setTab(next);
|
|
212
|
+
close();
|
|
213
|
+
}}
|
|
214
|
+
stats={active === "accounts" ? stats.items : undefined}
|
|
215
|
+
className={props.className}
|
|
216
|
+
>
|
|
217
|
+
{active === "accounts" ? (
|
|
218
|
+
<div className="space-y-3">
|
|
219
|
+
<Toolbar actions={props.toolbarActions}>
|
|
220
|
+
{scopeId ? (
|
|
221
|
+
<Button size="sm" variant="outline" aria-label={`Remove keyword scope ${scopeLabel ?? scopeId}`} onClick={() => { setScopeId(null); setScopeLabel(null); }}>
|
|
222
|
+
Keyword: {scopeLabel ?? scopeId} <X className="ml-1 size-3.5" aria-hidden />
|
|
223
|
+
</Button>
|
|
224
|
+
) : null}
|
|
225
|
+
<div className="inline-flex gap-1" role="group" aria-label="Look-back window">
|
|
226
|
+
{WINDOWS.map((w) => (
|
|
227
|
+
<Button key={w} size="sm" variant={days === w ? "default" : "outline"} aria-pressed={days === w} onClick={() => setDays(w)}>
|
|
228
|
+
{w} days
|
|
229
|
+
</Button>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
</Toolbar>
|
|
233
|
+
<SplitView
|
|
234
|
+
detailOpen={Boolean(account) || accountMissing || accountFailed}
|
|
235
|
+
onCloseDetail={close}
|
|
236
|
+
expanded={expanded && Boolean(account)}
|
|
237
|
+
onExpandedChange={(next) => setView(next ? "full" : "")}
|
|
238
|
+
detailWidth="lg"
|
|
239
|
+
list={<IntentFeed key={scopeId || "all"} days={days} keywordIds={scopeId ? [scopeId] : undefined} onSelectAccount={openAccount} />}
|
|
240
|
+
detail={account ? (
|
|
241
|
+
<AccountDetail key={account.company_id ?? account.company_domain} account={account} panel={panel} props={props} />
|
|
242
|
+
) : accountFailed ? (
|
|
243
|
+
<div className="space-y-3 p-4"><ApiError error={deepLinked.error} title="Could not load this account's intent" onRetry={() => void deepLinked.refetch()} /><Button size="sm" variant="outline" onClick={close}>Close</Button></div>
|
|
244
|
+
) : accountMissing ? (
|
|
245
|
+
<div className="space-y-3 p-4"><EmptyState title="No intent for this account" description={`No buying-intent signal for this account in the last ${days} days. Try a longer window.`} /><Button size="sm" variant="outline" onClick={close}>Close</Button></div>
|
|
246
|
+
) : null}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
) : (
|
|
250
|
+
<SplitView
|
|
251
|
+
detailOpen={Boolean(id)}
|
|
252
|
+
onCloseDetail={close}
|
|
253
|
+
expanded={expanded}
|
|
254
|
+
onExpandedChange={(next) => setView(next ? "full" : "")}
|
|
255
|
+
detailWidth="lg"
|
|
256
|
+
list={<IntentKeywordManager hiddenIds={removed} onSelectKeyword={(k) => { setKeyword(k); setId(k.id); }} />}
|
|
257
|
+
detail={id ? (
|
|
258
|
+
<KeywordDetail key={id} keywordId={id} keyword={keyword?.id === id ? keyword : null} onLoaded={setKeyword}
|
|
259
|
+
onSelectAccount={openAccount} onShowAccounts={showAccounts} onRemoved={(kid) => setRemoved((r) => [...r, kid])} {...panel} />
|
|
260
|
+
) : null}
|
|
261
|
+
/>
|
|
262
|
+
)}
|
|
263
|
+
</ScreenLayout>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* KeywordDetail: the complete screen for one tracked keyword or competitor domain. Shows every field the list returns, the
|
|
5
|
+
* accounts currently showing intent for it (the intent feed scoped to this keyword), the companies resolved behind it, and the
|
|
6
|
+
* action to stop tracking. Pass the row from IntentKeywordManager; with only an id (a deep link) it offers to load the row.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* 1. listAbmCompanies GET /intent/abm/companies?keyword_ids (read; inside IntentFeed)
|
|
10
|
+
* 2. keywordsList POST /intent/keywords/list (billable tier, confirmed; only to restore a row from an id)
|
|
11
|
+
* 3. keywordCompanies POST /intent/keywords/{id}/companies (billable, confirmed)
|
|
12
|
+
* 4. keywordsDelete DELETE /intent/keywords/{id} (destructive, confirmed; 404 counts as done)
|
|
13
|
+
*/
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import { Button } from "@graph8/sdk/ui";
|
|
16
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
17
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
18
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
19
|
+
import { g8Delete, g8Post } from "@/g8/lib/g8-client";
|
|
20
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
21
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
22
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
23
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
24
|
+
import { rowsOf } from "../lib/intent-utils";
|
|
25
|
+
import type { AbmAccount, IntentKeywordRow } from "../lib/intent-utils";
|
|
26
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
27
|
+
import { AskButton, useConfirmAction } from "@/g8/components/confirmed-action";
|
|
28
|
+
import { IntentFeed } from "./intent-feed";
|
|
29
|
+
|
|
30
|
+
export interface KeywordDetailProps {
|
|
31
|
+
keywordId: string;
|
|
32
|
+
/** The row from the keyword list. Without it the screen can restore it after a confirmed list call. */
|
|
33
|
+
keyword?: IntentKeywordRow | null;
|
|
34
|
+
/** Called with the restored row. */
|
|
35
|
+
onLoaded?: (keyword: IntentKeywordRow) => void;
|
|
36
|
+
onSelectAccount?: (account: AbmAccount) => void;
|
|
37
|
+
onClose?: () => void;
|
|
38
|
+
onExpand?: () => void;
|
|
39
|
+
onCollapse?: () => void;
|
|
40
|
+
/** Called after the keyword was removed. */
|
|
41
|
+
onRemoved?: (keywordId: string) => void;
|
|
42
|
+
/** Shows a "Show accounts" button that scopes the account feed to this keyword. */
|
|
43
|
+
onShowAccounts?: (keyword: IntentKeywordRow) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface CompanyRow {
|
|
47
|
+
name?: string | null;
|
|
48
|
+
company_name?: string | null;
|
|
49
|
+
COMPANY_NAME?: string | null;
|
|
50
|
+
domain?: string | null;
|
|
51
|
+
company_domain?: string | null;
|
|
52
|
+
COMPANY_DOMAIN?: string | null;
|
|
53
|
+
}
|
|
54
|
+
const companyName = (c: CompanyRow) => c.name ?? c.company_name ?? c.COMPANY_NAME ?? c.domain ?? c.company_domain ?? c.COMPANY_DOMAIN ?? "Unknown";
|
|
55
|
+
|
|
56
|
+
export function KeywordDetail({ keywordId, keyword, onLoaded, onSelectAccount, onClose, onExpand, onCollapse, onRemoved, onShowAccounts }: KeywordDetailProps) {
|
|
57
|
+
const { ask, dialog, failure } = useConfirmAction();
|
|
58
|
+
const [companies, setCompanies] = useState<CompanyRow[] | null>(null);
|
|
59
|
+
const restore = useG8Mutation(async () => {
|
|
60
|
+
const found = rowsOf<IntentKeywordRow>(await g8Post("/intent/keywords/list", { page: 1, limit: 200 })).find((k) => k.id === keywordId);
|
|
61
|
+
if (found) onLoaded?.(found);
|
|
62
|
+
return found ?? null;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (!keyword) {
|
|
66
|
+
return (
|
|
67
|
+
<div className="space-y-3 p-4">
|
|
68
|
+
{restore.data === null ? (
|
|
69
|
+
<EmptyState title="Keyword not found" description="It is no longer among your tracked keywords. It may have been removed." />
|
|
70
|
+
) : (
|
|
71
|
+
<EmptyState title="Keyword details are not loaded" description="Listing tracked keywords is a billable read, so it asks first." />
|
|
72
|
+
)}
|
|
73
|
+
<div className="flex justify-center">
|
|
74
|
+
<AskButton ask={ask} label="Load keyword" tier="billable" title="Load tracked keywords" confirmLabel="Load keywords" description="Listing tracked keywords is classified billable by the API."
|
|
75
|
+
run={async () => { await restore.mutateAsync(undefined); }} />
|
|
76
|
+
</div>
|
|
77
|
+
{/* A failed load surfaces once, through the confirm hook's `failure`. */}
|
|
78
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
79
|
+
<ConfirmSpendDialog {...dialog} />
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const actions = (
|
|
85
|
+
<div className="flex flex-wrap gap-1">
|
|
86
|
+
{onShowAccounts ? <Button size="sm" variant="outline" onClick={() => onShowAccounts(keyword)}>Show accounts</Button> : null}
|
|
87
|
+
<AskButton ask={ask} label="Load companies" tier="billable" title={`Load companies for "${keyword.keyword}"`} confirmLabel="Load companies" description="Resolves the companies behind this keyword's traffic."
|
|
88
|
+
run={async () => setCompanies(rowsOf<CompanyRow>(await g8Post(`/intent/keywords/${keyword.id}/companies`, { limit: 50, page: 1 })))} />
|
|
89
|
+
<AskButton ask={ask} label="Stop tracking" variant="ghost" tier="destructive" title={`Stop tracking "${keyword.keyword}"`} confirmLabel="Stop tracking" tolerateStatus={[404]}
|
|
90
|
+
description="The keyword is removed. Companies already saved to lists stay." run={() => g8Delete(`/intent/keywords/${keyword.id}`)} onDone={() => { onRemoved?.(keyword.id); onClose?.(); }} />
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
const tabs = [
|
|
94
|
+
{
|
|
95
|
+
id: "overview",
|
|
96
|
+
label: "Overview",
|
|
97
|
+
content: (
|
|
98
|
+
<div className="space-y-4">
|
|
99
|
+
<KeyValueList columns={2} items={[
|
|
100
|
+
{ label: "Keyword", value: keyword.keyword },
|
|
101
|
+
{ label: "Type", value: keyword.signal_type ?? "-" },
|
|
102
|
+
{ label: "Companies resolved", value: keyword.total_resolved_companies ?? 0 },
|
|
103
|
+
{ label: "People resolved", value: keyword.total_resolved_contacts ?? 0 },
|
|
104
|
+
{ label: "Pages tracked", value: keyword.total_urls ?? "-" },
|
|
105
|
+
{ label: "Auto sync", value: keyword.auto_sync_enabled == null ? "-" : keyword.auto_sync_enabled ? "on" : "off" },
|
|
106
|
+
]} />
|
|
107
|
+
<SectionCard title="Companies behind this keyword" description="Loading them is billable, so it asks first (Load companies above).">
|
|
108
|
+
{companies == null ? <p className="text-sm text-muted-foreground">Not loaded yet.</p> : companies.length === 0 ? <p className="text-sm text-muted-foreground">No resolved companies yet.</p> : (
|
|
109
|
+
<ul className="grid gap-1 text-sm md:grid-cols-2">{companies.map((c, i) => <li key={i}>{companyName(c)}</li>)}</ul>
|
|
110
|
+
)}
|
|
111
|
+
</SectionCard>
|
|
112
|
+
</div>
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
{ id: "accounts", label: "Accounts showing intent", content: <IntentFeed keywordIds={[keyword.id]} onSelectAccount={onSelectAccount} /> },
|
|
116
|
+
];
|
|
117
|
+
return (
|
|
118
|
+
<>
|
|
119
|
+
<DetailPanel title={keyword.keyword} subtitle="Tracked keyword" badges={<StatusPill tone={keyword.is_active === false ? "neutral" : "positive"}>{keyword.is_active === false ? "inactive" : "tracking"}</StatusPill>} actions={actions} tabs={tabs} onClose={onClose} onExpand={onExpand} onCollapse={onCollapse} />
|
|
120
|
+
{failure ? <ApiError error={failure} title="That did not work" /> : null}
|
|
121
|
+
<ConfirmSpendDialog {...dialog} />
|
|
122
|
+
</>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -13,17 +13,18 @@
|
|
|
13
13
|
"crm"
|
|
14
14
|
],
|
|
15
15
|
"operations": [
|
|
16
|
-
"
|
|
16
|
+
"get_account_intent_signals_accounts__account_id__intent_signals_get",
|
|
17
|
+
"get_brief_customer_success_companies__company_id__brief_get",
|
|
18
|
+
"get_company_companies__company_id__get",
|
|
17
19
|
"get_lifecycle_stage_counts_lifecycle_stages_counts_get",
|
|
20
|
+
"get_revenue_summary_accounts_revenue_summary_get",
|
|
21
|
+
"list_lifecycle_contacts_lifecycle_contacts_get",
|
|
18
22
|
"list_lifecycle_stage_records_lifecycle_stages__stage__records_get",
|
|
23
|
+
"list_lifecycle_stages_lifecycle_stages_get",
|
|
19
24
|
"move_company_lifecycle_stage_companies__company_id__lifecycle_stage_post",
|
|
20
25
|
"move_contact_lifecycle_stage_contacts__contact_id__lifecycle_stage_post",
|
|
21
|
-
"list_lifecycle_contacts_lifecycle_contacts_get",
|
|
22
|
-
"unlock_lifecycle_contacts_lifecycle_contacts_unlock_post",
|
|
23
|
-
"get_revenue_summary_accounts_revenue_summary_get",
|
|
24
|
-
"get_brief_customer_success_companies__company_id__brief_get",
|
|
25
26
|
"synthesize_brief_customer_success_companies__company_id__brief_synthesize_post",
|
|
26
|
-
"
|
|
27
|
+
"unlock_lifecycle_contacts_lifecycle_contacts_unlock_post"
|
|
27
28
|
],
|
|
28
29
|
"components": [
|
|
29
30
|
{
|
|
@@ -103,8 +104,12 @@
|
|
|
103
104
|
"name": "LifecycleScreen",
|
|
104
105
|
"file": "components/lifecycle-screen.tsx",
|
|
105
106
|
"purpose": "Finished lifecycle product: board, one tab per stage (contact queue, customers, churned) and a CRM record panel with Move stage.",
|
|
106
|
-
"operations": [
|
|
107
|
-
|
|
107
|
+
"operations": [
|
|
108
|
+
"get_company_companies__company_id__get"
|
|
109
|
+
],
|
|
110
|
+
"tiers": [
|
|
111
|
+
"read"
|
|
112
|
+
]
|
|
108
113
|
},
|
|
109
114
|
{
|
|
110
115
|
"name": "AccountBriefTab",
|
|
@@ -25,6 +25,7 @@ import { useLifecycleStages } from "../hooks/use-lifecycle-stages";
|
|
|
25
25
|
import { asRec, body, money } from "../lib/lifecycle";
|
|
26
26
|
import type { CompanyRecord, LifecycleStage } from "../lib/lifecycle";
|
|
27
27
|
import { StageMoveDialog } from "./stage-move-dialog";
|
|
28
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
28
29
|
|
|
29
30
|
export interface LifecycleBoardProps {
|
|
30
31
|
/** Open a company in your detail route (CompanyDetail from the crm block, for example). */
|
|
@@ -77,13 +78,14 @@ function Column({ stage, count, pageSize, version, onOpenCompany, onMove }: Colu
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
export function LifecycleBoard({ onOpenCompany, pageSize = 25, className }: LifecycleBoardProps) {
|
|
81
|
+
const brand = useBrand();
|
|
80
82
|
const { stages, countOf, loading, error, refetch } = useLifecycleStages();
|
|
81
83
|
const [moving, setMoving] = useState<{ record: CompanyRecord; stage: LifecycleStage } | null>(null);
|
|
82
84
|
const [version, setVersion] = useState(0);
|
|
83
85
|
|
|
84
86
|
if (loading) return <div className={className} aria-busy="true"><Skeleton className="h-48 w-full" /></div>;
|
|
85
87
|
if (error) return <ApiError error={error} onRetry={() => void refetch()} />;
|
|
86
|
-
if (stages.length === 0) return <EmptyState title="No lifecycle stages" description=
|
|
88
|
+
if (stages.length === 0) return <EmptyState title="No lifecycle stages" description={`Stages are configured in ${brand.linkLabel} under Revenue settings.`} />;
|
|
87
89
|
|
|
88
90
|
return (
|
|
89
91
|
<div className={className}>
|
|
@@ -47,6 +47,7 @@ import { CustomerTable } from "./customer-table";
|
|
|
47
47
|
import { LeadQueue } from "./lead-queue";
|
|
48
48
|
import { LifecycleBoard } from "./lifecycle-board";
|
|
49
49
|
import { StageMoveDialog } from "./stage-move-dialog";
|
|
50
|
+
import { useBrand } from "@/g8/hooks/use-brand";
|
|
50
51
|
|
|
51
52
|
export interface LifecycleScreenProps {
|
|
52
53
|
/** Buttons next to "Move stage" in the record panel. */
|
|
@@ -78,6 +79,7 @@ function StageTab({ stage, version, onOpenCompany, onOpenContact }: { stage: Lif
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
export function LifecycleScreen({ renderDetailActions, extraDetailTabs, toolbarActions }: LifecycleScreenProps) {
|
|
82
|
+
const brand = useBrand();
|
|
81
83
|
const { stages, countOf, loading, error, refetch } = useLifecycleStages();
|
|
82
84
|
const [tabParam, setTab] = useUrlState("tab", "board");
|
|
83
85
|
const [raw, setRaw] = useUrlState("rec");
|
|
@@ -123,7 +125,7 @@ export function LifecycleScreen({ renderDetailActions, extraDetailTabs, toolbarA
|
|
|
123
125
|
|
|
124
126
|
let body: ReactNode;
|
|
125
127
|
if (error) body = <ApiError error={error} title="Could not load lifecycle stages" onRetry={() => void refetch()} />;
|
|
126
|
-
else if (!loading && stages.length === 0) body = <EmptyState title="No lifecycle stages" description=
|
|
128
|
+
else if (!loading && stages.length === 0) body = <EmptyState title="No lifecycle stages" description={`Stages are configured in ${brand.linkLabel} under Revenue settings.`} />;
|
|
127
129
|
else if (activeStage) body = <StageTab stage={activeStage} version={version} onOpenCompany={openCompany} onOpenContact={openContact} />;
|
|
128
130
|
else body = <LifecycleBoard key={version} onOpenCompany={openCompany} />;
|
|
129
131
|
|
|
@@ -85,7 +85,7 @@ export function money(cents: number | null | undefined, currency = "USD"): strin
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
export function contactName(c: ContactRecord): string {
|
|
88
|
-
return [c.first_name, c.last_name].filter(Boolean).join(" ") ||
|
|
88
|
+
return [c.first_name, c.last_name].filter(Boolean).join(" ") || (c.id != null ? `#${c.id}` : "-");
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/** A masked field arrives as `"***"`; the real value needs an unlock (billable) or an enrichment. */
|
package/blocks/lists/README.md
CHANGED
|
@@ -6,6 +6,19 @@ It builds on the `crm` block (the members panel is a `ContactTable`).
|
|
|
6
6
|
|
|
7
7
|
Install: `npx @graph8/sdk add lists` (installs `foundation` and `crm` first).
|
|
8
8
|
|
|
9
|
+
## Screens
|
|
10
|
+
|
|
11
|
+
Two finished pages. Mount one as a route with no props (`<ListsScreen />`); both take optional `toolbarActions`,
|
|
12
|
+
`renderRowActions` and `renderDetailActions` slots for cross-block glue.
|
|
13
|
+
|
|
14
|
+
| Screen | What it does |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `ListsScreen` | Searchable, paged table of lists with member counts and build status; **New list** / Edit / Delete (confirmed) in a side sheet; open a list (`?id=`) for its Members tab (contacts, remove from list with confirmation), **Add contacts** from the CRM (conflicts shown, never auto-picked), **Refresh** for dynamic lists, **Check health**, and a Details tab. **Export** downloads a list as CSV or XLSX. `?view=full` opens a list or a suppressed contact full-page. Company lists show counts; the API has no read operation for company-list members, so they link out to the graph8 app. |
|
|
17
|
+
| `SuppressionsScreen` | Do-not-contact ledger filtered by channel and reason; **Suppress contacts** by id or by ticking people from the CRM (default channel: all); check any contact id; open a row for the contact's record and live status; **Reinstate** (confirmed, lifts every suppression on the contact). |
|
|
18
|
+
|
|
19
|
+
Prerequisites: `crm` installed (the CLI does it), proxy allowlist `lists` and `contacts`, key scopes `lists:read/write/delete`
|
|
20
|
+
and `contacts:read/write`. The screens use the foundation screen kit (`components/screen/*`, `hooks/use-url-state`).
|
|
21
|
+
|
|
9
22
|
## What you get
|
|
10
23
|
|
|
11
24
|
| Component | Use it for | Embed props |
|
package/blocks/lists/block.json
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"members panel built on the CRM ContactTable with remove-from-list",
|
|
9
9
|
"ListPicker (search or create) and AddToListButton for any contact selection",
|
|
10
10
|
"conflict review when contacts belong to other reps",
|
|
11
|
-
"suppression ledger by channel and reason, bulk suppress, check, reinstate"
|
|
11
|
+
"suppression ledger by channel and reason, bulk suppress, check, reinstate",
|
|
12
|
+
"finished ListsScreen and SuppressionsScreen pages"
|
|
12
13
|
],
|
|
13
14
|
"dependsOn": [
|
|
14
15
|
"crm"
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
"check_list_integrity_lists__list_id__integrity_check_post",
|
|
21
22
|
"create_list_lists_post",
|
|
22
23
|
"delete_list_lists__list_id__delete",
|
|
24
|
+
"download_list_lists__audience_id__download_get",
|
|
23
25
|
"list_contact_fields_fields_get",
|
|
24
26
|
"list_contacts_contacts_get",
|
|
25
27
|
"list_lists_lists_get",
|
|
@@ -90,6 +92,41 @@
|
|
|
90
92
|
"read",
|
|
91
93
|
"write"
|
|
92
94
|
]
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "ListsScreen",
|
|
98
|
+
"file": "components/lists-screen.tsx",
|
|
99
|
+
"purpose": "Finished Lists page: searchable table, create/edit/delete, member panel with add and remove, refresh and health check.",
|
|
100
|
+
"operations": [
|
|
101
|
+
"check_list_integrity_lists__list_id__integrity_check_post",
|
|
102
|
+
"create_list_lists_post",
|
|
103
|
+
"delete_list_lists__list_id__delete",
|
|
104
|
+
"download_list_lists__audience_id__download_get",
|
|
105
|
+
"list_lists_lists_get",
|
|
106
|
+
"refresh_dynamic_list_lists__audience_id__refresh_post",
|
|
107
|
+
"remove_contacts_from_list_lists__list_id__contacts_delete",
|
|
108
|
+
"update_list_lists__list_id__patch"
|
|
109
|
+
],
|
|
110
|
+
"tiers": [
|
|
111
|
+
"read",
|
|
112
|
+
"write",
|
|
113
|
+
"destructive"
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"name": "SuppressionsScreen",
|
|
118
|
+
"file": "components/suppressions-screen.tsx",
|
|
119
|
+
"purpose": "Finished do-not-contact page: filtered ledger, suppress by id or from the CRM, contact detail, confirmed reinstate.",
|
|
120
|
+
"operations": [
|
|
121
|
+
"bulk_add_suppressions_contacts_suppressions_bulk_add_post",
|
|
122
|
+
"check_contact_suppression_contacts__contact_id__suppression_get",
|
|
123
|
+
"list_suppressions_contacts_suppressions_get",
|
|
124
|
+
"reinstate_suppressions_contacts_suppressions_reinstate_post"
|
|
125
|
+
],
|
|
126
|
+
"tiers": [
|
|
127
|
+
"read",
|
|
128
|
+
"write"
|
|
129
|
+
]
|
|
93
130
|
}
|
|
94
131
|
],
|
|
95
132
|
"hooks": [],
|
|
@@ -161,6 +198,7 @@
|
|
|
161
198
|
}
|
|
162
199
|
],
|
|
163
200
|
"peerDependencies": {
|
|
201
|
+
"lucide-react": ">=0.400.0",
|
|
164
202
|
"react": ">=18"
|
|
165
203
|
}
|
|
166
204
|
}
|
|
@@ -21,6 +21,7 @@ import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogH
|
|
|
21
21
|
import { ApiError } from "@/g8/components/api-error";
|
|
22
22
|
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
23
23
|
import { g8Post } from "@/g8/lib/g8-client";
|
|
24
|
+
import { plural } from "@/g8/lib/plural";
|
|
24
25
|
import { ListPicker } from "./list-picker";
|
|
25
26
|
import type { ListSummary } from "./list-picker";
|
|
26
27
|
|
|
@@ -63,7 +64,7 @@ export function AddToListButton({ contactIds, listId, label = "Add to list", var
|
|
|
63
64
|
...(resolution ? { conflict_resolution: resolution } : {}),
|
|
64
65
|
});
|
|
65
66
|
return { listId: destination as number, added: res.data.added, already_member: res.data.already_member ?? [], not_found: res.data.not_found ?? [] };
|
|
66
|
-
}, { onSuccess: (r) => { setResult(r); onAdded?.(r); } });
|
|
67
|
+
}, { invalidates: [["lists"], ["contacts", "list"]], onSuccess: (r) => { setResult(r); onAdded?.(r); } });
|
|
67
68
|
|
|
68
69
|
const conflict = conflictOf(add.error);
|
|
69
70
|
const close = () => { setOpen(false); setResult(null); setTarget(null); add.reset(); };
|
|
@@ -74,7 +75,7 @@ export function AddToListButton({ contactIds, listId, label = "Add to list", var
|
|
|
74
75
|
<Dialog open={open} onOpenChange={(o) => (o ? setOpen(true) : close())}>
|
|
75
76
|
<DialogContent>
|
|
76
77
|
<DialogHeader>
|
|
77
|
-
<DialogTitle>Add {
|
|
78
|
+
<DialogTitle>Add {plural(contactIds.length, "contact")} to a list</DialogTitle>
|
|
78
79
|
<DialogDescription>Contacts already in the list are skipped.</DialogDescription>
|
|
79
80
|
</DialogHeader>
|
|
80
81
|
|
|
@@ -31,6 +31,9 @@ export interface ListSummary {
|
|
|
31
31
|
contact_count?: number | null;
|
|
32
32
|
company_count?: number | null;
|
|
33
33
|
is_dynamic?: boolean;
|
|
34
|
+
source?: string | null;
|
|
35
|
+
created_by?: string | null;
|
|
36
|
+
tags?: string[];
|
|
34
37
|
created_at?: string | null;
|
|
35
38
|
updated_at?: string | null;
|
|
36
39
|
}
|
|
@@ -60,8 +63,9 @@ export function ListPicker({ value, onChange, type = "contacts", allowCreate = t
|
|
|
60
63
|
const [search, setSearch] = useState("");
|
|
61
64
|
const [newName, setNewName] = useState(suggestedName ?? "");
|
|
62
65
|
const q = useDebounced(search.trim());
|
|
63
|
-
const lists = useG8Query((signal) => g8Get<ListSummary[]>("/lists", { search: q || undefined, limit: 50 }, { signal }), [q]);
|
|
66
|
+
const lists = useG8Query((signal) => g8Get<ListSummary[]>("/lists", { search: q || undefined, limit: 50 }, { signal }), [q], { key: ["lists", "picker", { q }] });
|
|
64
67
|
const create = useG8Mutation((title: string) => g8Post<ListSummary>("/lists", { title, type }), {
|
|
68
|
+
invalidates: [["lists"]],
|
|
65
69
|
onSuccess: (res) => { setNewName(""); void lists.refetch(); onChange({ type, ...res.data, title: res.data.title ?? newName }); },
|
|
66
70
|
});
|
|
67
71
|
|