@growth-operations/ui-shared 1.24.0 → 1.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-operations/ui-shared",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { Flex, Tile, Text, LoadingSpinner } from "@hubspot/ui-extensions";
2
+ import { Flex, LoadingSpinner } from "@hubspot/ui-extensions";
3
3
  import { InstallProgress } from "./InstallProgress";
4
4
  import { HealthHero } from "./HealthHero";
5
5
  import { OnboardingChecklist } from "./OnboardingChecklist";
@@ -10,50 +10,16 @@ import { PeriodComparison } from "./PeriodComparison";
10
10
  import { FeatureNudges } from "./FeatureNudges";
11
11
  import { HelpfulLinks } from "./HelpfulLinks";
12
12
 
13
- // Minimal inline ATS tile (the `pipeline` block). Kept tiny here rather than a
14
- // separate file — it's the simplest of the three archetype tiles. Renders
15
- // nothing when there's no pipeline block.
16
- function PipelineTile({ pipeline }) {
17
- if (!pipeline) return null;
18
- const byStage = pipeline.applications_by_stage ?? {};
19
- return (
20
- <Tile>
21
- <Flex direction="column" gap="small">
22
- <Text format={{ fontWeight: "bold" }}>Pipeline</Text>
23
- <Flex direction="row" gap="large">
24
- <Flex direction="column" gap="extra-small">
25
- <Text format={{ fontWeight: "demibold" }}>Open jobs</Text>
26
- <Text>{pipeline.open_jobs ?? 0}</Text>
27
- </Flex>
28
- <Flex direction="column" gap="extra-small">
29
- <Text format={{ fontWeight: "demibold" }}>Outstanding scorecards</Text>
30
- <Text>{pipeline.outstanding_scorecards ?? 0}</Text>
31
- </Flex>
32
- </Flex>
33
- {Object.keys(byStage).length > 0 && (
34
- <Flex direction="row" gap="medium">
35
- {Object.entries(byStage).map(([stage, count]) => (
36
- <Flex key={stage} direction="column" gap="extra-small">
37
- <Text format={{ fontWeight: "demibold" }}>{stage}</Text>
38
- <Text>{count}</Text>
39
- </Flex>
40
- ))}
41
- </Flex>
42
- )}
43
- </Flex>
44
- </Tile>
45
- );
46
- }
47
-
48
13
  // Picks the single archetype activity tile (CONTRACT: at most ONE present).
49
- // sync_pulse -> SyncPulse; pipeline -> PipelineTile. The credit meter is
14
+ // sync_pulse -> SyncPulse. The credit meter is
50
15
  // deliberately NOT on Home anymore (Andrew's 2026-10 feedback) — it lives at
51
16
  // the top of the Usage tab; Home's credit story is the top BillingBanner's
52
17
  // trial states plus the post-checklist period comparison. Renders nothing
53
- // when no tile applies.
18
+ // when no tile applies. (The ATS `pipeline` tile was retired 2026-10-14 —
19
+ // product decision: it duplicated the dedicated Applications/Jobs/Scorecards
20
+ // tabs and its cold-started counters read wrong until churn.)
54
21
  function ArchetypeTile({ state }) {
55
22
  if (state.sync_pulse) return <SyncPulse syncPulse={state.sync_pulse} />;
56
- if (state.pipeline) return <PipelineTile pipeline={state.pipeline} />;
57
23
  return null;
58
24
  }
59
25
 
@@ -67,7 +33,7 @@ function ArchetypeTile({ state }) {
67
33
  // plan_fit says so)
68
34
  // + <PlanFitBanner> (upgrade_recommended only)
69
35
  // + <OnboardingChecklist> (only if not complete)
70
- // + the archetype activity tile (sync/pipeline)
36
+ // + the archetype activity tile (sync_pulse)
71
37
  // + <PeriodComparison> (post-checklist, when the
72
38
  // backend has a full closed period)
73
39
  // + <FeatureUsage> (once the non-billing setup steps
@@ -12,9 +12,10 @@ import {
12
12
  //
13
13
  // Composition: most-used first (what this install actually runs on), then
14
14
  // the not-in-use catalog as "Recommended to explore" — each with its
15
- // one-line rationale and setup-guide link. Each section renders only when it
16
- // has rows, and the whole thing renders NOTHING when the block is absent
17
- // (older backends, legacy installs) — absence is never an empty box.
15
+ // one-line rationale and (for super admins) its setup CTA links. Each
16
+ // section renders only when it has rows, and the whole thing renders NOTHING
17
+ // when the block is absent (older backends, legacy installs) — absence is
18
+ // never an empty box.
18
19
 
19
20
  // Singular/plural label for a row's unit ("credits" | "events") — the wire
20
21
  // field is credits_or_events, so an event-denominated feature must not be
@@ -61,29 +62,34 @@ function RecommendedTile({ notInUse, isSuperAdmin, onNavigate }) {
61
62
  <Text format={{ fontWeight: "bold" }}>Recommended to explore</Text>
62
63
  {picks.map((f) => {
63
64
  // Setup CTAs point at admin-only surfaces (the layout editor above
64
- // all) — show them to super admins only; everyone else keeps the
65
- // docs link (a non-admin clicking into a HubSpot permission wall is
66
- // worse than no link).
67
- const showCta = isSuperAdmin && f.ctaLabel && (f.ctaUrl || f.ctaRoute);
65
+ // all) — show them to super admins only; everyone else gets name +
66
+ // rationale alone (a non-admin clicking into a HubSpot permission
67
+ // wall is worse than no link).
68
+ //
69
+ // "Learn more →" (docs_url) intentionally NOT rendered (Jasper
70
+ // 2026-10-14): every feature's docs_url points at the same single
71
+ // setup guide — noise until per-feature articles exist. docs_url
72
+ // stays in the wire contract; re-enabling is a one-line revert.
73
+ const ctas = isSuperAdmin ? f.ctas : [];
68
74
  return (
69
75
  <Flex key={f.key} direction="column" gap="extra-small">
70
76
  <Text format={{ fontWeight: "demibold" }}>{f.name}</Text>
71
77
  {f.rationale ? <Text>{f.rationale}</Text> : null}
72
- {showCta && f.ctaUrl ? (
73
- <Link href={{ url: f.ctaUrl, external: true }}>
74
- {f.ctaLabel} →
75
- </Link>
76
- ) : null}
77
- {showCta && !f.ctaUrl && f.ctaRoute && onNavigate ? (
78
- <Button variant="secondary" onClick={() => onNavigate(f.ctaRoute)}>
79
- {f.ctaLabel}
80
- </Button>
81
- ) : null}
82
- {f.docsUrl ? (
83
- <Link href={{ url: f.docsUrl, external: true }}>
84
- Learn more →
85
- </Link>
86
- ) : null}
78
+ {ctas.map((cta) =>
79
+ cta.url ? (
80
+ <Link key={cta.label} href={{ url: cta.url, external: true }}>
81
+ {cta.label} →
82
+ </Link>
83
+ ) : onNavigate ? (
84
+ <Button
85
+ key={cta.label}
86
+ variant="secondary"
87
+ onClick={() => onNavigate(cta.route)}
88
+ >
89
+ {cta.label}
90
+ </Button>
91
+ ) : null
92
+ )}
87
93
  </Flex>
88
94
  );
89
95
  })}
@@ -15,9 +15,21 @@
15
15
  const normalizeUnit = (u) =>
16
16
  typeof u === "string" && u.toLowerCase().startsWith("event") ? "events" : "credits";
17
17
 
18
+ // One well-formed setup CTA is {label: non-empty string, url XOR route:
19
+ // non-empty string}. Anything else (missing label, both targets, neither
20
+ // target, wrong types) is dropped — a malformed item never renders.
21
+ const normalizeCta = (c) => {
22
+ if (!c || typeof c !== "object") return null;
23
+ const label = typeof c.label === "string" && c.label ? c.label : null;
24
+ const url = typeof c.url === "string" && c.url ? c.url : null;
25
+ const route = typeof c.route === "string" && c.route ? c.route : null;
26
+ if (!label || !(url || route) || (url && route)) return null;
27
+ return url ? { label, url } : { label, route };
28
+ };
29
+
18
30
  // Resolve the feature_usage block into render-ready shape:
19
31
  // { mostUsed: [{ key, name, count, unit }],
20
- // notInUse: [{ key, name, docsUrl, rationale }] }
32
+ // notInUse: [{ key, name, docsUrl, rationale, ctas }] }
21
33
  // Returns null when the block is absent/malformed or BOTH lists are empty
22
34
  // (an install using every detectable feature has nothing to show here).
23
35
  export function resolveFeatureUsage(featureUsage) {
@@ -39,12 +51,10 @@ export function resolveFeatureUsage(featureUsage) {
39
51
  name: f.name,
40
52
  docsUrl: typeof f.docs_url === "string" && f.docs_url ? f.docs_url : null,
41
53
  rationale: typeof f.rationale === "string" ? f.rationale : "",
42
- // Optional SETUP action (the same cta_* vocabulary as nudges): the "go
43
- // do the thing" deep link for an unused feature — layout editor, store
44
- // listing, or in-app route. Absent on backends that predate it.
45
- ctaLabel: typeof f.cta_label === "string" && f.cta_label ? f.cta_label : null,
46
- ctaUrl: typeof f.cta_url === "string" && f.cta_url ? f.cta_url : null,
47
- ctaRoute: typeof f.cta_route === "string" && f.cta_route ? f.cta_route : null,
54
+ // Optional SETUP actions (1.26.0 — a LIST per feature: one feature can
55
+ // surface in multiple places, e.g. a card on both contacts and
56
+ // applications). Each item is {label, url|route}; absent/empty -> [].
57
+ ctas: (Array.isArray(f.ctas) ? f.ctas : []).map(normalizeCta).filter(Boolean),
48
58
  }));
49
59
 
50
60
  if (mostUsed.length === 0 && notInUse.length === 0) return null;