@growth-operations/ui-shared 1.14.0 → 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 +1 -1
- package/src/billing/BillingTab.jsx +67 -12
- package/src/billing/PlanGrid.jsx +71 -2
- package/src/sdk/billing.js +2 -2
package/package.json
CHANGED
|
@@ -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
|
-
: "
|
|
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
|
|
265
|
-
|
|
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={
|
|
356
|
+
heading={
|
|
357
|
+
isCanceled
|
|
358
|
+
? "Resume your subscription"
|
|
359
|
+
: needsCheckout
|
|
360
|
+
? "Choose a plan to resume"
|
|
361
|
+
: "Your plan"
|
|
362
|
+
}
|
|
317
363
|
footnote={
|
|
318
|
-
|
|
319
|
-
? "
|
|
320
|
-
:
|
|
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
|
)}
|
package/src/billing/PlanGrid.jsx
CHANGED
|
@@ -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">
|
|
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
|
-
{
|
|
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/sdk/billing.js
CHANGED
|
@@ -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
|
|
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
|