@growth-operations/ui-shared 1.21.1 → 1.22.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.21.1",
3
+ "version": "1.22.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -19,6 +19,11 @@ import { resolveBillingBanner } from "../lib/creditDisplay";
19
19
  // successful top-up): actions are paused until the next
20
20
  // billing period or a successful top-up.
21
21
  //
22
+ // Andrew 2026-10-13 (banner consolidation): this is the ONE banner — the
23
+ // free-grant trial states (out of credits / trial ended -> RED, trial active
24
+ // -> YELLOW, with the "Need help?" footer) fly HERE on every page; the
25
+ // separate Home-mounted TrialBanner is retired. Copy reused verbatim from it.
26
+ //
22
27
  // Legacy metered accounts are unchanged: they only ever see threshold_state
23
28
  // "healthy"/"depleted" with over_included doing the talking — overage is
24
29
  // "paying overage, not blocked" (the 2026-09-02 incident: a paid account
@@ -35,6 +40,8 @@ import { resolveBillingBanner } from "../lib/creditDisplay";
35
40
  // actionsNoun — short noun phrase for the running-low / paused messages,
36
41
  // e.g. "file actions" / "task actions". Falls back to
37
42
  // "actions".
43
+ // appName — display name override for the trial-state copy; defaults to
44
+ // state.app.name, then "this app".
38
45
  // onNavigate — in-app PageLink-equivalent callback (the host owns the
39
46
  // router — see ui-shared's OnboardingChecklist for the same
40
47
  // pattern). Renders the in-app Billing CTA when provided;
@@ -49,10 +56,11 @@ import { resolveBillingBanner } from "../lib/creditDisplay";
49
56
  // and the external portal CTA (kind "portal") is NOT
50
57
  // suppressed. Omitting the prop keeps the legacy behavior
51
58
  // (button always rendered), so non-updated hosts don't break.
52
- export function BillingBanner({ state, actionLabel, actionsNoun, onNavigate, context, currentPath }) {
59
+ export function BillingBanner({ state, actionLabel, actionsNoun, appName, onNavigate, context, currentPath }) {
53
60
  const banner = resolveBillingBanner(state?.entitlement, {
54
61
  actionLabel,
55
62
  actionsNoun,
63
+ appName: appName ?? state?.app?.name,
56
64
  });
57
65
  if (!banner) return null;
58
66
 
@@ -88,6 +96,9 @@ export function BillingBanner({ state, actionLabel, actionsNoun, onNavigate, con
88
96
  <Alert title={banner.title} variant={banner.variant}>
89
97
  <Flex direction="column" gap="extra-small">
90
98
  <Text>{banner.message}</Text>
99
+ {/* The support footer line rides the trial-state banners (the same
100
+ microcopy the retired Home TrialBanner rendered). */}
101
+ {banner.footer && <Text variant="microcopy">{banner.footer}</Text>}
91
102
  {banner.cta?.kind === "portal" && portalStartUrl ? (
92
103
  <Button
93
104
  variant="secondary"
@@ -16,7 +16,7 @@ import {
16
16
  TableRow,
17
17
  TableCell,
18
18
  } from "@hubspot/ui-extensions";
19
- import { fmtDate, fmtMoney, daysUntil } from "../lib/format";
19
+ import { fmtDate, fmtMoney, daysUntil, SUPPORT_FOOTER } from "../lib/format";
20
20
  import { useStrictModeEffect } from "../lib/useStrictModeEffect";
21
21
  import { CreditMeter } from "../home/CreditMeter";
22
22
  import { PlanGrid } from "./PlanGrid";
@@ -82,7 +82,14 @@ function useRefreshedBillingActionTokens(billingBaseUrl, initialTokens) {
82
82
  // Color-coded account-status panel (per Andrew's billing feedback): trial =>
83
83
  // yellow + days left + end date; active => green + next renewal; past_due =>
84
84
  // red + warning. Replaces the low-value flat-rate invoice chart.
85
- function StatusPanel({ entitlement }) {
85
+ //
86
+ // plansAvailable / restartAvailable describe what the canceled customer can
87
+ // actually SEE below the panel (exported for tests). When a catalog gap
88
+ // leaves plans EMPTY (2026-10-13 sparkfly incident) the "below" copy would
89
+ // promise a grid that isn't there — so it swaps to copy that matches the
90
+ // standalone restart button (or, without a restart token, the Stripe link
91
+ // above), plus the support footer the dead end needs.
92
+ export function StatusPanel({ entitlement, plansAvailable = true, restartAvailable = false }) {
86
93
  if (!entitlement) return null;
87
94
  const {
88
95
  status,
@@ -207,8 +214,15 @@ function StatusPanel({ entitlement }) {
207
214
  }
208
215
 
209
216
  if (status === "canceled") {
210
- const resumeCta =
211
- mode === "credits"
217
+ // plansAvailable === false means the catalog mirror came back EMPTY — no
218
+ // grid renders below, so the copy must not say "below". With a restart
219
+ // token the standalone one-click restart button (rendered under this
220
+ // panel) is the recovery path; without one, the Stripe link above is.
221
+ const resumeCta = !plansAvailable
222
+ ? restartAvailable
223
+ ? "Restart your previous plan in one click — we charge your card on file and your data starts syncing again."
224
+ : "Manage your billing in Stripe with the link above to resume — your data starts syncing again."
225
+ : mode === "credits"
212
226
  ? "Choose a plan below to start a new subscription."
213
227
  : "Restart your previous plan below in one click, or choose a different one — either way your data starts syncing again.";
214
228
  return (
@@ -216,6 +230,9 @@ function StatusPanel({ entitlement }) {
216
230
  <Flex direction="column" gap="extra-small">
217
231
  <Text format={{ fontWeight: "bold" }}>Your subscription was canceled</Text>
218
232
  <Text>{resumeCta}</Text>
233
+ {!plansAvailable && (
234
+ <Text variant="microcopy">{SUPPORT_FOOTER}</Text>
235
+ )}
219
236
  </Flex>
220
237
  </Alert>
221
238
  );
@@ -228,7 +245,8 @@ function StatusPanel({ entitlement }) {
228
245
  );
229
246
  }
230
247
 
231
- function TrialSubscriptionBilling({ context, state, appKey, openIframe = null }) {
248
+ // Exported for tests (the canceled/empty-catalog restart fallback).
249
+ export function TrialSubscriptionBilling({ context, state, appKey, openIframe = null }) {
232
250
  const ent = state?.entitlement;
233
251
 
234
252
  // Install not provisioned with a Stripe customer yet — calm "finish setup"
@@ -316,6 +334,21 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
316
334
  (isTrialing && currentOrder != null && plans.length > 0) ||
317
335
  (needsCheckout && plans.length > 0);
318
336
 
337
+ // Empty-catalog restart fallback (2026-10-13 sparkfly incident): a catalog
338
+ // data gap can leave `plans` EMPTY for a canceled account — showPicker then
339
+ // hides the grid while the panel copy still promised one. With no grid to
340
+ // pick from, the one-click restart becomes THE recovery path: a standalone
341
+ // primary button hitting /v1/billing/restart/start with NO price_id (the
342
+ // billing service resolves the account's last subscription price
343
+ // server-side). Requires the restart action token; legacy/Anvil-mode
344
+ // entitlements are excluded defensively (BillingTab's mode switch already
345
+ // routes mode "legacy" to LegacyBilling, so this arm is unreachable for
346
+ // them — the guard keeps it that way if the switch ever changes).
347
+ const standaloneRestartUrl =
348
+ isCanceled && plans.length === 0 && ent.mode !== "legacy"
349
+ ? keepSubscriptionUrl
350
+ : null;
351
+
319
352
  return (
320
353
  <Flex direction="column" gap="medium">
321
354
  {/* PRIMARY CTA when there's an existing subscription to manage
@@ -331,7 +364,11 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
331
364
  {portalStartUrl ? "Manage billing in Stripe" : "Preparing billing…"}
332
365
  </LoadingButton>
333
366
 
334
- <StatusPanel entitlement={state?.entitlement} />
367
+ <StatusPanel
368
+ entitlement={state?.entitlement}
369
+ plansAvailable={!needsCheckout || plans.length > 0}
370
+ restartAvailable={standaloneRestartUrl != null}
371
+ />
335
372
 
336
373
  {/* Scheduled-cancel on an ACTIVE sub: one click flips
337
374
  cancel_at_period_end off in place (no re-checkout, no new sub) —
@@ -343,9 +380,23 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
343
380
  </Button>
344
381
  )}
345
382
 
383
+ {/* CANCELED with an EMPTY catalog (no grid to pick from): the
384
+ price_id-less one-click restart is the recovery path, rendered
385
+ under the status panel. Reuses keepSubscriptionUrl — the restart
386
+ endpoint never needed a price_id client-side. */}
387
+ {standaloneRestartUrl && (
388
+ <Button href={{ url: standaloneRestartUrl, external: true }} variant="primary">
389
+ Restart your previous plan
390
+ </Button>
391
+ )}
392
+
346
393
  <Text format={{ fontStyle: "italic" }}>
347
394
  {needsCheckout
348
- ? "Choose a plan below to resume. Once you have an active subscription, use the link above to manage payment methods or view invoices."
395
+ ? isCanceled && plans.length === 0
396
+ ? standaloneRestartUrl
397
+ ? "Restart charges your card on file and resumes syncing immediately — no checkout page. Once you have an active subscription, use the link above to manage payment methods or view invoices."
398
+ : "Once you have an active subscription, use the link above to manage payment methods or view invoices."
399
+ : "Choose a plan below to resume. Once you have an active subscription, use the link above to manage payment methods or view invoices."
349
400
  : "Billing is managed in Stripe across all Growth Operations apps. Use the link above to update your plan, payment method, or view invoices."}
350
401
  </Text>
351
402
 
@@ -5,7 +5,6 @@ import { HealthHero } from "./HealthHero";
5
5
  import { OnboardingChecklist } from "./OnboardingChecklist";
6
6
  import { SyncPulse } from "./SyncPulse";
7
7
  import { FeatureUsage } from "./FeatureUsage";
8
- import { TrialBanner } from "./TrialBanner";
9
8
  import { PlanFitBanner } from "./PlanFitBanner";
10
9
  import { PeriodComparison } from "./PeriodComparison";
11
10
  import { FeatureNudges } from "./FeatureNudges";
@@ -49,8 +48,9 @@ function PipelineTile({ pipeline }) {
49
48
  // Picks the single archetype activity tile (CONTRACT: at most ONE present).
50
49
  // sync_pulse -> SyncPulse; pipeline -> PipelineTile. The credit meter is
51
50
  // deliberately NOT on Home anymore (Andrew's 2026-10 feedback) — it lives at
52
- // the top of the Usage tab; Home's credit story is the trial banner plus the
53
- // post-checklist period comparison. Renders nothing when no tile applies.
51
+ // the top of the Usage tab; Home's credit story is the top BillingBanner's
52
+ // trial states plus the post-checklist period comparison. Renders nothing
53
+ // when no tile applies.
54
54
  function ArchetypeTile({ state }) {
55
55
  if (state.sync_pulse) return <SyncPulse syncPulse={state.sync_pulse} />;
56
56
  if (state.pipeline) return <PipelineTile pipeline={state.pipeline} />;
@@ -63,8 +63,7 @@ function ArchetypeTile({ state }) {
63
63
  // Lifecycle composition:
64
64
  // no state -> LoadingSpinner (still fetching /v1/home)
65
65
  // activation pending -> <InstallProgress> (install isn't finished yet)
66
- // activated cockpit -> <TrialBanner> (credit apps on the free grant)
67
- // + <HealthHero> (+ "...on the right plan" when
66
+ // activated cockpit -> <HealthHero> (+ "...on the right plan" when
68
67
  // plan_fit says so)
69
68
  // + <PlanFitBanner> (upgrade_recommended only)
70
69
  // + <OnboardingChecklist> (only if not complete)
@@ -136,10 +135,9 @@ export function AppHome({ state, onNavigate, appName, onRetry, helpfulLinks }) {
136
135
  callout above the cockpit; InstallProgress renders null when clean. */}
137
136
  <InstallProgress state={state} onRetry={onRetry} />
138
137
 
139
- {/* Trial states (active countdown / ended / out of credits) fly above
140
- the hero — they're the strongest call to action a free account
141
- has. Renders null for paid plans and non-credits modes. */}
142
- <TrialBanner state={state} appName={appName} />
138
+ {/* No Home trial banner anymore — Andrew 2026-10-13: trial states (and
139
+ every other app-stopping billing condition) fly in the ONE top
140
+ <BillingBanner> the app layout mounts above every route. */}
143
141
 
144
142
  <HealthHero appName={appName} state={state} />
145
143
 
@@ -26,8 +26,11 @@ export function HealthHero({ appName, state }) {
26
26
  // Plan-fit reinforcement: a healthy verdict on the right plan gets the
27
27
  // extra reassurance line; upgrade_recommended is carried by health
28
28
  // v2's "attention" reasons + the PlanFitBanner, not here.
29
+ // Dual-read: base nests plan_fit under payload.state; self-hosted
30
+ // backends MAY emit it top-level (same pattern as planFitOf).
31
+ const planFit = state.state?.plan_fit ?? state.plan_fit;
29
32
  const onRightPlan =
30
- health.level === "healthy" && state.plan_fit?.verdict === "right_plan";
33
+ health.level === "healthy" && planFit?.verdict === "right_plan";
31
34
  return (
32
35
  <Flex direction="column" gap="small">
33
36
  {name && <Heading>{name}</Heading>}
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { Flex, Tile, Text, ProgressBar } from "@hubspot/ui-extensions";
3
- import { resolvePeriodComparison } from "../lib/homeBlocks";
3
+ import { periodComparisonOf } from "../lib/homeBlocks";
4
4
 
5
5
  // The post-checklist Home usage block: the last full billing period vs the
6
6
  // current period-to-date, side by side — one bar per period (shared
@@ -41,7 +41,9 @@ function PeriodColumn({ period, maxTotal }) {
41
41
  }
42
42
 
43
43
  export function PeriodComparison({ state }) {
44
- const view = resolvePeriodComparison(state?.period_comparison);
44
+ // periodComparisonOf dual-reads payload.state.period_comparison (base) ??
45
+ // payload.period_comparison (self-hosted).
46
+ const view = periodComparisonOf(state);
45
47
  if (!view) return null;
46
48
 
47
49
  return (
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { Text, Alert } from "@hubspot/ui-extensions";
3
- import { resolvePlanFit } from "../lib/homeBlocks";
3
+ import { planFitOf } from "../lib/homeBlocks";
4
4
 
5
5
  // The plan-fit savings banner: when state.plan_fit.verdict is
6
6
  // "upgrade_recommended", tell the customer what staying on their current tier
@@ -14,7 +14,10 @@ import { resolvePlanFit } from "../lib/homeBlocks";
14
14
  // Renders nothing when the block is absent or the verdict isn't
15
15
  // upgrade_recommended.
16
16
  export function PlanFitBanner({ state }) {
17
- const fit = resolvePlanFit(state?.plan_fit);
17
+ // planFitOf dual-reads payload.state.plan_fit (base) ?? payload.plan_fit
18
+ // (self-hosted) — a top-level-only read left this banner dead on every
19
+ // base-hosted app (found 2026-10-13).
20
+ const fit = planFitOf(state);
18
21
  if (!fit || fit.kind !== "upgrade_recommended") return null;
19
22
 
20
23
  return (
package/src/index.js CHANGED
@@ -14,7 +14,6 @@ export { FeatureNudges } from "./home/FeatureNudges";
14
14
  export { FeatureUsage } from "./home/FeatureUsage";
15
15
  export { CreditMeter } from "./home/CreditMeter";
16
16
  export { HelpfulLinks } from "./home/HelpfulLinks";
17
- export { TrialBanner } from "./home/TrialBanner";
18
17
  export { PlanFitBanner } from "./home/PlanFitBanner";
19
18
  export { PeriodComparison } from "./home/PeriodComparison";
20
19
  export { UsageHighlights } from "./home/UsageHighlights";
@@ -48,20 +47,22 @@ export {
48
47
  statusVariant,
49
48
  STATUS_LABELS,
50
49
  STATUS_VARIANT,
50
+ SUPPORT_FOOTER,
51
51
  } from "./lib/format";
52
52
  export { useStrictModeEffect } from "./lib/useStrictModeEffect";
53
53
  export { resolveFeatureUsage, featureUsageOf } from "./lib/featureUsage";
54
54
  export {
55
55
  hasTopUpBank,
56
+ creditsBlockOf,
56
57
  resolveCreditMeter,
57
58
  resolveBillingBanner,
58
59
  parseLimitInput,
59
60
  } from "./lib/creditDisplay";
60
61
  export {
61
- TRIAL_BANNER_FOOTER,
62
- resolveTrialBanner,
63
62
  resolvePlanFit,
63
+ planFitOf,
64
64
  resolvePeriodComparison,
65
+ periodComparisonOf,
65
66
  } from "./lib/homeBlocks";
66
67
  export {
67
68
  AUTO_PROVISION_TITLE,
@@ -25,6 +25,7 @@
25
25
  // double-guards so a stale/mixed payload can't regress the 2026-09-02
26
26
  // incident where a paying, overage account was shown a blocking
27
27
  // "Depleted"/"out of credits" panel).
28
+ import { daysUntil, SUPPORT_FOOTER } from "./format";
28
29
 
29
30
  // True when the account has (or ever had) a top-up bank. Note the CreditMeter
30
31
  // bank section renders only while the bank HAS credits left (bankRemaining >
@@ -98,6 +99,15 @@ function bankBarVariantFor(bankRemaining, bankGranted) {
98
99
  if (bankRemaining * 100 <= 50 * bankGranted) return "warning";
99
100
  return "success";
100
101
  }
102
+ // The credits block lives at payload.state.credits on base-hosted backends; a
103
+ // self-hosted backend MAY emit it top-level. Dual-read (nested wins), same as
104
+ // featureUsageOf/planFitOf — without this the meter falls back to the legacy
105
+ // single-bar path (no monthly/additional split, no "renews on" line).
106
+ export function creditsBlockOf(state) {
107
+ if (!state || typeof state !== "object") return null;
108
+ return state.state?.credits ?? state.credits ?? null;
109
+ }
110
+
101
111
  export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
102
112
  const ent = entitlement?.mode === "credits" ? entitlement : null;
103
113
  const meter = creditMeter ?? null;
@@ -302,9 +312,77 @@ export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
302
312
  };
303
313
  }
304
314
 
315
+ // --- Free-grant trial states (consolidated into the TOP banner) ------------
316
+
317
+ // Andrew 2026-10-13: ONE banner at the top of every page. Anything that STOPS
318
+ // the app — out of credits, trial ended, payment failed — flies RED in the
319
+ // top BillingBanner; "trial active" flies YELLOW there too. The separate
320
+ // Home-mounted TrialBanner is retired; its copy (approved by the product
321
+ // owner) is reused VERBATIM here — only the placement and the trial-ended
322
+ // variant (warning -> error: it stops the app) changed.
323
+ //
324
+ // First match wins inside trialStateOf, mirroring the retired resolver:
325
+ // out of credits — the free grant is spent (depleted / remaining <= 0).
326
+ // Beats the clock: a burned-through grant is "out of
327
+ // credits" even with days left.
328
+ // trial ended — the grant's 30-day clock ran out with credits left.
329
+ // trial active — the clock is still running: "[XX] trial days remaining".
330
+ // Returns { kind, title, body } or null (no grant clock on record).
331
+ function trialStateOf(ent, { appName } = {}) {
332
+ const name = appName ?? "this app";
333
+ const granted = ent.granted ?? 0;
334
+ const used = ent.used ?? 0;
335
+ const remaining = ent.remaining ?? Math.max(granted - used, 0);
336
+ const daysLeft =
337
+ ent.grant_expires_at != null ? daysUntil(ent.grant_expires_at) : null;
338
+
339
+ if (ent.depleted === true || remaining <= 0) {
340
+ return {
341
+ kind: "out_of_credits",
342
+ title: "You're out of credits",
343
+ body: `You have used ${used}/${granted} free credits. Select one of the plans below to continue use of ${name}.`,
344
+ };
345
+ }
346
+ if (daysLeft != null && daysLeft <= 0) {
347
+ return {
348
+ kind: "trial_ended",
349
+ title: "Your trial has ended",
350
+ body: `Your free trial has ended. Select a plan to continue use of ${name}.`,
351
+ };
352
+ }
353
+ if (daysLeft != null) {
354
+ return {
355
+ kind: "trial_active",
356
+ title: `${daysLeft} trial days remaining`,
357
+ body: `You have ${daysLeft} days remaining in your trial period. Select a plan to continue use of ${name}.`,
358
+ };
359
+ }
360
+ return null;
361
+ }
362
+
363
+ // Trial states map onto the banner shape with the shared support footer and a
364
+ // Billing-tab CTA (the copy asks for a plan; on non-Home pages nothing is
365
+ // "below", so the button routes to the picker). out-of-credits/trial-ended
366
+ // are RED (they stop the app), trial-active is YELLOW — Andrew 2026-10-13.
367
+ const TRIAL_STATE_VARIANT = {
368
+ out_of_credits: "error",
369
+ trial_ended: "error",
370
+ trial_active: "warning",
371
+ };
372
+
373
+ function trialBannerView(trial) {
374
+ return {
375
+ variant: TRIAL_STATE_VARIANT[trial.kind],
376
+ title: trial.title,
377
+ message: trial.body,
378
+ footer: SUPPORT_FOOTER,
379
+ cta: { kind: "billing", label: "Go to Billing →" },
380
+ };
381
+ }
382
+
305
383
  // Resolve the app-wide BillingBanner from the credits entitlement arm.
306
384
  // Returns null to render nothing (non-credits mode, pre-install, or healthy),
307
- // else { variant, title, message, cta } where cta is null or:
385
+ // else { variant, title, message, footer?, cta } where cta is null or:
308
386
  // { kind: "portal", label } — link out to the Stripe billing-portal entry
309
387
  // point (payment-method fixes)
310
388
  // { kind: "billing", label } — in-app navigate to the Billing tab
@@ -312,7 +390,23 @@ export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
312
390
  // Driven by threshold_state for the top-up model; the legacy metered branches
313
391
  // (free depleted / paid over_included / free running-low) are byte-identical
314
392
  // to the pre-top-up banner.
315
- export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } = {}) {
393
+ //
394
+ // Precedence (first match wins):
395
+ // 1. top_up_pending (info) — transient in-flight top-up
396
+ // 2. top_up_failed (error) — card on file failed to process
397
+ // 3. limit_reached (warning)
398
+ // 4. depleted (error) — THE out-of-credits block; existing
399
+ // byte-identical copy wins whenever the
400
+ // explicit signals (threshold_state /
401
+ // depleted flag / entitled=false) fire
402
+ // 5. trial out_of_credits (error) — free grant spent WITHOUT the explicit
403
+ // signals (remaining <= 0 catch-all)
404
+ // 6. trial ended (error) — the grant clock ran out with credits left
405
+ // 7. over_included (warning) — paid metered overage (never blocked)
406
+ // 8. running low (warning) — free grant under low_threshold
407
+ // 9. trial active (warning) — the countdown; yields to running-low, the
408
+ // more urgent of the two yellow states
409
+ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun, appName } = {}) {
316
410
  const ent = entitlement;
317
411
  if (!ent || ent.mode !== "credits" || ent.status === "not_installed") {
318
412
  return null;
@@ -353,6 +447,14 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
353
447
  };
354
448
  }
355
449
 
450
+ // The free-grant trial states (null for paid plans — a paid plan is past
451
+ // the trial). Computed BEFORE the depleted branch: a lapsed trial clock
452
+ // explains a legacy `entitled: false` reading better than "out of credits"
453
+ // (credits remain; the GRANT expired), so trial_ended wins over that one
454
+ // legacy signal below — but never over an explicit depleted flag or
455
+ // threshold_state "depleted".
456
+ const trial = onPaidPlan ? null : trialStateOf(ent, { appName });
457
+
356
458
  // --- Hard-blocked. threshold_state "depleted" is the ONLY blocking state
357
459
  // (a topup-model paid plan blocks at true zero); `over_included` overrides
358
460
  // a depleted reading so a metered paid account in overage is never shown as
@@ -362,7 +464,9 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
362
464
  const depleted =
363
465
  thresholdState != null
364
466
  ? thresholdState === "depleted" && !overIncluded
365
- : !onPaidPlan && (ent.depleted === true || ent.entitled === false);
467
+ : !onPaidPlan &&
468
+ (ent.depleted === true ||
469
+ (ent.entitled === false && trial?.kind !== "trial_ended"));
366
470
 
367
471
  if (depleted) {
368
472
  return {
@@ -374,6 +478,11 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
374
478
  cta: { kind: "billing", label: "Go to Billing →" },
375
479
  };
376
480
  }
481
+ // Free-grant blockers the explicit signals didn't catch: the spent grant
482
+ // (remaining <= 0 without any flag) and the lapsed trial clock.
483
+ if (trial && (trial.kind === "out_of_credits" || trial.kind === "trial_ended")) {
484
+ return trialBannerView(trial);
485
+ }
377
486
  if (overIncluded) {
378
487
  return {
379
488
  variant: "warning",
@@ -396,6 +505,11 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
396
505
  cta: { kind: "billing", label: "Go to Billing →" },
397
506
  };
398
507
  }
508
+ // Trial active — the countdown flies at the top in YELLOW for the whole
509
+ // trial (Andrew 2026-10-13), yielding only to running-low above.
510
+ if (trial?.kind === "trial_active") {
511
+ return trialBannerView(trial);
512
+ }
399
513
 
400
514
  return null;
401
515
  }
package/src/lib/format.js CHANGED
@@ -1,6 +1,12 @@
1
1
  // Shared formatting + status helpers for Growth Operations app UIs.
2
2
  // Framework-agnostic (no React) so they can be unit-reasoned and reused.
3
3
 
4
+ // The support footer line shared by customer-facing dead-end/urgency surfaces
5
+ // (the top billing banner's trial states, the Billing tab's empty-catalog
6
+ // canceled panel). Formerly TRIAL_BANNER_FOOTER on the retired Home
7
+ // TrialBanner — promoted here once a second surface needed it.
8
+ export const SUPPORT_FOOTER = "Need help? info@growthoperationsco.com";
9
+
4
10
  // ISO datetime string (or ms epoch) -> "Mon DD, YYYY", null-safe.
5
11
  // new Date(NaN).toLocaleDateString() would throw, so guard it.
6
12
  export function fmtDate(value) {
@@ -1,71 +1,16 @@
1
1
  // Pure resolvers for the Home cockpit blocks added for Andrew's 2026-10
2
- // App Home feedback: the trial banner (three states), the plan-fit signal,
3
- // and the period-over-period credit comparison. Framework-free (no React) so
4
- // the branch math is unit-testable; the components in ../home stay thin.
2
+ // App Home feedback: the plan-fit signal and the period-over-period credit
3
+ // comparison. Framework-free (no React) so the branch math is unit-testable;
4
+ // the components in ../home stay thin.
5
+ //
6
+ // (The trial banner used to live here. Andrew 2026-10-13: ONE banner at the
7
+ // top of every page — the trial states moved into resolveBillingBanner in
8
+ // ./creditDisplay, copy verbatim, and the Home-mounted <TrialBanner> is
9
+ // retired.)
5
10
  //
6
11
  // Everything here degrades gracefully: every block is additive on the
7
12
  // /v1/home payload, and a resolver returns null (render nothing) whenever
8
13
  // the fields it needs are absent.
9
- import { daysUntil } from "./format";
10
-
11
- // --- Trial banner ----------------------------------------------------------
12
-
13
- // Rides under every trial banner (Andrew's exact copy).
14
- export const TRIAL_BANNER_FOOTER = "Need help? info@growthoperationsco.com";
15
-
16
- // Resolve the Home trial banner from the credits entitlement arm.
17
- // Returns null (render nothing) for non-credits modes, paid plans (the trial
18
- // is over), pre-install, or a backend that carries no grant clock. Otherwise
19
- // { variant, title, body, footer } — copy verbatim from Andrew, with the app
20
- // display name templated in.
21
- //
22
- // State selection (first match wins):
23
- // out of credits — the free grant is spent (depleted / remaining <= 0).
24
- // Beats the clock: a burned-through grant is "out of
25
- // credits" even with days left.
26
- // trial ended — the grant's 30-day clock ran out with credits left.
27
- // trial active — the clock is still running: "[XX] trial days remaining".
28
- export function resolveTrialBanner(entitlement, { appName } = {}) {
29
- const ent = entitlement;
30
- if (!ent || ent.mode !== "credits") return null;
31
- if (ent.plan) return null; // a paid plan is past the trial
32
- if (ent.status === "not_installed") return null;
33
- const name = appName ?? "this app";
34
-
35
- const granted = ent.granted ?? 0;
36
- const used = ent.used ?? 0;
37
- const remaining = ent.remaining ?? Math.max(granted - used, 0);
38
- const daysLeft =
39
- ent.grant_expires_at != null ? daysUntil(ent.grant_expires_at) : null;
40
-
41
- if (ent.depleted === true || remaining <= 0) {
42
- return {
43
- variant: "error",
44
- title: "You're out of credits",
45
- body: `You have used ${used}/${granted} free credits. Select one of the plans below to continue use of ${name}.`,
46
- footer: TRIAL_BANNER_FOOTER,
47
- };
48
- }
49
- if (daysLeft != null && daysLeft <= 0) {
50
- return {
51
- variant: "warning",
52
- title: "Your trial has ended",
53
- body: `Your free trial has ended. Select a plan to continue use of ${name}.`,
54
- footer: TRIAL_BANNER_FOOTER,
55
- };
56
- }
57
- if (daysLeft != null) {
58
- return {
59
- variant: "info",
60
- title: `${daysLeft} trial days remaining`,
61
- body: `You have ${daysLeft} days remaining in your trial period. Select a plan to continue use of ${name}.`,
62
- footer: TRIAL_BANNER_FOOTER,
63
- };
64
- }
65
- // No grant clock on record (a backend that predates grant_expires_at) —
66
- // nothing to count down, so no banner.
67
- return null;
68
- }
69
14
 
70
15
  // --- Plan fit ---------------------------------------------------------------
71
16
 
@@ -114,6 +59,15 @@ export function resolvePlanFit(planFit) {
114
59
  return null;
115
60
  }
116
61
 
62
+ // The plan_fit block lives at payload.state.plan_fit on base-hosted backends;
63
+ // a self-hosted backend MAY emit it top-level instead. Dual-read (nested
64
+ // wins), same as featureUsageOf — without this the banner/hero line silently
65
+ // never render (shipped dead 2026-10-12, found 2026-10-13).
66
+ export function planFitOf(state) {
67
+ if (!state || typeof state !== "object") return null;
68
+ return resolvePlanFit(state.state?.plan_fit ?? state.plan_fit);
69
+ }
70
+
117
71
  // --- Period comparison ------------------------------------------------------
118
72
 
119
73
  // Resolve the period_comparison block (null until the install has one full
@@ -144,3 +98,12 @@ export function resolvePeriodComparison(periodComparison) {
144
98
  maxTotal: Math.max(last.total, current.total, 1),
145
99
  };
146
100
  }
101
+
102
+ // Same nested-vs-top-level duality as planFitOf: base serves
103
+ // payload.state.period_comparison; self-hosted backends MAY go top-level.
104
+ export function periodComparisonOf(state) {
105
+ if (!state || typeof state !== "object") return null;
106
+ return resolvePeriodComparison(
107
+ state.state?.period_comparison ?? state.period_comparison
108
+ );
109
+ }
@@ -27,6 +27,7 @@ import {
27
27
  } from "@hubspot/ui-extensions";
28
28
 
29
29
  import { useStrictModeEffect } from "../lib/useStrictModeEffect";
30
+ import { creditsBlockOf } from "../lib/creditDisplay";
30
31
  import { CreditMeter } from "../home/CreditMeter";
31
32
 
32
33
  const DEFAULT_BASE_PATH = "/api/v1/usage";
@@ -56,10 +57,13 @@ export function UsageTab({
56
57
  return (
57
58
  <Flex direction="column" gap="medium">
58
59
  {state && (
60
+ // creditsBlockOf dual-reads payload.state.credits (base) ??
61
+ // payload.credits (self-hosted) — a top-level-only read silently
62
+ // dropped the two-pool split + "renews on" line on base-hosted apps.
59
63
  <CreditMeter
60
64
  entitlement={state.entitlement}
61
65
  creditMeter={state.credit_meter}
62
- credits={state.credits}
66
+ credits={creditsBlockOf(state)}
63
67
  />
64
68
  )}
65
69
 
@@ -1,32 +0,0 @@
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;