@growth-operations/ui-shared 1.19.1 → 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 +19 -9
- 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 +87 -30
- 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,16 +59,26 @@ 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
83
|
// success above 50% full, warning at <= 50%. NO danger variant:
|
|
74
84
|
// HubSpot's ProgressBar paints the WHOLE TRACK red in danger (not just
|
|
@@ -78,8 +88,8 @@ export function hasTopUpBank(ent) {
|
|
|
78
88
|
// danger stays on the pool bar, where near-full is the truth.
|
|
79
89
|
function poolBarVariantFor(poolUsed, poolGrant) {
|
|
80
90
|
if (!(poolGrant > 0)) return "success"; // no grant: nothing to deplete
|
|
81
|
-
if (poolUsed * 100
|
|
82
|
-
if (poolUsed * 100 >=
|
|
91
|
+
if (poolUsed * 100 > 80 * poolGrant) return "danger";
|
|
92
|
+
if (poolUsed * 100 >= 50 * poolGrant) return "warning";
|
|
83
93
|
return "success";
|
|
84
94
|
}
|
|
85
95
|
|
|
@@ -88,10 +98,14 @@ function bankBarVariantFor(bankRemaining, bankGranted) {
|
|
|
88
98
|
if (bankRemaining * 100 <= 50 * bankGranted) return "warning";
|
|
89
99
|
return "success";
|
|
90
100
|
}
|
|
91
|
-
export function resolveCreditMeter(entitlement, creditMeter) {
|
|
101
|
+
export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
|
|
92
102
|
const ent = entitlement?.mode === "credits" ? entitlement : null;
|
|
93
103
|
const meter = creditMeter ?? null;
|
|
94
|
-
|
|
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;
|
|
95
109
|
|
|
96
110
|
// Number precedence: once the entitlement arm carries the top-up fields it
|
|
97
111
|
// is authoritative — the credit_meter block stays RAW pool-only under the
|
|
@@ -103,9 +117,25 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
103
117
|
const pick = (key) =>
|
|
104
118
|
entHasTopUpFields ? ent[key] ?? meter?.[key] : meter?.[key] ?? ent?.[key];
|
|
105
119
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
}
|
|
109
139
|
|
|
110
140
|
const onPaidPlan = !!ent?.plan;
|
|
111
141
|
const lowThreshold = ent?.low_threshold ?? 0;
|
|
@@ -125,22 +155,45 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
125
155
|
// bank portion is bankRemaining and the rest is pool remaining; the pool
|
|
126
156
|
// segment's full size is the combined denominator minus the bank.
|
|
127
157
|
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
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.
|
|
133
170
|
let bank = null;
|
|
134
|
-
const bankRemaining =
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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);
|
|
138
184
|
bank = {
|
|
139
185
|
bankRemaining,
|
|
140
186
|
// The bank's lifetime granted total — the bank bar's denominator. 0 on
|
|
141
187
|
// backends that predate top_up_bank_granted (the component degrades to
|
|
142
188
|
// a text-only bank line; never divide by zero, never "of 0").
|
|
143
|
-
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,
|
|
144
197
|
poolGrant,
|
|
145
198
|
poolRemaining,
|
|
146
199
|
poolUsed: poolGrant - poolRemaining,
|
|
@@ -158,7 +211,7 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
158
211
|
tagLabel = "Depleted";
|
|
159
212
|
} else if (thresholdState === "top_up_failed") {
|
|
160
213
|
tagVariant = "danger";
|
|
161
|
-
tagLabel = "
|
|
214
|
+
tagLabel = "Couldn't add credits";
|
|
162
215
|
} else if (thresholdState === "limit_reached") {
|
|
163
216
|
tagVariant = "warning";
|
|
164
217
|
tagLabel = "Limit reached";
|
|
@@ -195,7 +248,7 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
195
248
|
variant: "error",
|
|
196
249
|
title: "You're out of credits",
|
|
197
250
|
message: onPaidPlan
|
|
198
|
-
? "Actions are paused until your
|
|
251
|
+
? "Actions are paused until your plan renews or more credits are added."
|
|
199
252
|
: "Choose a plan to resume your use.",
|
|
200
253
|
};
|
|
201
254
|
} else if (thresholdState === "top_up_failed") {
|
|
@@ -203,14 +256,14 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
203
256
|
variant: "error",
|
|
204
257
|
title: "We couldn't add credits",
|
|
205
258
|
message:
|
|
206
|
-
"The automatic
|
|
259
|
+
"The automatic additional-credits purchase failed — update your payment method in Billing.",
|
|
207
260
|
};
|
|
208
261
|
} else if (thresholdState === "limit_reached") {
|
|
209
262
|
alert = {
|
|
210
263
|
variant: "warning",
|
|
211
264
|
title: "Billing limit reached",
|
|
212
265
|
message:
|
|
213
|
-
"Automatic
|
|
266
|
+
"Automatic additional-credit purchases are paused at your spending limit for this period. Raise your billing limit in Billing to resume them.",
|
|
214
267
|
};
|
|
215
268
|
} else if (overIncluded) {
|
|
216
269
|
alert = {
|
|
@@ -223,7 +276,8 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
223
276
|
alert = {
|
|
224
277
|
variant: "warning",
|
|
225
278
|
title: "Running low on credits",
|
|
226
|
-
message:
|
|
279
|
+
message:
|
|
280
|
+
"You're getting close to your limit — pick a plan or add more credits.",
|
|
227
281
|
};
|
|
228
282
|
}
|
|
229
283
|
|
|
@@ -242,6 +296,9 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
242
296
|
bankBarVariant,
|
|
243
297
|
alert,
|
|
244
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,
|
|
245
302
|
};
|
|
246
303
|
}
|
|
247
304
|
|
|
@@ -282,7 +339,7 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
|
|
|
282
339
|
return {
|
|
283
340
|
variant: "error",
|
|
284
341
|
title: "We couldn't add credits",
|
|
285
|
-
message: `The automatic
|
|
342
|
+
message: `The automatic additional-credits purchase failed. Update your payment method so your ${noun} aren't interrupted.`,
|
|
286
343
|
cta: { kind: "portal", label: "Update payment method" },
|
|
287
344
|
};
|
|
288
345
|
}
|
|
@@ -291,7 +348,7 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
|
|
|
291
348
|
variant: "warning",
|
|
292
349
|
title: "Billing limit reached",
|
|
293
350
|
message:
|
|
294
|
-
"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.",
|
|
295
352
|
cta: { kind: "billing", label: "Raise your billing limit" },
|
|
296
353
|
};
|
|
297
354
|
}
|
|
@@ -312,7 +369,7 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
|
|
|
312
369
|
variant: "error",
|
|
313
370
|
title: "You're out of credits",
|
|
314
371
|
message: onPaidPlan
|
|
315
|
-
? `Your ${noun} are paused until your
|
|
372
|
+
? `Your ${noun} are paused until your plan renews or more credits are added.`
|
|
316
373
|
: `Your credits are used up. Choose a plan in Billing to keep ${label}.`,
|
|
317
374
|
cta: { kind: "billing", label: "Go to Billing →" },
|
|
318
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>
|