@growth-operations/ui-shared 1.21.2 → 1.23.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/FeatureUsage.jsx +19 -3
- package/src/index.js +7 -4
- package/src/lib/creditDisplay.js +108 -3
- package/src/lib/featureUsage.js +41 -0
- package/src/lib/format.js +6 -0
- package/src/lib/homeBlocks.js +8 -63
- 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
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Flex, Tile, Text, Link } from "@hubspot/ui-extensions";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
featureUsageOf,
|
|
5
|
+
pickDailyRecommendations,
|
|
6
|
+
recommendationsRotated,
|
|
7
|
+
} from "../lib/featureUsage";
|
|
4
8
|
|
|
5
9
|
// The Home "Most used features" / "Recommended to explore" sections, fed by
|
|
6
10
|
// the additive state.feature_usage block (per-app feature registry + credit
|
|
@@ -44,21 +48,33 @@ function MostUsedTile({ mostUsed }) {
|
|
|
44
48
|
|
|
45
49
|
function RecommendedTile({ notInUse }) {
|
|
46
50
|
if (notInUse.length === 0) return null;
|
|
51
|
+
// Long lists (ATS: 13 features) render as a rotating daily slice instead of
|
|
52
|
+
// a wall — the whole list still arrives in the payload; the window is a
|
|
53
|
+
// pure function of the UTC date (see ../lib/featureUsage).
|
|
54
|
+
const picks = pickDailyRecommendations(
|
|
55
|
+
notInUse,
|
|
56
|
+
new Date().toISOString().slice(0, 10)
|
|
57
|
+
);
|
|
47
58
|
return (
|
|
48
59
|
<Tile>
|
|
49
60
|
<Flex direction="column" gap="small">
|
|
50
61
|
<Text format={{ fontWeight: "bold" }}>Recommended to explore</Text>
|
|
51
|
-
{
|
|
62
|
+
{picks.map((f) => (
|
|
52
63
|
<Flex key={f.key} direction="column" gap="extra-small">
|
|
53
64
|
<Text format={{ fontWeight: "demibold" }}>{f.name}</Text>
|
|
54
65
|
{f.rationale ? <Text>{f.rationale}</Text> : null}
|
|
55
66
|
{f.docsUrl ? (
|
|
56
67
|
<Link href={{ url: f.docsUrl, external: true }}>
|
|
57
|
-
|
|
68
|
+
Learn more →
|
|
58
69
|
</Link>
|
|
59
70
|
) : null}
|
|
60
71
|
</Flex>
|
|
61
72
|
))}
|
|
73
|
+
{recommendationsRotated(notInUse) ? (
|
|
74
|
+
<Text variant="microcopy">
|
|
75
|
+
New picks daily — {notInUse.length} features to explore in all.
|
|
76
|
+
</Text>
|
|
77
|
+
) : null}
|
|
62
78
|
</Flex>
|
|
63
79
|
</Tile>
|
|
64
80
|
);
|
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,9 +47,15 @@ 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
|
-
export {
|
|
53
|
+
export {
|
|
54
|
+
resolveFeatureUsage,
|
|
55
|
+
featureUsageOf,
|
|
56
|
+
pickDailyRecommendations,
|
|
57
|
+
recommendationsRotated,
|
|
58
|
+
} from "./lib/featureUsage";
|
|
54
59
|
export {
|
|
55
60
|
hasTopUpBank,
|
|
56
61
|
creditsBlockOf,
|
|
@@ -59,8 +64,6 @@ export {
|
|
|
59
64
|
parseLimitInput,
|
|
60
65
|
} from "./lib/creditDisplay";
|
|
61
66
|
export {
|
|
62
|
-
TRIAL_BANNER_FOOTER,
|
|
63
|
-
resolveTrialBanner,
|
|
64
67
|
resolvePlanFit,
|
|
65
68
|
planFitOf,
|
|
66
69
|
resolvePeriodComparison,
|
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 >
|
|
@@ -311,9 +312,77 @@ export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
|
|
|
311
312
|
};
|
|
312
313
|
}
|
|
313
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
|
+
|
|
314
383
|
// Resolve the app-wide BillingBanner from the credits entitlement arm.
|
|
315
384
|
// Returns null to render nothing (non-credits mode, pre-install, or healthy),
|
|
316
|
-
// else { variant, title, message, cta } where cta is null or:
|
|
385
|
+
// else { variant, title, message, footer?, cta } where cta is null or:
|
|
317
386
|
// { kind: "portal", label } — link out to the Stripe billing-portal entry
|
|
318
387
|
// point (payment-method fixes)
|
|
319
388
|
// { kind: "billing", label } — in-app navigate to the Billing tab
|
|
@@ -321,7 +390,23 @@ export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
|
|
|
321
390
|
// Driven by threshold_state for the top-up model; the legacy metered branches
|
|
322
391
|
// (free depleted / paid over_included / free running-low) are byte-identical
|
|
323
392
|
// to the pre-top-up banner.
|
|
324
|
-
|
|
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 } = {}) {
|
|
325
410
|
const ent = entitlement;
|
|
326
411
|
if (!ent || ent.mode !== "credits" || ent.status === "not_installed") {
|
|
327
412
|
return null;
|
|
@@ -362,6 +447,14 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
|
|
|
362
447
|
};
|
|
363
448
|
}
|
|
364
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
|
+
|
|
365
458
|
// --- Hard-blocked. threshold_state "depleted" is the ONLY blocking state
|
|
366
459
|
// (a topup-model paid plan blocks at true zero); `over_included` overrides
|
|
367
460
|
// a depleted reading so a metered paid account in overage is never shown as
|
|
@@ -371,7 +464,9 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
|
|
|
371
464
|
const depleted =
|
|
372
465
|
thresholdState != null
|
|
373
466
|
? thresholdState === "depleted" && !overIncluded
|
|
374
|
-
: !onPaidPlan &&
|
|
467
|
+
: !onPaidPlan &&
|
|
468
|
+
(ent.depleted === true ||
|
|
469
|
+
(ent.entitled === false && trial?.kind !== "trial_ended"));
|
|
375
470
|
|
|
376
471
|
if (depleted) {
|
|
377
472
|
return {
|
|
@@ -383,6 +478,11 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
|
|
|
383
478
|
cta: { kind: "billing", label: "Go to Billing →" },
|
|
384
479
|
};
|
|
385
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
|
+
}
|
|
386
486
|
if (overIncluded) {
|
|
387
487
|
return {
|
|
388
488
|
variant: "warning",
|
|
@@ -405,6 +505,11 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
|
|
|
405
505
|
cta: { kind: "billing", label: "Go to Billing →" },
|
|
406
506
|
};
|
|
407
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
|
+
}
|
|
408
513
|
|
|
409
514
|
return null;
|
|
410
515
|
}
|
package/src/lib/featureUsage.js
CHANGED
|
@@ -52,3 +52,44 @@ export function featureUsageOf(state) {
|
|
|
52
52
|
if (!state || typeof state !== "object") return null;
|
|
53
53
|
return resolveFeatureUsage(state.state?.feature_usage ?? state.feature_usage);
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
// --- Recommendation rotation ------------------------------------------------
|
|
57
|
+
|
|
58
|
+
// A long not_in_use list is a firehose: a fresh install of a big app (ATS
|
|
59
|
+
// has 13 registered features) gets the whole registry dumped on Home exactly
|
|
60
|
+
// when focus matters most, and a wall of 13 "Read the guide" links teaches
|
|
61
|
+
// nothing. So short lists (<= RECOMMENDATION_WHOLE_LIST_MAX) render whole;
|
|
62
|
+
// longer lists show a deterministic daily slice that rotates day over day.
|
|
63
|
+
// The seed is the UTC date alone — same picks all day, no per-portal state,
|
|
64
|
+
// no backend involvement (the full list still arrives in the payload).
|
|
65
|
+
export const RECOMMENDATION_PICK_COUNT = 3;
|
|
66
|
+
export const RECOMMENDATION_WHOLE_LIST_MAX = 4;
|
|
67
|
+
|
|
68
|
+
// djb2 over the date string — tiny, stable across browsers, good enough for
|
|
69
|
+
// a day offset (not a security or money decision).
|
|
70
|
+
export function rotationDayHash(dateISO) {
|
|
71
|
+
let h = 5381;
|
|
72
|
+
for (let i = 0; i < dateISO.length; i++) {
|
|
73
|
+
h = ((h << 5) + h + dateISO.charCodeAt(i)) >>> 0;
|
|
74
|
+
}
|
|
75
|
+
return h;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The slice to render for `todayISO` ("YYYY-MM-DD", UTC). Order within the
|
|
79
|
+
// day stays registry order; the window wraps around the end of the list.
|
|
80
|
+
// Pure + date-injected so tests don't depend on the wall clock.
|
|
81
|
+
export function pickDailyRecommendations(notInUse, todayISO) {
|
|
82
|
+
if (!Array.isArray(notInUse)) return [];
|
|
83
|
+
if (notInUse.length <= RECOMMENDATION_WHOLE_LIST_MAX) return notInUse;
|
|
84
|
+
const start = rotationDayHash(todayISO) % notInUse.length;
|
|
85
|
+
return Array.from(
|
|
86
|
+
{ length: RECOMMENDATION_PICK_COUNT },
|
|
87
|
+
(_, i) => notInUse[(start + i) % notInUse.length]
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// True when rotation truncated the list — the tile shows a quiet "new picks
|
|
92
|
+
// daily" hint only then, so small registries read exactly as before.
|
|
93
|
+
export function recommendationsRotated(notInUse) {
|
|
94
|
+
return Array.isArray(notInUse) && notInUse.length > RECOMMENDATION_WHOLE_LIST_MAX;
|
|
95
|
+
}
|
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
|
|
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;
|