@growth-operations/ui-shared 1.13.2 → 1.15.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.13.2",
3
+ "version": "1.15.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -0,0 +1,88 @@
1
+ import React from "react";
2
+ import { Flex, Text, Alert, Button } from "@hubspot/ui-extensions";
3
+
4
+ // Shared app-wide billing banner for the credit archetype (CreditState.
5
+ // to_entitlement's credits arm). Meant to sit in the app's layout/shell
6
+ // (rendered above every route), NOT inside the Billing tab itself — it's the
7
+ // "surface this everywhere, not just on the Billing page" nudge.
8
+ //
9
+ // A paid plan is never blocked or "running low" the way the free tier is —
10
+ // exceeding the included bucket is overage, not an emergency (see
11
+ // common.entitlements.credits.to_entitlement's `over_included` field). Only
12
+ // the free tier gets the blocking/urgent banners; a paid account in overage
13
+ // gets a calm informational one instead.
14
+ //
15
+ // Originally copy-pasted per app (a local `billingBannerFor` in each
16
+ // Pages.jsx) — moved here after the SAME bug (independently re-deriving
17
+ // urgency from raw `remaining`, no plan-awareness) had to be hand-fixed in
18
+ // 3 places (confirmed live 2026-09-02, file-attachment-manager: a paid
19
+ // account correctly kept working while this banner still showed the
20
+ // blocking "out of credits" copy). Consume this instead of re-implementing
21
+ // it — the next app in the wave should need zero new banner code.
22
+ //
23
+ // Props:
24
+ // state — the /v1/home payload (entitlement union). Renders nothing
25
+ // outside the credits archetype or before install completes.
26
+ // actionLabel — short gerund phrase for the depleted message, e.g.
27
+ // "attaching and updating files" / "completing, reassigning,
28
+ // and deleting tasks". Falls back to "going" if omitted.
29
+ // actionsNoun — short noun phrase for the running-low message, e.g.
30
+ // "file actions" / "task actions". Falls back to "actions".
31
+ // onNavigate — in-app PageLink-equivalent callback (the host owns the
32
+ // router — see ui-shared's OnboardingChecklist for the same
33
+ // pattern). Renders a "Go to Billing" button when provided;
34
+ // omit it to render the banner with no button.
35
+ export function BillingBanner({ state, actionLabel, actionsNoun, onNavigate }) {
36
+ const ent = state?.entitlement;
37
+ if (!ent || ent.mode !== "credits" || ent.status === "not_installed") {
38
+ return null;
39
+ }
40
+
41
+ const onPaidPlan = !!ent.plan;
42
+ let banner = null;
43
+
44
+ if (!onPaidPlan && (ent.depleted === true || ent.entitled === false)) {
45
+ banner = {
46
+ variant: "error",
47
+ title: "You're out of credits",
48
+ message: `Your credits are used up. Choose a plan in Billing to keep ${actionLabel ?? "going"}.`,
49
+ };
50
+ } else if (onPaidPlan && ent.over_included === true) {
51
+ banner = {
52
+ variant: "warning",
53
+ title: "You've used your included credits",
54
+ message:
55
+ "Extra usage this period is billed as overage at your plan's per-credit rate.",
56
+ };
57
+ } else if (
58
+ !onPaidPlan &&
59
+ typeof ent.remaining === "number" &&
60
+ typeof ent.low_threshold === "number" &&
61
+ ent.remaining <= ent.low_threshold
62
+ ) {
63
+ banner = {
64
+ variant: "warning",
65
+ title: "Running low on credits",
66
+ message: `You're almost out of credits. Pick a plan in Billing so your ${
67
+ actionsNoun ?? "actions"
68
+ } don't get interrupted.`,
69
+ };
70
+ }
71
+
72
+ if (!banner) return null;
73
+
74
+ return (
75
+ <Alert title={banner.title} variant={banner.variant}>
76
+ <Flex direction="column" gap="extra-small">
77
+ <Text>{banner.message}</Text>
78
+ {onNavigate && (
79
+ <Button variant="secondary" onClick={() => onNavigate("/billing")}>
80
+ Go to Billing →
81
+ </Button>
82
+ )}
83
+ </Flex>
84
+ </Alert>
85
+ );
86
+ }
87
+
88
+ export default BillingBanner;
@@ -122,6 +122,27 @@ function StatusPanel({ entitlement }) {
122
122
  }
123
123
 
124
124
  if (status === "active") {
125
+ // Scheduled-cancel (cancel_at_period_end) is a WARNING, not a success:
126
+ // access ends at the period boundary unless the customer acts. The "Keep
127
+ // my subscription" button lives beside this panel (TrialSubscriptionBilling
128
+ // has the billing_base_url + restart token; StatusPanel only sees the
129
+ // entitlement).
130
+ if (cancel_at_period_end) {
131
+ return (
132
+ <Alert variant="warning">
133
+ <Flex direction="column" gap="extra-small">
134
+ <Text format={{ fontWeight: "bold" }}>
135
+ Your subscription is set to cancel
136
+ </Text>
137
+ <Text>
138
+ It stays active until {fmtDate(current_period_end)}, then your
139
+ data stops syncing. Changed your mind? Keep it below — no
140
+ re-checkout needed.
141
+ </Text>
142
+ </Flex>
143
+ </Alert>
144
+ );
145
+ }
125
146
  return (
126
147
  <Alert variant="success">
127
148
  <Flex direction="column" gap="extra-small">
@@ -130,11 +151,6 @@ function StatusPanel({ entitlement }) {
130
151
  Your subscription is active. Next renewal:{" "}
131
152
  {fmtDate(current_period_end)}.
132
153
  </Text>
133
- {cancel_at_period_end && (
134
- <Text format={{ fontStyle: "italic" }}>
135
- Cancels at the end of the current period.
136
- </Text>
137
- )}
138
154
  </Flex>
139
155
  </Alert>
140
156
  );
@@ -182,7 +198,7 @@ function StatusPanel({ entitlement }) {
182
198
  const resumeCta =
183
199
  mode === "credits"
184
200
  ? "Choose a plan below to start a new subscription."
185
- : "Choose a plan below to start a new subscription and resume syncing your data.";
201
+ : "Restart your previous plan below in one click, or choose a different one — either way your data starts syncing again.";
186
202
  return (
187
203
  <Alert variant="warning">
188
204
  <Flex direction="column" gap="extra-small">
@@ -245,6 +261,20 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
245
261
  `&return_url=${encodeURIComponent(returnUrl)}`
246
262
  : null;
247
263
 
264
+ // "Keep my subscription" for a sub flagged cancel_at_period_end: hits
265
+ // /v1/billing/restart/start with NO price_id — the uncancel branch flips the
266
+ // flag off in place (the one true resume Stripe supports) and 303s back
267
+ // here. Null (button hidden, not disabled) until the restart token exists —
268
+ // an older backend that doesn't mint it simply doesn't show the button; the
269
+ // Stripe portal still offers the same renewal as a fallback.
270
+ const restartToken = state?.billing_action_tokens?.restart ?? null;
271
+ const keepSubscriptionUrl =
272
+ base && restartToken
273
+ ? `${base}/v1/billing/restart/start` +
274
+ `?token=${encodeURIComponent(restartToken)}` +
275
+ `&return_url=${encodeURIComponent(returnUrl)}`
276
+ : null;
277
+
248
278
  // Tier picker — every status needs ONE of two distinct modes, never both:
249
279
  // trialing -> UPGRADE mode: a trialing sub already
250
280
  // exists, so a higher-tier click swaps its item in place
@@ -261,8 +291,8 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
261
291
  // (upgrade) or doesn't (checkout) — showing the upgrade endpoint with no
262
292
  // real subscription behind it would 404/error at click time.
263
293
  const isTrialing = ent.status === "trialing";
264
- const needsCheckout =
265
- ent.status === "pending_purchase" || ent.status === "canceled";
294
+ const isCanceled = ent.status === "canceled";
295
+ const needsCheckout = ent.status === "pending_purchase" || isCanceled;
266
296
  const plans = state?.plans ?? [];
267
297
  const currentOrder = isTrialing
268
298
  ? plans.find((p) => p.current)?.tier_order
@@ -291,6 +321,16 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
291
321
 
292
322
  <StatusPanel entitlement={state?.entitlement} />
293
323
 
324
+ {/* Scheduled-cancel on an ACTIVE sub: one click flips
325
+ cancel_at_period_end off in place (no re-checkout, no new sub) —
326
+ the /v1/billing/restart/start uncancel branch. Rendered only when
327
+ the restart token exists (see keepSubscriptionUrl above). */}
328
+ {ent.status === "active" && ent.cancel_at_period_end && keepSubscriptionUrl && (
329
+ <Button href={{ url: keepSubscriptionUrl, external: true }} variant="primary">
330
+ Keep my subscription
331
+ </Button>
332
+ )}
333
+
294
334
  <Text format={{ fontStyle: "italic" }}>
295
335
  {needsCheckout
296
336
  ? "Choose a plan below to resume. Once you have an active subscription, use the link above to manage payment methods or view invoices."
@@ -313,12 +353,27 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
313
353
  currentOrder={currentOrder}
314
354
  endpoint={needsCheckout ? "checkout/start" : "upgrade/start"}
315
355
  ctaLabel={needsCheckout ? "Choose" : "Upgrade to"}
316
- heading={needsCheckout ? "Choose a plan to resume" : "Your plan"}
356
+ heading={
357
+ isCanceled
358
+ ? "Resume your subscription"
359
+ : needsCheckout
360
+ ? "Choose a plan to resume"
361
+ : "Your plan"
362
+ }
317
363
  footnote={
318
- needsCheckout
319
- ? "Choose a plan to start a new subscription and resume syncing your data."
320
- : "Upgrade any time during your trial — your trial end date stays the same, and the new tier applies when it converts. To move to a lower tier, talk to sales."
364
+ isCanceled
365
+ ? "Restart charges your card on file and resumes syncing immediately — no checkout page. If the card needs updating, we'll send you to Stripe to finish. You can also switch to a different plan or billing interval."
366
+ : needsCheckout
367
+ ? "Choose a plan to start a new subscription and resume syncing your data."
368
+ : "Upgrade any time during your trial — your trial end date stays the same, and the new tier applies when it converts. To move to a lower tier, talk to sales."
321
369
  }
370
+ // Canceled only: the account's last tier is still stamped
371
+ // `current` (account.plan persists after cancel), so its card
372
+ // becomes the one-click "Restart <plan>" CTA (/v1/billing/
373
+ // restart/start — recreate off-session on the saved card, with a
374
+ // hosted-Checkout fallback server-side) instead of a disabled
375
+ // "Current plan" marker. Other tiers stay plain checkout.
376
+ resumeCurrentPlan={isCanceled}
322
377
  openIframe={openIframe}
323
378
  />
324
379
  )}
@@ -72,6 +72,17 @@ function PlanCard({
72
72
  // can't read whether the external meetings page is CSP-frameable, so the modal
73
73
  // is best-effort; the href fallback always works.
74
74
  openIframe = null,
75
+ // Canceled-account checkout grid (BillingTab passes this only for status
76
+ // "canceled"): the account's LAST tier is still stamped plan.current, so its
77
+ // card becomes the one-click "Restart <plan>" CTA targeting
78
+ // /v1/billing/restart/start (off-session recreate on the saved card, hosted
79
+ // Checkout fallback server-side) instead of the disabled "Current plan"
80
+ // marker, and the tag reads "Previous plan". The card's interval toggle stays
81
+ // live — restarting onto the OTHER interval is a legitimate switch (the old
82
+ // sub is gone; there is no in-place swap, so isIntervalSwitch is suppressed
83
+ // in this mode). If the backend doesn't mint a restart token (older common),
84
+ // the card degrades to the plain checkout CTA — same recovery, one extra page.
85
+ resumeCurrentPlan = false,
75
86
  }) {
76
87
  // Default this card to annual when it offers annual (cheaper-per-month story);
77
88
  // monthly-only tiers default to monthly. Both legs → show the toggle. The
@@ -107,10 +118,17 @@ function PlanCard({
107
118
  // a downgrade card is fully read-only.
108
119
  const canToggle = hasMonthly && hasAnnual && !isDowngrade;
109
120
  const [interval, setInterval] = useState(defaultInterval);
121
+ // Resume mode (canceled account): this card IS the restart CTA. The old
122
+ // subscription is gone, so the in-place interval-switch concept doesn't
123
+ // apply — toggling the interval just picks which price the restart creates.
124
+ // talk_to_sales tiers are excluded: they have no price to restart onto
125
+ // (custom pricing goes through sales regardless of account state).
126
+ const isResumeCard = resumeCurrentPlan && plan.current && !plan.talk_to_sales;
110
127
  // On the current card, toggling to a DIFFERENT interval than what's actually
111
128
  // billed is the in-place monthly<->annual switch; toggling back to the same
112
129
  // interval (or current_interval being unknown) is just "Current plan".
113
130
  const isIntervalSwitch =
131
+ !isResumeCard &&
114
132
  plan.current &&
115
133
  plan.current_interval != null &&
116
134
  ((interval === "annual" && plan.current_interval !== "year") ||
@@ -168,6 +186,20 @@ function PlanCard({
168
186
  `&return_url=${encodeURIComponent(returnUrl ?? "")}`
169
187
  : null;
170
188
 
189
+ // Resume mode: the restart endpoint recreates the canceled sub off-session
190
+ // on the card on file (falling back to hosted Checkout server-side when
191
+ // there's no usable card) — so this card's CTA can ALWAYS point at
192
+ // restart/start; the degradation when the backend predates the restart
193
+ // action is just the plain checkout CTA below.
194
+ const restartToken = billingActionTokens?.restart ?? null;
195
+ const restartUrl =
196
+ isResumeCard && billingBaseUrl && restartToken && leg?.price_id
197
+ ? `${billingBaseUrl}/v1/billing/restart/start` +
198
+ `?token=${encodeURIComponent(restartToken)}` +
199
+ `&price_id=${encodeURIComponent(leg.price_id)}` +
200
+ `&return_url=${encodeURIComponent(returnUrl ?? "")}`
201
+ : null;
202
+
171
203
  const features = plan.features ?? [];
172
204
  // Align the CTA buttons across cards. HubSpot's column Flex does NOT stretch
173
205
  // to the Tile height (AutoGrid equalizes the card height but flex={1}/justify
@@ -184,7 +216,9 @@ function PlanCard({
184
216
  <Flex direction="row" gap="small" align="center">
185
217
  <Heading>{plan.name ?? plan.tier}</Heading>
186
218
  {plan.current && (
187
- <StatusTag variant="success">Current plan</StatusTag>
219
+ <StatusTag variant={isResumeCard ? "default" : "success"}>
220
+ {isResumeCard ? "Previous plan" : "Current plan"}
221
+ </StatusTag>
188
222
  )}
189
223
  </Flex>
190
224
 
@@ -239,7 +273,36 @@ function PlanCard({
239
273
  (lower than current, trial picker) => disabled + Talk-to-sales (not
240
274
  self-serve). talk_to_sales tier => contact link. Otherwise
241
275
  pre-create-then-link checkout/upgrade for the selected interval. */}
242
- {plan.current && !isIntervalSwitch ? (
276
+ {isResumeCard && restartUrl ? (
277
+ // One-click restart: recreate the canceled sub on the saved card
278
+ // (off-session) via /v1/billing/restart/start — no checkout page.
279
+ // The selected interval's price is what gets restarted onto.
280
+ <Button
281
+ href={{ url: restartUrl, external: true }}
282
+ variant="primary"
283
+ >
284
+ Restart {plan.name ?? plan.tier}
285
+ </Button>
286
+ ) : isResumeCard && !restartUrl && !leg ? (
287
+ // Resume mode but no price for the selected interval and no restart
288
+ // URL to fall back on — same guidance as the generic no-leg case.
289
+ <Text format={{ fontStyle: "italic" }}>
290
+ Not available {interval === "annual" ? "annually" : "monthly"}
291
+ </Text>
292
+ ) : isResumeCard && !restartUrl ? (
293
+ // Backend predates the restart action (no restart token minted) —
294
+ // degrade to the plain checkout CTA; same recovery, one extra page.
295
+ <Button
296
+ href={startUrl ? { url: startUrl, external: true } : undefined}
297
+ disabled={!startUrl}
298
+ variant="primary"
299
+ >
300
+ {ctaLabel} {plan.name ?? plan.tier}
301
+ </Button>
302
+ ) : plan.current && !isIntervalSwitch && !resumeCurrentPlan ? (
303
+ // Inert "Current plan" marker — suppressed in resume mode, where a
304
+ // canceled account's previous tier must stay actionable (restart
305
+ // above, checkout fallback, or Talk-to-sales for custom tiers).
243
306
  <Button disabled>Current plan</Button>
244
307
  ) : plan.current && isIntervalSwitch ? (
245
308
  <Button
@@ -335,6 +398,11 @@ export function PlanGrid({
335
398
  // actions.openIframeModal (from the hosting card root) — lets a talk_to_sales
336
399
  // tier open its meetings_url scheduler in an in-card modal. Optional.
337
400
  openIframe = null,
401
+ // Canceled-account grid (BillingTab, status "canceled" only): the account's
402
+ // last tier (still stamped `current`) renders as a one-click "Restart
403
+ // <plan>" CTA via /v1/billing/restart/start instead of the disabled
404
+ // "Current plan" marker. See PlanCard.
405
+ resumeCurrentPlan = false,
338
406
  }) {
339
407
  const plans = plansOverride ?? state?.plans ?? [];
340
408
 
@@ -377,6 +445,7 @@ export function PlanGrid({
377
445
  ctaLabel={ctaLabel}
378
446
  currentOrder={currentOrder}
379
447
  openIframe={openIframe}
448
+ resumeCurrentPlan={resumeCurrentPlan}
380
449
  />
381
450
  ))}
382
451
  </AutoGrid>
package/src/index.js CHANGED
@@ -17,6 +17,7 @@ export { HelpfulLinks } from "./home/HelpfulLinks";
17
17
  // Billing.
18
18
  export { BillingTab } from "./billing/BillingTab";
19
19
  export { PlanGrid } from "./billing/PlanGrid";
20
+ export { BillingBanner } from "./billing/BillingBanner";
20
21
 
21
22
  // Alerts — the shared customer alerts table (level filter, sort, dismiss).
22
23
  export { AlertsTab } from "./alerts/AlertsTab";
@@ -11,8 +11,8 @@
11
11
  // token these endpoints don't expect).
12
12
  import { hubspot, logger } from "@hubspot/ui-extensions";
13
13
 
14
- // Re-mint a fresh { portal, checkout, upgrade } token set from a still-valid
15
- // one. See billing.py's /v1/billing/refresh-tokens: it verifies `token`
14
+ // Re-mint a fresh { portal, checkout, upgrade, restart } token set from a
15
+ // still-valid one. See billing.py's /v1/billing/refresh-tokens: it verifies `token`
16
16
  // (whichever action it authorizes — BillingTab always holds/passes `portal`)
17
17
  // and returns a brand-new set signed for the same app_key/portal_id. Throws
18
18
  // on a 401 (token already expired) — the caller's next /v1/home load is the