@growth-operations/ui-shared 1.36.0 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-operations/ui-shared",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -640,6 +640,7 @@ export function CreditsBilling({ context, state, appKey, openIframe = null }) {
640
640
  <CreditMeter
641
641
  entitlement={state?.entitlement}
642
642
  creditMeter={state?.credit_meter}
643
+ appName={state?.app?.name}
643
644
  />
644
645
  <LoadingButton
645
646
  href={
@@ -719,6 +720,7 @@ export function CreditsBilling({ context, state, appKey, openIframe = null }) {
719
720
  <CreditMeter
720
721
  entitlement={state?.entitlement}
721
722
  creditMeter={state?.credit_meter}
723
+ appName={state?.app?.name}
722
724
  />
723
725
  <PlanGrid context={context} state={state} appKey={appKey} openIframe={openIframe} />
724
726
  {!hasPlans && (
@@ -43,8 +43,10 @@ import { resolveCreditMeter } from "../lib/creditDisplay";
43
43
  // credits — the /v1/home credits block (optional; the authoritative
44
44
  // two-bucket monthly/additional numbers + renews_on when the
45
45
  // backend carries it — see CONTRACT.md).
46
- export function CreditMeter({ entitlement, creditMeter, credits = null }) {
47
- const view = resolveCreditMeter(entitlement, creditMeter, credits);
46
+ // appName — display name for the trial-ended alert copy (optional;
47
+ // hosts pass state.app.name — falls back to "this app").
48
+ export function CreditMeter({ entitlement, creditMeter, credits = null, appName = null }) {
49
+ const view = resolveCreditMeter(entitlement, creditMeter, credits, { appName });
48
50
  if (!view) return null;
49
51
 
50
52
  const { granted, used, remaining, bank, tagVariant, tagLabel, poolBarVariant, bankBarVariant, alert } = view;
@@ -25,6 +25,11 @@
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
+ //
29
+ // TRIAL ENDED overrides all of the above for FREE accounts (Andrew
30
+ // 2026-11-11): the backend maps an expired grant clock onto depleted even
31
+ // with credits left, so a lapsed clock reads tag "Trial ended" + the
32
+ // trial-ended alert, never "Depleted"/"out of credits".
28
33
  import { daysUntil, SUPPORT_FOOTER } from "./format";
29
34
 
30
35
  // True when the account has (or ever had) a top-up bank. Note the CreditMeter
@@ -108,7 +113,7 @@ export function creditsBlockOf(state) {
108
113
  return state.state?.credits ?? state.credits ?? null;
109
114
  }
110
115
 
111
- export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
116
+ export function resolveCreditMeter(entitlement, creditMeter, credits = null, { appName } = {}) {
112
117
  const ent = entitlement?.mode === "credits" ? entitlement : null;
113
118
  const meter = creditMeter ?? null;
114
119
  // The state.credits block stands on its own (it IS the credit data), so it
@@ -161,6 +166,16 @@ export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
161
166
  : !onPaidPlan && (ent?.depleted === true || remaining <= 0);
162
167
  const low = !depleted && !overIncluded && remaining <= lowThreshold;
163
168
 
169
+ // Trial ended (free account, grant clock lapsed) outranks every credit
170
+ // reading on the tag/alert — the backend maps an expired grant onto
171
+ // depleted even with credits left, and the lapsed clock is the true story
172
+ // (Andrew 2026-11-11; the same override the top BillingBanner applies).
173
+ const trialDaysLeft =
174
+ !onPaidPlan && ent?.grant_expires_at != null
175
+ ? daysUntil(ent.grant_expires_at)
176
+ : null;
177
+ const trialEnded = trialDaysLeft != null && trialDaysLeft <= 0;
178
+
164
179
  // Bank split. Drawdown is monthly-first, so of the combined `remaining` the
165
180
  // bank portion is bankRemaining and the rest is pool remaining; the pool
166
181
  // segment's full size is the combined denominator minus the bank.
@@ -216,7 +231,10 @@ export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
216
231
 
217
232
  let tagVariant;
218
233
  let tagLabel;
219
- if (depleted) {
234
+ if (trialEnded) {
235
+ tagVariant = "danger";
236
+ tagLabel = "Trial ended";
237
+ } else if (depleted) {
220
238
  tagVariant = "danger";
221
239
  tagLabel = "Depleted";
222
240
  } else if (thresholdState === "top_up_failed") {
@@ -253,7 +271,13 @@ export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
253
271
  // The inline meter alert mirrors the banner's messaging at the point of
254
272
  // glance. Legacy branches keep their pre-top-up copy byte-for-byte.
255
273
  let alert = null;
256
- if (depleted) {
274
+ if (trialEnded) {
275
+ alert = {
276
+ variant: "error",
277
+ title: "Your trial has ended",
278
+ message: `Your free trial has ended. Select a plan to continue use of ${appName ?? "this app"}.`,
279
+ };
280
+ } else if (depleted) {
257
281
  alert = {
258
282
  variant: "error",
259
283
  title: "You're out of credits",
@@ -321,12 +345,19 @@ export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
321
345
  // owner) is reused VERBATIM here — only the placement and the trial-ended
322
346
  // variant (warning -> error: it stops the app) changed.
323
347
  //
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".
348
+ // Andrew 2026-11-11: the trial CLOCK outranks the credit count. Once the
349
+ // 30-day window lapses, "trial ended" is the ONLY banner — it overrides the
350
+ // credits reading even when the grant is also spent. This ordering matters
351
+ // because the backend maps an EXPIRED grant (credits left) onto depleted /
352
+ // threshold_state "depleted" (CreditState.to_entitlement), so the "explicit"
353
+ // out-of-credits signals often MEAN "trial over", not "credits gone" —
354
+ // portal 52109545 flew "You're out of credits" with 49/100 credits left.
355
+ //
356
+ // First match wins inside trialStateOf:
357
+ // trial ended — the grant's 30-day clock ran out (credits left or not).
358
+ // out of credits — the free grant is spent INSIDE the window (depleted /
359
+ // remaining <= 0 with days left, or no clock on record).
360
+ // trial active — the clock is still running: "[XX] trial days remaining".
330
361
  // Returns { kind, title, body } or null (no grant clock on record).
331
362
  function trialStateOf(ent, { appName } = {}) {
332
363
  const name = appName ?? "this app";
@@ -336,13 +367,6 @@ function trialStateOf(ent, { appName } = {}) {
336
367
  const daysLeft =
337
368
  ent.grant_expires_at != null ? daysUntil(ent.grant_expires_at) : null;
338
369
 
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
370
  if (daysLeft != null && daysLeft <= 0) {
347
371
  return {
348
372
  kind: "trial_ended",
@@ -350,6 +374,18 @@ function trialStateOf(ent, { appName } = {}) {
350
374
  body: `Your free trial has ended. Select a plan to continue use of ${name}.`,
351
375
  };
352
376
  }
377
+ if (ent.depleted === true || remaining <= 0) {
378
+ return {
379
+ kind: "out_of_credits",
380
+ // True when a live trial clock is on record (a lapsed clock returned
381
+ // trial_ended above). The banner's hard-depleted branch keys on this:
382
+ // live-clock accounts fly the slide's used-X/Y copy (Andrew 2026-11-11);
383
+ // clockless free accounts keep the generic pre-top-up line.
384
+ liveClock: daysLeft != null,
385
+ title: "You're out of credits",
386
+ body: `You have used ${used}/${granted} free credits. Select one of the plans below to continue use of ${name}.`,
387
+ };
388
+ }
353
389
  if (daysLeft != null) {
354
390
  return {
355
391
  kind: "trial_active",
@@ -395,13 +431,18 @@ function trialBannerView(trial) {
395
431
  // 1. top_up_pending (info) — transient in-flight top-up
396
432
  // 2. top_up_failed (error) — card on file failed to process
397
433
  // 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
434
+ // 4. trial ended (error) — the grant clock ran out; the ONLY banner
435
+ // after day 30 (Andrew 2026-11-11), beating
436
+ // every credits reading — the backend maps
437
+ // expiry onto depleted / threshold_state
438
+ // "depleted", so an "explicit" depleted often
439
+ // MEANS trial over (portal 52109545)
440
+ // 5. depleted (error) — THE out-of-credits block. A free account
441
+ // with a live trial clock flies the slide's
442
+ // used-X/Y trial copy; clockless free and
443
+ // topup-paid accounts keep the generic copy
444
+ // 6. trial out_of_credits (error) — free grant spent WITHOUT the explicit
403
445
  // signals (remaining <= 0 catch-all)
404
- // 6. trial ended (error) — the grant clock ran out with credits left
405
446
  // 7. over_included (warning) — paid metered overage (never blocked)
406
447
  // 8. running low (warning) — free grant under low_threshold
407
448
  // 9. trial active (warning) — the countdown; yields to running-low, the
@@ -448,12 +489,15 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun, ap
448
489
  }
449
490
 
450
491
  // 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".
492
+ // the trial). TRIAL ENDED is resolved BEFORE the depleted branch: the
493
+ // backend maps an expired grant onto depleted / threshold_state "depleted"
494
+ // even with credits left, and after the 30-day window the lapsed clock is
495
+ // the true story (Andrew 2026-11-11 — portal 52109545 showed "You're out of
496
+ // credits" with 49/100 credits left).
456
497
  const trial = onPaidPlan ? null : trialStateOf(ent, { appName });
498
+ if (trial?.kind === "trial_ended") {
499
+ return trialBannerView(trial);
500
+ }
457
501
 
458
502
  // --- Hard-blocked. threshold_state "depleted" is the ONLY blocking state
459
503
  // (a topup-model paid plan blocks at true zero); `over_included` overrides
@@ -464,11 +508,15 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun, ap
464
508
  const depleted =
465
509
  thresholdState != null
466
510
  ? thresholdState === "depleted" && !overIncluded
467
- : !onPaidPlan &&
468
- (ent.depleted === true ||
469
- (ent.entitled === false && trial?.kind !== "trial_ended"));
511
+ : !onPaidPlan && (ent.depleted === true || ent.entitled === false);
470
512
 
471
513
  if (depleted) {
514
+ // A free TRIAL account at 100/100 inside the window flies the slide's
515
+ // used-X/Y trial copy (Andrew 2026-11-11); the generic line below is for
516
+ // clockless free accounts and topup-model paid plans blocked at zero.
517
+ if (trial?.kind === "out_of_credits" && trial.liveClock) {
518
+ return trialBannerView(trial);
519
+ }
472
520
  return {
473
521
  variant: "error",
474
522
  title: "You're out of credits",
@@ -478,9 +526,9 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun, ap
478
526
  cta: { kind: "billing", label: "Go to Billing →" },
479
527
  };
480
528
  }
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")) {
529
+ // The spent free grant the explicit signals didn't catch (remaining <= 0
530
+ // without any flag, clock still running).
531
+ if (trial?.kind === "out_of_credits") {
484
532
  return trialBannerView(trial);
485
533
  }
486
534
  if (overIncluded) {
@@ -69,6 +69,7 @@ export function UsageTab({
69
69
  entitlement={state.entitlement}
70
70
  creditMeter={state.credit_meter}
71
71
  credits={creditsBlockOf(state)}
72
+ appName={state?.app?.name ?? appName}
72
73
  />
73
74
  )}
74
75