@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 +1 -1
- package/src/billing/BillingBanner.jsx +12 -1
- package/src/billing/BillingTab.jsx +58 -7
- package/src/home/AppHome.jsx +7 -9
- package/src/home/HealthHero.jsx +4 -1
- package/src/home/PeriodComparison.jsx +4 -2
- package/src/home/PlanFitBanner.jsx +5 -2
- package/src/index.js +4 -3
- package/src/lib/creditDisplay.js +117 -3
- package/src/lib/format.js +6 -0
- package/src/lib/homeBlocks.js +26 -63
- package/src/usage/UsageTab.jsx +5 -1
- package/src/home/TrialBanner.jsx +0 -32
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
?
|
|
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
|
|
package/src/home/AppHome.jsx
CHANGED
|
@@ -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
|
|
53
|
-
// post-checklist period comparison. Renders nothing
|
|
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 -> <
|
|
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
|
-
{/*
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
package/src/home/HealthHero.jsx
CHANGED
|
@@ -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" &&
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
package/src/lib/creditDisplay.js
CHANGED
|
@@ -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
|
-
|
|
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 &&
|
|
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) {
|
package/src/lib/homeBlocks.js
CHANGED
|
@@ -1,71 +1,16 @@
|
|
|
1
1
|
// Pure resolvers for the Home cockpit blocks added for Andrew's 2026-10
|
|
2
|
-
// App Home feedback: the
|
|
3
|
-
//
|
|
4
|
-
// the
|
|
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
|
+
}
|
package/src/usage/UsageTab.jsx
CHANGED
|
@@ -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
|
|
66
|
+
credits={creditsBlockOf(state)}
|
|
63
67
|
/>
|
|
64
68
|
)}
|
|
65
69
|
|
package/src/home/TrialBanner.jsx
DELETED
|
@@ -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;
|