@growth-operations/ui-shared 1.19.0 → 1.21.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.
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { Flex, Tile, Text, ProgressBar, StatusTag, Alert } from "@hubspot/ui-extensions";
3
- import { daysUntil } from "../lib/format";
3
+ import { daysUntil, fmtDate } from "../lib/format";
4
4
  import { resolveCreditMeter } from "../lib/creditDisplay";
5
5
 
6
6
  // THE credit-archetype activity tile + hero signal. For credit apps, "credits
@@ -25,7 +25,10 @@ import { resolveCreditMeter } from "../lib/creditDisplay";
25
25
  // used-of-granted bar would drift to permanently-near-full over multiple
26
26
  // purchases; the remaining level refills on purchase and drains on
27
27
  // spend), colored by ITS OWN level: success above 50% full, warning at
28
- // 20–50%, danger at <=20% (view.bankBarVariant).
28
+ // <=50% (view.bankBarVariant). NO danger on this bar — HubSpot's
29
+ // ProgressBar paints the whole track red in danger, so a low gauge
30
+ // would read as FULL; at <=20% a warning "Running low" StatusTag rides
31
+ // the line instead (bank.bankLow).
29
32
  // Each bar colors by its own fill, NOT the account's threshold state — the
30
33
  // StatusTag keeps the account-state colors. Hosts whose backend predates
31
34
  // top_up_bank_granted degrade gracefully: the bank renders as a text-only
@@ -37,8 +40,11 @@ import { resolveCreditMeter } from "../lib/creditDisplay";
37
40
  // Props:
38
41
  // entitlement — the /v1/home entitlement union (uses the credits arm).
39
42
  // creditMeter — the /v1/home credit_meter block (optional).
40
- export function CreditMeter({ entitlement, creditMeter }) {
41
- const view = resolveCreditMeter(entitlement, creditMeter);
43
+ // credits — the /v1/home credits block (optional; the authoritative
44
+ // two-bucket monthly/additional numbers + renews_on when the
45
+ // backend carries it — see CONTRACT.md).
46
+ export function CreditMeter({ entitlement, creditMeter, credits = null }) {
47
+ const view = resolveCreditMeter(entitlement, creditMeter, credits);
42
48
  if (!view) return null;
43
49
 
44
50
  const { granted, used, remaining, bank, tagVariant, tagLabel, poolBarVariant, bankBarVariant, alert } = view;
@@ -60,27 +66,40 @@ export function CreditMeter({ entitlement, creditMeter }) {
60
66
  Monthly plan credits: {bank.poolRemaining} of {bank.poolGrant}{" "}
61
67
  left
62
68
  </Text>
69
+ {view.renewsOn && (
70
+ <Text variant="microcopy">Renews on {fmtDate(view.renewsOn)}</Text>
71
+ )}
63
72
  <ProgressBar
64
73
  title={`${bank.poolUsed} of ${bank.poolGrant} used`}
65
74
  value={bank.poolUsed}
66
75
  maxValue={bank.poolGrant > 0 ? bank.poolGrant : 1}
67
76
  showPercentage={true}
77
+ valueDescription={`${bank.poolUsed} of ${bank.poolGrant} used`}
68
78
  variant={poolBarVariant}
69
79
  />
70
80
  </Flex>
71
- {bank.bankGranted > 0 ? (
81
+ {/* The additional-credits line rides along only when the account
82
+ has (or ever bought) a bank — never "0 of 0 left". */}
83
+ {bank.showAdditional && (bank.bankGranted > 0 ? (
72
84
  // Bank — level gauge over the granted total (refills on
73
85
  // purchase, drains on spend; never expires).
74
86
  <Flex direction="column" gap="extra-small">
75
- <Text>
76
- Additional credits (never expire): {bank.bankRemaining} of{" "}
77
- {bank.bankGranted} left
78
- </Text>
87
+ <Flex direction="row" gap="extra-small" align="center">
88
+ <Text>
89
+ Additional credits: {bank.bankRemaining} of{" "}
90
+ {bank.bankGranted} left
91
+ </Text>
92
+ {bank.bankLow && (
93
+ <StatusTag variant="warning">Running low</StatusTag>
94
+ )}
95
+ </Flex>
96
+ <Text variant="microcopy">Does not expire</Text>
79
97
  <ProgressBar
80
98
  title={`${bank.bankRemaining} of ${bank.bankGranted} left`}
81
99
  value={bank.bankRemaining}
82
100
  maxValue={bank.bankGranted > 0 ? bank.bankGranted : 1}
83
101
  showPercentage={true}
102
+ valueDescription={`${bank.bankRemaining} of ${bank.bankGranted} left`}
84
103
  variant={bankBarVariant}
85
104
  />
86
105
  </Flex>
@@ -90,11 +109,9 @@ export function CreditMeter({ entitlement, creditMeter }) {
90
109
  // only, no bar (never divide by zero, never "of 0").
91
110
  <Flex direction="column" gap="extra-small">
92
111
  <Text>Additional credits: {bank.bankRemaining} left</Text>
93
- <Text format={{ fontStyle: "italic" }}>
94
- Additional credits don't expire.
95
- </Text>
112
+ <Text variant="microcopy">Does not expire</Text>
96
113
  </Flex>
97
- )}
114
+ ))}
98
115
  </>
99
116
  ) : (
100
117
  <>
@@ -107,6 +124,7 @@ export function CreditMeter({ entitlement, creditMeter }) {
107
124
  value={used}
108
125
  maxValue={granted > 0 ? granted : 1}
109
126
  showPercentage={true}
127
+ valueDescription={`${used} of ${granted} used`}
110
128
  variant={poolBarVariant}
111
129
  />
112
130
  </>
@@ -0,0 +1,75 @@
1
+ import React from "react";
2
+ import { Flex, Tile, Text, Link } from "@hubspot/ui-extensions";
3
+ import { featureUsageOf } from "../lib/featureUsage";
4
+
5
+ // The Home "Most used features" / "Recommended to explore" sections, fed by
6
+ // the additive state.feature_usage block (per-app feature registry + credit
7
+ // ledger aggregation in growth-ops-apps-base).
8
+ //
9
+ // Composition: most-used first (what this install actually runs on), then
10
+ // the not-in-use catalog as "Recommended to explore" — each with its
11
+ // one-line rationale and setup-guide link. Each section renders only when it
12
+ // has rows, and the whole thing renders NOTHING when the block is absent
13
+ // (older backends, legacy installs) — absence is never an empty box.
14
+
15
+ // Singular/plural label for a row's unit ("credits" | "events") — the wire
16
+ // field is credits_or_events, so an event-denominated feature must not be
17
+ // mislabeled as credits.
18
+ const unitLabel = (unit, count) => {
19
+ const singular = unit === "events" ? "event" : "credit";
20
+ return count === 1 ? singular : `${singular}s`;
21
+ };
22
+
23
+ function MostUsedTile({ mostUsed }) {
24
+ if (mostUsed.length === 0) return null;
25
+ return (
26
+ <Tile>
27
+ <Flex direction="column" gap="small">
28
+ <Text format={{ fontWeight: "bold" }}>Most used features</Text>
29
+ {mostUsed.map((f) => (
30
+ <Flex key={f.key} direction="row" gap="small">
31
+ <Text>{f.name}</Text>
32
+ <Text variant="microcopy">
33
+ {f.count} {unitLabel(f.unit, f.count)}
34
+ </Text>
35
+ </Flex>
36
+ ))}
37
+ </Flex>
38
+ </Tile>
39
+ );
40
+ }
41
+
42
+ function RecommendedTile({ notInUse }) {
43
+ if (notInUse.length === 0) return null;
44
+ return (
45
+ <Tile>
46
+ <Flex direction="column" gap="small">
47
+ <Text format={{ fontWeight: "bold" }}>Recommended to explore</Text>
48
+ {notInUse.map((f) => (
49
+ <Flex key={f.key} direction="column" gap="extra-small">
50
+ <Text format={{ fontWeight: "demibold" }}>{f.name}</Text>
51
+ {f.rationale ? <Text>{f.rationale}</Text> : null}
52
+ {f.docsUrl ? (
53
+ <Link href={{ url: f.docsUrl, external: true }}>
54
+ Read the guide →
55
+ </Link>
56
+ ) : null}
57
+ </Flex>
58
+ ))}
59
+ </Flex>
60
+ </Tile>
61
+ );
62
+ }
63
+
64
+ export function FeatureUsage({ state }) {
65
+ const view = featureUsageOf(state);
66
+ if (!view) return null;
67
+ return (
68
+ <>
69
+ <MostUsedTile mostUsed={view.mostUsed} />
70
+ <RecommendedTile notInUse={view.notInUse} />
71
+ </>
72
+ );
73
+ }
74
+
75
+ export default FeatureUsage;
@@ -23,6 +23,11 @@ export function HealthHero({ appName, state }) {
23
23
  const name = appName ?? state.app?.name;
24
24
 
25
25
  if (health?.level) {
26
+ // Plan-fit reinforcement: a healthy verdict on the right plan gets the
27
+ // extra reassurance line; upgrade_recommended is carried by health
28
+ // v2's "attention" reasons + the PlanFitBanner, not here.
29
+ const onRightPlan =
30
+ health.level === "healthy" && state.plan_fit?.verdict === "right_plan";
26
31
  return (
27
32
  <Flex direction="column" gap="small">
28
33
  {name && <Heading>{name}</Heading>}
@@ -34,6 +39,7 @@ export function HealthHero({ appName, state }) {
34
39
  {(health.reasons ?? []).map((r, i) => (
35
40
  <Text key={i}>{r}</Text>
36
41
  ))}
42
+ {onRightPlan && <Text>...and you're on the right plan</Text>}
37
43
  </Flex>
38
44
  </Alert>
39
45
  </Flex>
@@ -10,6 +10,12 @@ import { Flex, Tile, Text, ProgressBar, Link, Button } from "@hubspot/ui-extensi
10
10
  // `onNavigate(route)` is supplied by the host app (PageLink can't be created
11
11
  // here without the router); cta_url renders as an external Link.
12
12
  export function OnboardingChecklist({ onboarding, onNavigate }) {
13
+ // The Getting Started checklist disappears entirely once complete — no
14
+ // "all done" state (Andrew's 2026-10 feedback). AppHome gates on the same
15
+ // flag; this guard covers direct consumers.
16
+ if (onboarding?.complete === true) {
17
+ return null;
18
+ }
13
19
  if (!onboarding || !Array.isArray(onboarding.steps) || onboarding.steps.length === 0) {
14
20
  return null;
15
21
  }
@@ -0,0 +1,60 @@
1
+ import React from "react";
2
+ import { Flex, Tile, Text, ProgressBar } from "@hubspot/ui-extensions";
3
+ import { resolvePeriodComparison } from "../lib/homeBlocks";
4
+
5
+ // The post-checklist Home usage block: the last full billing period vs the
6
+ // current period-to-date, side by side — one bar per period (shared
7
+ // denominator, so the bars are visually comparable) with the per-action
8
+ // credit breakdown beneath each. Fed by state.period_comparison, which the
9
+ // backend leaves null until the install has one full closed period; this
10
+ // renders nothing then.
11
+ //
12
+ // Bars are sentiment-free (always "success") — this is a comparison, not a
13
+ // health signal. Normalization (labels, totals, by-action lists, the shared
14
+ // maxTotal) lives in ../lib/homeBlocks (unit-tested).
15
+ function PeriodColumn({ period, maxTotal }) {
16
+ return (
17
+ <Flex direction="column" gap="extra-small">
18
+ <Text format={{ fontWeight: "demibold" }}>{period.label}</Text>
19
+ <Text>
20
+ {period.total} {period.total === 1 ? "credit" : "credits"}
21
+ </Text>
22
+ <ProgressBar
23
+ title={`${period.label}: ${period.total} credits`}
24
+ value={period.total}
25
+ maxValue={maxTotal}
26
+ showPercentage={false}
27
+ valueDescription={`${period.total} credits`}
28
+ variant="success"
29
+ />
30
+ {period.byAction.length > 0 && (
31
+ <Flex direction="column" gap="extra-small">
32
+ {period.byAction.map((a) => (
33
+ <Text key={a.action} variant="microcopy">
34
+ {a.action}: {a.credits} {a.credits === 1 ? "credit" : "credits"}
35
+ </Text>
36
+ ))}
37
+ </Flex>
38
+ )}
39
+ </Flex>
40
+ );
41
+ }
42
+
43
+ export function PeriodComparison({ state }) {
44
+ const view = resolvePeriodComparison(state?.period_comparison);
45
+ if (!view) return null;
46
+
47
+ return (
48
+ <Tile>
49
+ <Flex direction="column" gap="small">
50
+ <Text format={{ fontWeight: "bold" }}>Credits by period</Text>
51
+ <Flex direction="row" gap="large">
52
+ <PeriodColumn period={view.last} maxTotal={view.maxTotal} />
53
+ <PeriodColumn period={view.current} maxTotal={view.maxTotal} />
54
+ </Flex>
55
+ </Flex>
56
+ </Tile>
57
+ );
58
+ }
59
+
60
+ export default PeriodComparison;
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { Text, Alert } from "@hubspot/ui-extensions";
3
+ import { resolvePlanFit } from "../lib/homeBlocks";
4
+
5
+ // The plan-fit savings banner: when state.plan_fit.verdict is
6
+ // "upgrade_recommended", tell the customer what staying on their current tier
7
+ // is costing them ("You could save $X/mo by moving to Professional — you
8
+ // bought 3 additional credit packs this period."). The right_plan verdict is
9
+ // NOT a banner — it reinforces the healthy hero instead (HealthHero reads
10
+ // state.plan_fit directly). Copy assembly lives in ../lib/homeBlocks
11
+ // (unit-tested), including the degraded phrasings when the backend can't
12
+ // price the savings yet.
13
+ //
14
+ // Renders nothing when the block is absent or the verdict isn't
15
+ // upgrade_recommended.
16
+ export function PlanFitBanner({ state }) {
17
+ const fit = resolvePlanFit(state?.plan_fit);
18
+ if (!fit || fit.kind !== "upgrade_recommended") return null;
19
+
20
+ return (
21
+ <Alert title="You could save on a higher plan" variant="warning">
22
+ <Text>{fit.text}</Text>
23
+ </Alert>
24
+ );
25
+ }
26
+
27
+ export default PlanFitBanner;
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+ import { Flex, Text, Alert } from "@hubspot/ui-extensions";
3
+ import { resolveTrialBanner } from "../lib/homeBlocks";
4
+
5
+ // The Home trial banner for credit apps — Andrew's 2026-10 copy, in all three
6
+ // states (active countdown / ended-with-credits-left / out of credits), with
7
+ // the "Need help?" footer line. State selection + copy live in
8
+ // ../lib/homeBlocks (unit-tested); this is pure rendering.
9
+ //
10
+ // Renders nothing for paid plans, non-credits modes, pre-install, or a
11
+ // backend that carries no grant clock (resolveTrialBanner returns null).
12
+ //
13
+ // Props:
14
+ // state — the /v1/home payload (reads entitlement + app.name).
15
+ // appName — display name override; defaults to state.app.name.
16
+ export function TrialBanner({ state, appName }) {
17
+ const banner = resolveTrialBanner(state?.entitlement, {
18
+ appName: appName ?? state?.app?.name,
19
+ });
20
+ if (!banner) return null;
21
+
22
+ return (
23
+ <Alert title={banner.title} variant={banner.variant}>
24
+ <Flex direction="column" gap="extra-small">
25
+ <Text>{banner.body}</Text>
26
+ <Text variant="microcopy">{banner.footer}</Text>
27
+ </Flex>
28
+ </Alert>
29
+ );
30
+ }
31
+
32
+ export default TrialBanner;
@@ -0,0 +1,10 @@
1
+ // Reserved slot for the later "most used / not in use" Home section
2
+ // (separate workstream — do NOT build it here). AppHome renders this directly
3
+ // below PeriodComparison in the post-checklist cockpit; it intentionally
4
+ // renders nothing until that section lands, so the composition boundary is
5
+ // already in place.
6
+ export function UsageHighlights({ state }) {
7
+ return null;
8
+ }
9
+
10
+ export default UsageHighlights;
package/src/index.js CHANGED
@@ -11,14 +11,20 @@ export { HealthHero } from "./home/HealthHero";
11
11
  export { OnboardingChecklist } from "./home/OnboardingChecklist";
12
12
  export { SyncPulse } from "./home/SyncPulse";
13
13
  export { FeatureNudges } from "./home/FeatureNudges";
14
+ export { FeatureUsage } from "./home/FeatureUsage";
14
15
  export { CreditMeter } from "./home/CreditMeter";
15
16
  export { HelpfulLinks } from "./home/HelpfulLinks";
17
+ export { TrialBanner } from "./home/TrialBanner";
18
+ export { PlanFitBanner } from "./home/PlanFitBanner";
19
+ export { PeriodComparison } from "./home/PeriodComparison";
20
+ export { UsageHighlights } from "./home/UsageHighlights";
16
21
 
17
22
  // Billing.
18
23
  export { BillingTab } from "./billing/BillingTab";
19
24
  export { PlanGrid } from "./billing/PlanGrid";
20
25
  export { BillingBanner } from "./billing/BillingBanner";
21
26
  export { SubscriptionGate } from "./billing/SubscriptionGate";
27
+ export { AutoProvisionToggle } from "./billing/AutoProvisionToggle";
22
28
 
23
29
  // Alerts — the shared customer alerts table (level filter, sort, dismiss).
24
30
  export { AlertsTab } from "./alerts/AlertsTab";
@@ -44,12 +50,29 @@ export {
44
50
  STATUS_VARIANT,
45
51
  } from "./lib/format";
46
52
  export { useStrictModeEffect } from "./lib/useStrictModeEffect";
53
+ export { resolveFeatureUsage, featureUsageOf } from "./lib/featureUsage";
47
54
  export {
48
55
  hasTopUpBank,
49
56
  resolveCreditMeter,
50
57
  resolveBillingBanner,
51
58
  parseLimitInput,
52
59
  } from "./lib/creditDisplay";
60
+ export {
61
+ TRIAL_BANNER_FOOTER,
62
+ resolveTrialBanner,
63
+ resolvePlanFit,
64
+ resolvePeriodComparison,
65
+ } from "./lib/homeBlocks";
66
+ export {
67
+ AUTO_PROVISION_TITLE,
68
+ AUTO_PROVISION_CONFIRM_YES,
69
+ AUTO_PROVISION_CONFIRM_NO,
70
+ AUTO_PROVISION_IDLE,
71
+ autoProvisionSubtext,
72
+ autoProvisionConfirmBody,
73
+ resolveAutoProvision,
74
+ autoProvisionReducer,
75
+ } from "./lib/autoProvision";
53
76
 
54
77
  // HubSpot URL builders (app.hubspot.com record/property/list links).
55
78
  export {
@@ -74,6 +97,7 @@ export {
74
97
  getTopUps,
75
98
  getInvoices,
76
99
  setBillingLimit,
100
+ setAutoProvision,
77
101
  getSettings,
78
102
  updateSettings,
79
103
  makeSettingsApi,
@@ -0,0 +1,82 @@
1
+ // "Add Additional Credits Automatically" — the auto-provision toggle's copy
2
+ // (verbatim from Andrew's 2026-10 feedback, app name templated) plus the tiny
3
+ // state machine behind its ON→OFF confirmation flow. Framework-free so the
4
+ // flow is unit-testable; AutoProvisionToggle (../billing) stays thin.
5
+ //
6
+ // state.auto_provision = { enabled } is additive on the /v1/home payload and
7
+ // REPLACES the old billing-limit concept: enabled (the DEFAULT — also when
8
+ // the block is absent) buys another pack when monthly credits run out;
9
+ // disabled means no purchase and actions start failing the entitlement gate.
10
+
11
+ export const AUTO_PROVISION_TITLE = "Add Additional Credits Automatically";
12
+
13
+ export function autoProvisionSubtext(appName) {
14
+ return `If monthly credits are used, this will automatically purchase another pack of credits so use of ${appName} is not impacted.`;
15
+ }
16
+
17
+ export function autoProvisionConfirmBody(appName) {
18
+ return `Are you sure you want to disable automatic credit provisioning? If monthly credits are used, ${appName} will no longer function and workflow actions will fail.`;
19
+ }
20
+
21
+ export const AUTO_PROVISION_CONFIRM_YES = "Yes, disable this feature.";
22
+ export const AUTO_PROVISION_CONFIRM_NO =
23
+ "No, keep automatic credit provisioning enabled.";
24
+
25
+ // Read the account flag with its contract default: enabled UNLESS the block
26
+ // explicitly says false.
27
+ export function resolveAutoProvision(autoProvision) {
28
+ return { enabled: autoProvision?.enabled !== false };
29
+ }
30
+
31
+ // The toggle's interaction state. `enabled` itself lives in the component
32
+ // (seeded from the payload, updated on save); this machine owns everything
33
+ // around it: the ON→OFF confirmation and the in-flight POST.
34
+ // idle — nothing happening
35
+ // confirming — the user flipped ON→OFF; the confirmation panel is up and
36
+ // the toggle stays checked until they decide
37
+ // saving — the auto-provision POST is in flight
38
+ export const AUTO_PROVISION_IDLE = {
39
+ phase: "idle",
40
+ notice: null, // null | { variant: "success"|"error", text }
41
+ };
42
+
43
+ // Events:
44
+ // { type: "request", enabled } — the user flipped the toggle to `enabled`.
45
+ // OFF requests park in `confirming` (the dangerous direction); ON
46
+ // requests save immediately (no confirmation to turn protection ON).
47
+ // { type: "confirm" } — "Yes, disable this feature."
48
+ // { type: "cancel" } — "No, keep automatic credit provisioning enabled."
49
+ // { type: "saved" } — the POST succeeded
50
+ // { type: "failed", message } — the POST failed
51
+ export function autoProvisionReducer(state, action) {
52
+ switch (action.type) {
53
+ case "request":
54
+ if (state.phase === "saving") return state;
55
+ return action.enabled === false
56
+ ? { phase: "confirming", notice: null }
57
+ : { phase: "saving", notice: null };
58
+ case "confirm":
59
+ if (state.phase !== "confirming") return state;
60
+ return { phase: "saving", notice: null };
61
+ case "cancel":
62
+ if (state.phase !== "confirming") return state;
63
+ return { phase: "idle", notice: null };
64
+ case "saved":
65
+ return {
66
+ phase: "idle",
67
+ notice: { variant: "success", text: "Saved." },
68
+ };
69
+ case "failed":
70
+ return {
71
+ phase: "idle",
72
+ notice: {
73
+ variant: "error",
74
+ text: action.message ?? "Couldn't update automatic credit provisioning.",
75
+ },
76
+ };
77
+ case "dismiss-notice":
78
+ return { ...state, notice: null };
79
+ default:
80
+ return state;
81
+ }
82
+ }