@growth-operations/ui-shared 1.19.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/alerts/AlertsTab.jsx +8 -1
- package/src/billing/AutoProvisionToggle.jsx +139 -0
- package/src/billing/BillingTab.jsx +22 -238
- package/src/home/AppHome.jsx +51 -13
- package/src/home/CreditMeter.jsx +31 -13
- package/src/home/FeatureUsage.jsx +75 -0
- package/src/home/HealthHero.jsx +6 -0
- package/src/home/OnboardingChecklist.jsx +6 -0
- package/src/home/PeriodComparison.jsx +60 -0
- package/src/home/PlanFitBanner.jsx +27 -0
- package/src/home/TrialBanner.jsx +32 -0
- package/src/home/UsageHighlights.jsx +10 -0
- package/src/index.js +24 -0
- package/src/lib/autoProvision.js +82 -0
- package/src/lib/creditDisplay.js +97 -34
- package/src/lib/featureUsage.js +54 -0
- package/src/lib/homeBlocks.js +146 -0
- package/src/sdk/app/topUps.js +55 -18
- package/src/sdk/index.js +1 -1
- package/src/usage/UsageTab.jsx +19 -4
package/src/lib/creditDisplay.js
CHANGED
|
@@ -59,37 +59,53 @@ export function hasTopUpBank(ent) {
|
|
|
59
59
|
// no bank section)
|
|
60
60
|
// alert — { variant, title, message } or null
|
|
61
61
|
// grantExpiresAt — pass-through for the free-grant countdown
|
|
62
|
+
// renewsOn — "YYYY-MM-DD" the monthly pool renews on, from the
|
|
63
|
+
// state.credits block (null when absent)
|
|
62
64
|
//
|
|
63
65
|
// BAR COLORS ARE PER-BAR FILL LEVELS (Jasper 2026-10): a pool at 99% used
|
|
64
66
|
// with a full bank must NOT render green just because the account is healthy
|
|
65
67
|
// — each bar colors by its own level; the StatusTag keeps the account-state
|
|
66
|
-
// colors.
|
|
67
|
-
//
|
|
68
|
-
// cross-multiplications — no float fuzz at the 80/
|
|
68
|
+
// colors. The pool bar colors on % CONSUMED (Andrew 2026-10: green under
|
|
69
|
+
// 50%, orange 50–80%, red over 80%). Comparisons are integer
|
|
70
|
+
// cross-multiplications — no float fuzz at the 50/80/20 boundaries.
|
|
69
71
|
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
+
// THE state.credits BLOCK (2026-10 contract addition): when the payload
|
|
73
|
+
// carries `credits` ({ monthly: { remaining, included, renews_on },
|
|
74
|
+
// additional: { remaining, purchased, expires } }) it is AUTHORITATIVE for
|
|
75
|
+
// the two-bucket display — no deriving pool/bank from the entitlement's
|
|
76
|
+
// combined numbers. Absent (older backends) -> the legacy entitlement
|
|
77
|
+
// derivation below, unchanged.
|
|
78
|
+
//
|
|
79
|
+
// pool (DEPLETION meter, value = poolUsed of poolGrant), colored on %
|
|
80
|
+
// consumed per Andrew's 2026-10 feedback: success under 50% used, warning
|
|
81
|
+
// from 50% through 80%, danger over 80%.
|
|
72
82
|
// bank (LEVEL gauge, value = bankRemaining of bankGranted):
|
|
73
|
-
// success above 50% full, warning at
|
|
74
|
-
//
|
|
75
|
-
//
|
|
83
|
+
// success above 50% full, warning at <= 50%. NO danger variant:
|
|
84
|
+
// HubSpot's ProgressBar paints the WHOLE TRACK red in danger (not just
|
|
85
|
+
// the filled portion), so a low level gauge would read as a full bar
|
|
86
|
+
// (caught in Jasper's 2026-10-04 screenshot review). Urgency at <= 20%
|
|
87
|
+
// rides the "Running low" tag (bank.bankLow) instead of the bar color.
|
|
88
|
+
// danger stays on the pool bar, where near-full is the truth.
|
|
76
89
|
function poolBarVariantFor(poolUsed, poolGrant) {
|
|
77
90
|
if (!(poolGrant > 0)) return "success"; // no grant: nothing to deplete
|
|
78
|
-
if (poolUsed * 100
|
|
79
|
-
if (poolUsed * 100 >=
|
|
91
|
+
if (poolUsed * 100 > 80 * poolGrant) return "danger";
|
|
92
|
+
if (poolUsed * 100 >= 50 * poolGrant) return "warning";
|
|
80
93
|
return "success";
|
|
81
94
|
}
|
|
82
95
|
|
|
83
96
|
function bankBarVariantFor(bankRemaining, bankGranted) {
|
|
84
97
|
if (!(bankGranted > 0)) return "success"; // degraded text-only line renders no bar
|
|
85
|
-
if (bankRemaining * 100 <= 20 * bankGranted) return "danger";
|
|
86
98
|
if (bankRemaining * 100 <= 50 * bankGranted) return "warning";
|
|
87
99
|
return "success";
|
|
88
100
|
}
|
|
89
|
-
export function resolveCreditMeter(entitlement, creditMeter) {
|
|
101
|
+
export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
|
|
90
102
|
const ent = entitlement?.mode === "credits" ? entitlement : null;
|
|
91
103
|
const meter = creditMeter ?? null;
|
|
92
|
-
|
|
104
|
+
// The state.credits block stands on its own (it IS the credit data), so it
|
|
105
|
+
// keeps the meter alive even on a payload whose entitlement arm is missing.
|
|
106
|
+
const creditsBlock =
|
|
107
|
+
credits && (credits.monthly || credits.additional) ? credits : null;
|
|
108
|
+
if (!ent && !meter && !creditsBlock) return null;
|
|
93
109
|
|
|
94
110
|
// Number precedence: once the entitlement arm carries the top-up fields it
|
|
95
111
|
// is authoritative — the credit_meter block stays RAW pool-only under the
|
|
@@ -101,9 +117,25 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
101
117
|
const pick = (key) =>
|
|
102
118
|
entHasTopUpFields ? ent[key] ?? meter?.[key] : meter?.[key] ?? ent?.[key];
|
|
103
119
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
120
|
+
// Number source: the state.credits block when present (authoritative
|
|
121
|
+
// two-bucket numbers), else the entitlement/meter precedence chain.
|
|
122
|
+
let granted;
|
|
123
|
+
let used;
|
|
124
|
+
let remaining;
|
|
125
|
+
if (creditsBlock) {
|
|
126
|
+
const monthly = creditsBlock.monthly ?? {};
|
|
127
|
+
const additional = creditsBlock.additional ?? {};
|
|
128
|
+
const poolGrant = Math.max(0, monthly.included ?? 0);
|
|
129
|
+
const poolRemaining = Math.max(0, monthly.remaining ?? 0);
|
|
130
|
+
const bankRemaining = Math.max(0, additional.remaining ?? 0);
|
|
131
|
+
granted = poolGrant + bankRemaining;
|
|
132
|
+
remaining = poolRemaining + bankRemaining;
|
|
133
|
+
used = Math.max(granted - remaining, 0);
|
|
134
|
+
} else {
|
|
135
|
+
granted = pick("granted") ?? 0;
|
|
136
|
+
used = pick("used") ?? 0;
|
|
137
|
+
remaining = pick("remaining") ?? Math.max(granted - used, 0);
|
|
138
|
+
}
|
|
107
139
|
|
|
108
140
|
const onPaidPlan = !!ent?.plan;
|
|
109
141
|
const lowThreshold = ent?.low_threshold ?? 0;
|
|
@@ -123,26 +155,53 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
123
155
|
// bank portion is bankRemaining and the rest is pool remaining; the pool
|
|
124
156
|
// segment's full size is the combined denominator minus the bank.
|
|
125
157
|
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
158
|
+
// Under the state.credits block the two-bucket layout is ALWAYS in force:
|
|
159
|
+
// the monthly line (+ "Renews on" subline + pool bar) renders even when the
|
|
160
|
+
// account has never bought additional credits; the additional-credits line
|
|
161
|
+
// rides along only when the account has (or ever bought) a bank —
|
|
162
|
+
// purchased > 0 carries the true lifetime total, so a spent bank reads
|
|
163
|
+
// "0 of 1,000 left" instead of disappearing (bank.showAdditional).
|
|
164
|
+
//
|
|
165
|
+
// Under the legacy derivation (no credits block) the bank section renders
|
|
166
|
+
// ONLY while the bank has credits left (bankRemaining > 0): a zero bank —
|
|
167
|
+
// never topped up, or fully burned — renders the plain single-pool bar, and
|
|
168
|
+
// with bankRemaining 0 the combined numbers ARE the pool numbers (granted
|
|
169
|
+
// == poolGrant, remaining == poolRemaining), so nothing is lost.
|
|
131
170
|
let bank = null;
|
|
132
|
-
const bankRemaining =
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
171
|
+
const bankRemaining = creditsBlock
|
|
172
|
+
? Math.max(0, creditsBlock.additional?.remaining ?? 0)
|
|
173
|
+
: Math.max(0, ent?.top_up_bank_remaining ?? 0);
|
|
174
|
+
const bankGrantedTotal = creditsBlock
|
|
175
|
+
? Math.max(0, creditsBlock.additional?.purchased ?? 0)
|
|
176
|
+
: Math.max(0, ent?.top_up_bank_granted ?? 0);
|
|
177
|
+
if (creditsBlock || bankRemaining > 0) {
|
|
178
|
+
const poolGrant = creditsBlock
|
|
179
|
+
? Math.max(0, creditsBlock.monthly?.included ?? 0)
|
|
180
|
+
: Math.max(granted - bankRemaining, 0);
|
|
181
|
+
const poolRemaining = creditsBlock
|
|
182
|
+
? Math.min(Math.max(0, creditsBlock.monthly?.remaining ?? 0), poolGrant)
|
|
183
|
+
: Math.min(Math.max(remaining - bankRemaining, 0), poolGrant);
|
|
136
184
|
bank = {
|
|
137
185
|
bankRemaining,
|
|
138
186
|
// The bank's lifetime granted total — the bank bar's denominator. 0 on
|
|
139
187
|
// backends that predate top_up_bank_granted (the component degrades to
|
|
140
188
|
// a text-only bank line; never divide by zero, never "of 0").
|
|
141
|
-
bankGranted:
|
|
189
|
+
bankGranted: bankGrantedTotal,
|
|
190
|
+
// Whether the additional-credits line renders at all. Legacy path: yes
|
|
191
|
+
// (we only get here with bankRemaining > 0). credits block: only when
|
|
192
|
+
// the account has or bought a bank — otherwise the two-bucket layout is
|
|
193
|
+
// just the monthly line.
|
|
194
|
+
showAdditional: creditsBlock
|
|
195
|
+
? bankRemaining > 0 || bankGrantedTotal > 0
|
|
196
|
+
: true,
|
|
142
197
|
poolGrant,
|
|
143
198
|
poolRemaining,
|
|
144
199
|
poolUsed: poolGrant - poolRemaining,
|
|
145
200
|
};
|
|
201
|
+
// "Running low" tag at <= 20% of the granted total (the bar itself caps
|
|
202
|
+
// at warning — see the header block). False when the total is unknown.
|
|
203
|
+
bank.bankLow =
|
|
204
|
+
bank.bankGranted > 0 && bankRemaining * 100 <= 20 * bank.bankGranted;
|
|
146
205
|
}
|
|
147
206
|
|
|
148
207
|
let tagVariant;
|
|
@@ -152,7 +211,7 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
152
211
|
tagLabel = "Depleted";
|
|
153
212
|
} else if (thresholdState === "top_up_failed") {
|
|
154
213
|
tagVariant = "danger";
|
|
155
|
-
tagLabel = "
|
|
214
|
+
tagLabel = "Couldn't add credits";
|
|
156
215
|
} else if (thresholdState === "limit_reached") {
|
|
157
216
|
tagVariant = "warning";
|
|
158
217
|
tagLabel = "Limit reached";
|
|
@@ -189,7 +248,7 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
189
248
|
variant: "error",
|
|
190
249
|
title: "You're out of credits",
|
|
191
250
|
message: onPaidPlan
|
|
192
|
-
? "Actions are paused until your
|
|
251
|
+
? "Actions are paused until your plan renews or more credits are added."
|
|
193
252
|
: "Choose a plan to resume your use.",
|
|
194
253
|
};
|
|
195
254
|
} else if (thresholdState === "top_up_failed") {
|
|
@@ -197,14 +256,14 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
197
256
|
variant: "error",
|
|
198
257
|
title: "We couldn't add credits",
|
|
199
258
|
message:
|
|
200
|
-
"The automatic
|
|
259
|
+
"The automatic additional-credits purchase failed — update your payment method in Billing.",
|
|
201
260
|
};
|
|
202
261
|
} else if (thresholdState === "limit_reached") {
|
|
203
262
|
alert = {
|
|
204
263
|
variant: "warning",
|
|
205
264
|
title: "Billing limit reached",
|
|
206
265
|
message:
|
|
207
|
-
"Automatic
|
|
266
|
+
"Automatic additional-credit purchases are paused at your spending limit for this period. Raise your billing limit in Billing to resume them.",
|
|
208
267
|
};
|
|
209
268
|
} else if (overIncluded) {
|
|
210
269
|
alert = {
|
|
@@ -217,7 +276,8 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
217
276
|
alert = {
|
|
218
277
|
variant: "warning",
|
|
219
278
|
title: "Running low on credits",
|
|
220
|
-
message:
|
|
279
|
+
message:
|
|
280
|
+
"You're getting close to your limit — pick a plan or add more credits.",
|
|
221
281
|
};
|
|
222
282
|
}
|
|
223
283
|
|
|
@@ -236,6 +296,9 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
236
296
|
bankBarVariant,
|
|
237
297
|
alert,
|
|
238
298
|
grantExpiresAt: ent?.grant_expires_at ?? null,
|
|
299
|
+
// "YYYY-MM-DD" the monthly pool renews on (state.credits block only; null
|
|
300
|
+
// on older backends — the component just skips the subline).
|
|
301
|
+
renewsOn: creditsBlock ? (creditsBlock.monthly?.renews_on ?? null) : null,
|
|
239
302
|
};
|
|
240
303
|
}
|
|
241
304
|
|
|
@@ -276,7 +339,7 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
|
|
|
276
339
|
return {
|
|
277
340
|
variant: "error",
|
|
278
341
|
title: "We couldn't add credits",
|
|
279
|
-
message: `The automatic
|
|
342
|
+
message: `The automatic additional-credits purchase failed. Update your payment method so your ${noun} aren't interrupted.`,
|
|
280
343
|
cta: { kind: "portal", label: "Update payment method" },
|
|
281
344
|
};
|
|
282
345
|
}
|
|
@@ -285,7 +348,7 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
|
|
|
285
348
|
variant: "warning",
|
|
286
349
|
title: "Billing limit reached",
|
|
287
350
|
message:
|
|
288
|
-
"Automatic
|
|
351
|
+
"Automatic additional-credit purchases are paused — you've hit your spending limit for this billing period. Raise your billing limit to keep them going.",
|
|
289
352
|
cta: { kind: "billing", label: "Raise your billing limit" },
|
|
290
353
|
};
|
|
291
354
|
}
|
|
@@ -306,7 +369,7 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
|
|
|
306
369
|
variant: "error",
|
|
307
370
|
title: "You're out of credits",
|
|
308
371
|
message: onPaidPlan
|
|
309
|
-
? `Your ${noun} are paused until your
|
|
372
|
+
? `Your ${noun} are paused until your plan renews or more credits are added.`
|
|
310
373
|
: `Your credits are used up. Choose a plan in Billing to keep ${label}.`,
|
|
311
374
|
cta: { kind: "billing", label: "Go to Billing →" },
|
|
312
375
|
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Pure resolver for the Home "Most used features" / "Recommended to explore"
|
|
2
|
+
// sections, fed by the additive `feature_usage` block on the /home payload
|
|
3
|
+
// (growth-ops-apps-base computes it from the credit ledger). Framework-free
|
|
4
|
+
// (no React) so the branch math is unit-testable; ../home/FeatureUsage.jsx
|
|
5
|
+
// stays thin.
|
|
6
|
+
//
|
|
7
|
+
// Degrades gracefully by contract: the block is ABSENT on older backends,
|
|
8
|
+
// on legacy (Anvil) installs, and on apps with no feature registry — every
|
|
9
|
+
// one of those resolves to null (render nothing), never an empty box.
|
|
10
|
+
|
|
11
|
+
// The wire field is `credits_or_events`: the registry anticipates
|
|
12
|
+
// event-denominated features, so a most_used row MAY carry a `unit` hint
|
|
13
|
+
// ("credits" | "events"; singular spellings accepted). Absent -> "credits",
|
|
14
|
+
// which is what every current backend emits.
|
|
15
|
+
const normalizeUnit = (u) =>
|
|
16
|
+
typeof u === "string" && u.toLowerCase().startsWith("event") ? "events" : "credits";
|
|
17
|
+
|
|
18
|
+
// Resolve the feature_usage block into render-ready shape:
|
|
19
|
+
// { mostUsed: [{ key, name, count, unit }],
|
|
20
|
+
// notInUse: [{ key, name, docsUrl, rationale }] }
|
|
21
|
+
// Returns null when the block is absent/malformed or BOTH lists are empty
|
|
22
|
+
// (an install using every detectable feature has nothing to show here).
|
|
23
|
+
export function resolveFeatureUsage(featureUsage) {
|
|
24
|
+
if (!featureUsage || typeof featureUsage !== "object") return null;
|
|
25
|
+
|
|
26
|
+
const mostUsed = (Array.isArray(featureUsage.most_used) ? featureUsage.most_used : [])
|
|
27
|
+
.filter((f) => f && f.key != null && f.name)
|
|
28
|
+
.map((f) => ({
|
|
29
|
+
key: f.key,
|
|
30
|
+
name: f.name,
|
|
31
|
+
count: typeof f.credits_or_events === "number" ? f.credits_or_events : 0,
|
|
32
|
+
unit: normalizeUnit(f.unit),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const notInUse = (Array.isArray(featureUsage.not_in_use) ? featureUsage.not_in_use : [])
|
|
36
|
+
.filter((f) => f && f.key != null && f.name)
|
|
37
|
+
.map((f) => ({
|
|
38
|
+
key: f.key,
|
|
39
|
+
name: f.name,
|
|
40
|
+
docsUrl: typeof f.docs_url === "string" && f.docs_url ? f.docs_url : null,
|
|
41
|
+
rationale: typeof f.rationale === "string" ? f.rationale : "",
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
if (mostUsed.length === 0 && notInUse.length === 0) return null;
|
|
45
|
+
return { mostUsed, notInUse };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The block lives at payload.state.feature_usage on current backends. The
|
|
49
|
+
// bare `payload.feature_usage` fallback keeps the component working if a
|
|
50
|
+
// host ever passes the inner state block instead of the whole payload.
|
|
51
|
+
export function featureUsageOf(state) {
|
|
52
|
+
if (!state || typeof state !== "object") return null;
|
|
53
|
+
return resolveFeatureUsage(state.state?.feature_usage ?? state.feature_usage);
|
|
54
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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.
|
|
5
|
+
//
|
|
6
|
+
// Everything here degrades gracefully: every block is additive on the
|
|
7
|
+
// /v1/home payload, and a resolver returns null (render nothing) whenever
|
|
8
|
+
// 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
|
+
|
|
70
|
+
// --- Plan fit ---------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
73
|
+
|
|
74
|
+
// "$40/mo" from a dollar number; whole amounts drop the decimals.
|
|
75
|
+
const fmtSavings = (usd) =>
|
|
76
|
+
`$${Number.isInteger(usd) ? usd : usd.toFixed(2)}/mo`;
|
|
77
|
+
|
|
78
|
+
// Resolve the plan_fit block. Returns:
|
|
79
|
+
// null — block absent/unknown verdict (render nothing)
|
|
80
|
+
// { kind: "right_plan" } — reinforces the healthy hero
|
|
81
|
+
// { kind: "upgrade_recommended", text } — the savings banner body, Andrew's
|
|
82
|
+
// copy with the tier/savings/pack count templated in; degrades to a
|
|
83
|
+
// plainer sentence when the backend can't price the savings yet.
|
|
84
|
+
export function resolvePlanFit(planFit) {
|
|
85
|
+
if (!planFit || typeof planFit !== "object") return null;
|
|
86
|
+
|
|
87
|
+
if (planFit.verdict === "right_plan") {
|
|
88
|
+
return { kind: "right_plan" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (planFit.verdict === "upgrade_recommended") {
|
|
92
|
+
const tier =
|
|
93
|
+
typeof planFit.target_tier === "string" && planFit.target_tier
|
|
94
|
+
? capitalize(planFit.target_tier)
|
|
95
|
+
: null;
|
|
96
|
+
const savings =
|
|
97
|
+
typeof planFit.monthly_savings_usd === "number"
|
|
98
|
+
? fmtSavings(planFit.monthly_savings_usd)
|
|
99
|
+
: null;
|
|
100
|
+
const packs = planFit.topup_packs_this_period ?? 0;
|
|
101
|
+
const packsText = `${packs} additional credit pack${packs === 1 ? "" : "s"}`;
|
|
102
|
+
|
|
103
|
+
let text;
|
|
104
|
+
if (savings && tier) {
|
|
105
|
+
text = `You could save ${savings} by moving to ${tier} — you bought ${packsText} this period.`;
|
|
106
|
+
} else if (tier) {
|
|
107
|
+
text = `You could save by moving to ${tier} — you bought ${packsText} this period.`;
|
|
108
|
+
} else {
|
|
109
|
+
text = `A higher plan may cost you less — you bought ${packsText} this period.`;
|
|
110
|
+
}
|
|
111
|
+
return { kind: "upgrade_recommended", text };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Period comparison ------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
// Resolve the period_comparison block (null until the install has one full
|
|
120
|
+
// closed period) into render-ready shape:
|
|
121
|
+
// { last, current, maxTotal } where each period is
|
|
122
|
+
// { label, total, byAction: [{ action, credits }] } and maxTotal is the
|
|
123
|
+
// shared bar denominator (so the two bars are visually comparable; >= 1 so
|
|
124
|
+
// a pair of zero periods never divides by zero).
|
|
125
|
+
// Returns null when the block or either period is missing.
|
|
126
|
+
export function resolvePeriodComparison(periodComparison) {
|
|
127
|
+
if (!periodComparison || typeof periodComparison !== "object") return null;
|
|
128
|
+
const norm = (p) => {
|
|
129
|
+
if (!p || typeof p !== "object") return null;
|
|
130
|
+
return {
|
|
131
|
+
label: p.label ?? "",
|
|
132
|
+
total: p.total_credits ?? 0,
|
|
133
|
+
byAction: Array.isArray(p.by_action)
|
|
134
|
+
? p.by_action.filter((a) => a && a.action != null)
|
|
135
|
+
: [],
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
const last = norm(periodComparison.last_period);
|
|
139
|
+
const current = norm(periodComparison.current_period);
|
|
140
|
+
if (!last || !current) return null;
|
|
141
|
+
return {
|
|
142
|
+
last,
|
|
143
|
+
current,
|
|
144
|
+
maxTotal: Math.max(last.total, current.total, 1),
|
|
145
|
+
};
|
|
146
|
+
}
|
package/src/sdk/app/topUps.js
CHANGED
|
@@ -28,24 +28,18 @@ export async function getTopUps(context, { appKey, portalId } = {}) {
|
|
|
28
28
|
return callAppApi(context, billingPath(appKey, "top-ups", portalId), "GET");
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// Throws AppApiError on failure, with the FastAPI {"detail": ...} string as
|
|
44
|
-
// the message — a 404 means the billing service predates the invoices route
|
|
45
|
-
// (the caller hides the section rather than erroring forever).
|
|
46
|
-
export async function getInvoices({ billingBaseUrl, token } = {}) {
|
|
47
|
-
const url = `${billingBaseUrl}/v1/billing/invoices?token=${encodeURIComponent(token)}`;
|
|
48
|
-
const response = await hubspot.fetch(url, { timeout: 10000, method: "GET" });
|
|
31
|
+
// Shared fetch for the BILLING service's token-auth routes
|
|
32
|
+
// (/v1/billing/*) — bare hubspot.fetch with the signed action token as a
|
|
33
|
+
// query param, no custom headers (the platform rejects them), body as a plain
|
|
34
|
+
// object (hubspot.fetch serializes it; see callAppApi's header note).
|
|
35
|
+
// Throws AppApiError on failure with the FastAPI {"detail": ...} string as
|
|
36
|
+
// the message.
|
|
37
|
+
async function billingFetch(url, { method = "GET", body } = {}) {
|
|
38
|
+
const response = await hubspot.fetch(url, {
|
|
39
|
+
timeout: 10000,
|
|
40
|
+
method,
|
|
41
|
+
...(body && { body }),
|
|
42
|
+
});
|
|
49
43
|
if (!response.ok) {
|
|
50
44
|
const errorText = await response.text();
|
|
51
45
|
// FastAPI errors come back as {"detail": "..."} — surface just the detail
|
|
@@ -64,6 +58,49 @@ export async function getInvoices({ billingBaseUrl, token } = {}) {
|
|
|
64
58
|
return response.json();
|
|
65
59
|
}
|
|
66
60
|
|
|
61
|
+
// GET {billingBaseUrl}/v1/billing/invoices?token=... ->
|
|
62
|
+
// { invoices: [{ id, date, description, amount_cents, currency, status,
|
|
63
|
+
// hosted_invoice_url, kind }] } (newest first, capped at 24)
|
|
64
|
+
//
|
|
65
|
+
// The billing service (growth-ops-apps-app), NOT base — see the header note.
|
|
66
|
+
// Auth is the signed billing-action `token` query param
|
|
67
|
+
// (common.billing.action_token): the endpoint is read-only and accepts any
|
|
68
|
+
// action; BillingTab passes the (interval-refreshed)
|
|
69
|
+
// state.billing_action_tokens.portal. The claim carries app_key + portal_id,
|
|
70
|
+
// so the response is scoped to THIS portal's Stripe customer — never a
|
|
71
|
+
// stranger's invoices.
|
|
72
|
+
//
|
|
73
|
+
// A 404 means the billing service predates the invoices route (the caller
|
|
74
|
+
// hides the section rather than erroring forever).
|
|
75
|
+
export async function getInvoices({ billingBaseUrl, token } = {}) {
|
|
76
|
+
const url = `${billingBaseUrl}/v1/billing/invoices?token=${encodeURIComponent(token)}`;
|
|
77
|
+
return billingFetch(url, { method: "GET" });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// POST /v1/hubspot/app_pages/{app}/billing/auto-provision, body
|
|
81
|
+
// { enabled: bool } — set the account's auto-provision flag (REPLACES the old
|
|
82
|
+
// billing-limit concept): enabled buys another pack of additional credits
|
|
83
|
+
// when the monthly credits run out; disabled means no purchase and actions
|
|
84
|
+
// start failing the entitlement gate.
|
|
85
|
+
//
|
|
86
|
+
// This route lives on the BASE service (same app_pages family as
|
|
87
|
+
// billing/limit), NOT the GOAA billing service — there is no
|
|
88
|
+
// /v1/billing/auto-provision there. Same callAppApi + ?token= posture as
|
|
89
|
+
// setBillingLimit below: verify_hubspot plus the billing action token (the
|
|
90
|
+
// claim scopes the change to THIS portal's account); BillingTab passes the
|
|
91
|
+
// interval-refreshed state.billing_action_tokens.portal.
|
|
92
|
+
export async function setAutoProvision(
|
|
93
|
+
context,
|
|
94
|
+
{ appKey, portalId, enabled, token } = {}
|
|
95
|
+
) {
|
|
96
|
+
return callAppApi(
|
|
97
|
+
context,
|
|
98
|
+
billingPath(appKey, "auto-provision", portalId, { token }),
|
|
99
|
+
"POST",
|
|
100
|
+
{ enabled: !!enabled }
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
67
104
|
// POST /v1/hubspot/app_pages/{app}/billing/limit, body { limit_cents: int|null }
|
|
68
105
|
// — set (or clear, with null) the per-period auto top-up spend cap. Body is a
|
|
69
106
|
// plain object, never JSON.stringify'd (the hubspot.fetch rule callAppApi
|
package/src/sdk/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// App backend (context.variables.BASE_URL) via hubspot.fetch.
|
|
5
5
|
export { callAppApi, AppApiError } from "./app/base";
|
|
6
6
|
export { getAlerts, dismissAlert } from "./app/alerts";
|
|
7
|
-
export { getTopUps, getInvoices, setBillingLimit } from "./app/topUps";
|
|
7
|
+
export { getTopUps, getInvoices, setBillingLimit, setAutoProvision } from "./app/topUps";
|
|
8
8
|
export { getSettings, updateSettings, makeSettingsApi } from "./app/settings";
|
|
9
9
|
export {
|
|
10
10
|
strToBase64,
|
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 { CreditMeter } from "../home/CreditMeter";
|
|
30
31
|
|
|
31
32
|
const DEFAULT_BASE_PATH = "/api/v1/usage";
|
|
32
33
|
|
|
@@ -34,25 +35,39 @@ const WINDOW_OPTIONS = [
|
|
|
34
35
|
{ label: "Last 7 days", value: "7" },
|
|
35
36
|
{ label: "Last 30 days", value: "30" },
|
|
36
37
|
{ label: "Last 90 days", value: "90" },
|
|
38
|
+
{ label: "Last 180 days", value: "180" },
|
|
39
|
+
{ label: "Last 365 days", value: "365" },
|
|
37
40
|
];
|
|
38
41
|
|
|
39
42
|
// context: extension context. basePath/portalId: target the right backend
|
|
40
|
-
// (base-hosted apps pass both). appName: chart copy.
|
|
43
|
+
// (base-hosted apps pass both). appName: chart copy. state: the /v1/home
|
|
44
|
+
// payload (optional) — when present, the credit meter renders at the TOP of
|
|
45
|
+
// the tab (Andrew's 2026-10 feedback: the meter moved off Home); without it
|
|
46
|
+
// the tab is chart-only, same as before.
|
|
41
47
|
export function UsageTab({
|
|
42
48
|
context,
|
|
43
49
|
basePath = DEFAULT_BASE_PATH,
|
|
44
50
|
portalId,
|
|
45
51
|
appName = "this app",
|
|
52
|
+
state = null,
|
|
46
53
|
}) {
|
|
47
54
|
const [days, setDays] = useState("30");
|
|
48
55
|
|
|
49
56
|
return (
|
|
50
57
|
<Flex direction="column" gap="medium">
|
|
58
|
+
{state && (
|
|
59
|
+
<CreditMeter
|
|
60
|
+
entitlement={state.entitlement}
|
|
61
|
+
creditMeter={state.credit_meter}
|
|
62
|
+
credits={state.credits}
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
|
|
51
66
|
<Flex direction="row" justify="between" align="end">
|
|
52
67
|
<Heading>Usage</Heading>
|
|
53
68
|
<Select
|
|
54
|
-
label="
|
|
55
|
-
name="
|
|
69
|
+
label="Time period"
|
|
70
|
+
name="time-period"
|
|
56
71
|
options={WINDOW_OPTIONS}
|
|
57
72
|
value={days}
|
|
58
73
|
onChange={setDays}
|
|
@@ -131,7 +146,7 @@ function UsageContent({ context, basePath, portalId, appName, days }) {
|
|
|
131
146
|
<Flex direction="column" gap="medium">
|
|
132
147
|
<Tile>
|
|
133
148
|
<Statistics>
|
|
134
|
-
<StatisticsItem label=
|
|
149
|
+
<StatisticsItem label={`Used (last ${days} days)`} number={usedInWindow} />
|
|
135
150
|
{hasBalance && <StatisticsItem label="Remaining" number={remaining} />}
|
|
136
151
|
{hasBalance && <StatisticsItem label="Granted" number={granted} />}
|
|
137
152
|
</Statistics>
|