@growth-operations/ui-shared 1.5.7 → 1.5.9
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 +49 -53
- package/src/billing/PlanGrid.jsx +53 -25
package/package.json
CHANGED
|
@@ -140,9 +140,10 @@ function StatusPanel({ entitlement }) {
|
|
|
140
140
|
);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
// Pre-creates a Stripe
|
|
144
|
-
// { portalUrl, loading, error }.
|
|
145
|
-
// the
|
|
143
|
+
// Pre-creates a Stripe Customer Portal session on mount (in-iframe POST) and
|
|
144
|
+
// returns { portalUrl, loading, error, notReady }. Used by the TRIAL arm only —
|
|
145
|
+
// the credits arm builds a direct /v1/billing/portal/start link instead (no
|
|
146
|
+
// in-iframe fetch, avoiding the billing service's cold-start timeout).
|
|
146
147
|
function usePortalSession({ context, state, appKey }) {
|
|
147
148
|
const [loading, setLoading] = useState(true);
|
|
148
149
|
const [error, setError] = useState(null);
|
|
@@ -169,18 +170,11 @@ function usePortalSession({ context, state, appKey }) {
|
|
|
169
170
|
resetOnUnmount();
|
|
170
171
|
return;
|
|
171
172
|
}
|
|
172
|
-
// No Stripe customer yet → don't even try to open a portal.
|
|
173
|
-
// that hasn't been provisioned
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// the trial arm relies on `status`.
|
|
178
|
-
const ent = state?.entitlement;
|
|
179
|
-
const status = ent?.status;
|
|
180
|
-
const onPaidPlan = !!ent?.plan;
|
|
181
|
-
const notProvisioned =
|
|
182
|
-
!ent || status === "not_installed" || (status == null && !onPaidPlan);
|
|
183
|
-
if (notProvisioned) {
|
|
173
|
+
// No Stripe customer yet → don't even try to open a portal. A trial
|
|
174
|
+
// install that hasn't been provisioned (no entitlement, or status null/
|
|
175
|
+
// not_installed) has nothing to manage; show the calm "not ready" state.
|
|
176
|
+
const status = state?.entitlement?.status;
|
|
177
|
+
if (!state.entitlement || status == null || status === "not_installed") {
|
|
184
178
|
if (mounted.current) {
|
|
185
179
|
setNotReady(true);
|
|
186
180
|
setLoading(false);
|
|
@@ -293,16 +287,28 @@ function TrialSubscriptionBilling({ context, state, appKey }) {
|
|
|
293
287
|
}
|
|
294
288
|
|
|
295
289
|
function CreditsBilling({ context, state, appKey }) {
|
|
296
|
-
// The Stripe portal session lets an EXISTING paid customer manage/switch/cancel
|
|
297
|
-
// their plan, with proration handled by Stripe.
|
|
298
|
-
const { portalUrl, loading, error, notReady } = usePortalSession({
|
|
299
|
-
context,
|
|
300
|
-
state,
|
|
301
|
-
appKey,
|
|
302
|
-
});
|
|
303
|
-
|
|
304
290
|
const onPaidPlan = !!state?.entitlement?.plan;
|
|
305
291
|
|
|
292
|
+
// Direct external link to the billing service's GET /v1/billing/portal/start,
|
|
293
|
+
// which resolves the customer + creates the Customer Portal session server-side
|
|
294
|
+
// and 303s to Stripe — IN THE OPENED TAB. No in-iframe portal-session fetch
|
|
295
|
+
// (that round trip hit the billing service's cold-start/CPU stall and surfaced
|
|
296
|
+
// as the iframe's 15s "Gateway took too long" error). Same pattern as
|
|
297
|
+
// PlanGrid's /checkout/start "Choose" button. null (button disabled) until
|
|
298
|
+
// billing_base_url is known.
|
|
299
|
+
const base = state?.billing_base_url ?? null;
|
|
300
|
+
const portalId = context?.portal?.id;
|
|
301
|
+
const returnUrl = state?.app_id
|
|
302
|
+
? `https://app.hubspot.com/app/${portalId}/${state.app_id}/billing`
|
|
303
|
+
: "https://app.hubspot.com/";
|
|
304
|
+
const portalStartUrl =
|
|
305
|
+
base && appKey && portalId
|
|
306
|
+
? `${base}/v1/billing/portal/start` +
|
|
307
|
+
`?app_key=${encodeURIComponent(appKey)}` +
|
|
308
|
+
`&portal_id=${encodeURIComponent(String(portalId))}` +
|
|
309
|
+
`&return_url=${encodeURIComponent(returnUrl)}`
|
|
310
|
+
: null;
|
|
311
|
+
|
|
306
312
|
// PAID: do NOT show the plan picker. The picker's "Choose" starts a NEW Stripe
|
|
307
313
|
// Checkout subscription — clicking another tier would create a SECOND
|
|
308
314
|
// subscription (double-bill), not switch in place. Plan changes (upgrade/
|
|
@@ -316,21 +322,15 @@ function CreditsBilling({ context, state, appKey }) {
|
|
|
316
322
|
entitlement={state?.entitlement}
|
|
317
323
|
creditMeter={state?.credit_meter}
|
|
318
324
|
/>
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
disabled={!portalUrl}
|
|
329
|
-
variant="primary"
|
|
330
|
-
>
|
|
331
|
-
{portalUrl ? "Manage subscription" : "Preparing billing…"}
|
|
332
|
-
</LoadingButton>
|
|
333
|
-
))}
|
|
325
|
+
<LoadingButton
|
|
326
|
+
href={
|
|
327
|
+
portalStartUrl ? { url: portalStartUrl, external: true } : undefined
|
|
328
|
+
}
|
|
329
|
+
disabled={!portalStartUrl}
|
|
330
|
+
variant="primary"
|
|
331
|
+
>
|
|
332
|
+
{portalStartUrl ? "Manage subscription" : "Preparing billing…"}
|
|
333
|
+
</LoadingButton>
|
|
334
334
|
<Text format={{ fontStyle: "italic" }}>
|
|
335
335
|
Change or cancel your plan in Stripe — billing is managed across all
|
|
336
336
|
Growth Operations apps.
|
|
@@ -350,21 +350,17 @@ function CreditsBilling({ context, state, appKey }) {
|
|
|
350
350
|
creditMeter={state?.credit_meter}
|
|
351
351
|
/>
|
|
352
352
|
<PlanGrid context={context} state={state} appKey={appKey} />
|
|
353
|
-
{!
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
>
|
|
365
|
-
{portalUrl ? "Buy credits in Stripe" : "Preparing billing…"}
|
|
366
|
-
</LoadingButton>
|
|
367
|
-
))}
|
|
353
|
+
{!hasPlans && (
|
|
354
|
+
<LoadingButton
|
|
355
|
+
href={
|
|
356
|
+
portalStartUrl ? { url: portalStartUrl, external: true } : undefined
|
|
357
|
+
}
|
|
358
|
+
disabled={!portalStartUrl}
|
|
359
|
+
variant="primary"
|
|
360
|
+
>
|
|
361
|
+
{portalStartUrl ? "Buy credits in Stripe" : "Preparing billing…"}
|
|
362
|
+
</LoadingButton>
|
|
363
|
+
)}
|
|
368
364
|
{!hasPlans && (
|
|
369
365
|
<Text format={{ fontStyle: "italic" }}>
|
|
370
366
|
Billing is managed in Stripe across all Growth Operations apps.
|
package/src/billing/PlanGrid.jsx
CHANGED
|
@@ -34,9 +34,14 @@ import { fmtMoney } from "../lib/format";
|
|
|
34
34
|
// feature bullets, and a CTA: "Current" (disabled) for the active tier,
|
|
35
35
|
// "Contact sales" for talk_to_sales tiers, else "Choose" which pre-creates a
|
|
36
36
|
// checkout session for that tier's price in the selected interval.
|
|
37
|
+
//
|
|
38
|
+
// The Monthly/Annual toggle is PER CARD (not a single grid-wide switch): a tier
|
|
39
|
+
// may offer one interval and not the other (e.g. a top tier that's monthly-only),
|
|
40
|
+
// so each card owns its own interval state and only shows the toggle when it has
|
|
41
|
+
// BOTH legs. A subscription still bills on one cadence — the cadence bought is
|
|
42
|
+
// whatever this card's toggle shows when its "Choose" is clicked.
|
|
37
43
|
function PlanCard({
|
|
38
44
|
plan,
|
|
39
|
-
interval,
|
|
40
45
|
billingBaseUrl,
|
|
41
46
|
appKey,
|
|
42
47
|
portalId,
|
|
@@ -44,6 +49,12 @@ function PlanCard({
|
|
|
44
49
|
supportUrl,
|
|
45
50
|
maxFeatures = 0,
|
|
46
51
|
}) {
|
|
52
|
+
// Default this card to annual when it offers annual (cheaper-per-month story);
|
|
53
|
+
// monthly-only tiers default to monthly. Both legs → show the toggle.
|
|
54
|
+
const hasMonthly = !!plan.monthly;
|
|
55
|
+
const hasAnnual = !!plan.annual;
|
|
56
|
+
const canToggle = hasMonthly && hasAnnual;
|
|
57
|
+
const [interval, setInterval] = useState(hasAnnual ? "annual" : "monthly");
|
|
47
58
|
const leg = interval === "annual" ? plan.annual : plan.monthly;
|
|
48
59
|
const periodLabel = interval === "annual" ? "/yr" : "/mo";
|
|
49
60
|
|
|
@@ -53,6 +64,17 @@ function PlanCard({
|
|
|
53
64
|
? `${fmtMoney(leg.unit_amount, leg.currency)}${periodLabel}`
|
|
54
65
|
: "Free";
|
|
55
66
|
|
|
67
|
+
// Annual savings vs. paying monthly for a year. Only when this card is showing
|
|
68
|
+
// annual AND has both legs to compare. annualFull = the struck-through "12×
|
|
69
|
+
// monthly" reference price; pct = the highlighted discount.
|
|
70
|
+
const showAnnualSavings =
|
|
71
|
+
interval === "annual" && hasMonthly && hasAnnual && plan.annual?.unit_amount;
|
|
72
|
+
const annualFull = showAnnualSavings ? plan.monthly.unit_amount * 12 : null;
|
|
73
|
+
const savingsPct =
|
|
74
|
+
showAnnualSavings && annualFull > plan.annual.unit_amount
|
|
75
|
+
? Math.round((1 - plan.annual.unit_amount / annualFull) * 100)
|
|
76
|
+
: 0;
|
|
77
|
+
|
|
56
78
|
// Direct one-click checkout link to the billing service's redirect endpoint.
|
|
57
79
|
// Null until we have everything (billing host, appKey, portal, this interval's
|
|
58
80
|
// price) — the button is disabled until then.
|
|
@@ -85,7 +107,33 @@ function PlanCard({
|
|
|
85
107
|
)}
|
|
86
108
|
</Flex>
|
|
87
109
|
|
|
88
|
-
<
|
|
110
|
+
<Flex direction="row" gap="small" align="center">
|
|
111
|
+
<Text format={{ fontWeight: "bold", fontSize: "lg" }}>{priceText}</Text>
|
|
112
|
+
{savingsPct > 0 && (
|
|
113
|
+
<StatusTag variant="success">Save {savingsPct}%</StatusTag>
|
|
114
|
+
)}
|
|
115
|
+
</Flex>
|
|
116
|
+
{/* Struck-through "12× monthly" reference so the annual discount reads. */}
|
|
117
|
+
{savingsPct > 0 && (
|
|
118
|
+
<Text format={{ lineDecoration: "strikethrough", fontStyle: "italic" }}>
|
|
119
|
+
{fmtMoney(annualFull, plan.annual.currency)}/yr
|
|
120
|
+
</Text>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{/* Per-card interval toggle — only when this tier offers both legs. */}
|
|
124
|
+
{canToggle && (
|
|
125
|
+
<ToggleGroup
|
|
126
|
+
name={`billing-interval-${plan.tier}`}
|
|
127
|
+
toggleType="radioButtonList"
|
|
128
|
+
inline={true}
|
|
129
|
+
value={interval}
|
|
130
|
+
onChange={(v) => v && setInterval(v)}
|
|
131
|
+
options={[
|
|
132
|
+
{ label: "Monthly", value: "monthly" },
|
|
133
|
+
{ label: "Annual", value: "annual" },
|
|
134
|
+
]}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
89
137
|
|
|
90
138
|
{plan.credits_per_period != null && (
|
|
91
139
|
<Text>{plan.credits_per_period.toLocaleString()} credits / month</Text>
|
|
@@ -137,11 +185,6 @@ function PlanCard({
|
|
|
137
185
|
|
|
138
186
|
export function PlanGrid({ context, state, appKey }) {
|
|
139
187
|
const plans = state?.plans ?? [];
|
|
140
|
-
// Default to annual only if EVERY paid plan offers it; else monthly.
|
|
141
|
-
const paid = plans.filter((p) => !p.talk_to_sales && (p.monthly || p.annual));
|
|
142
|
-
const annualAvailable =
|
|
143
|
-
paid.length > 0 && paid.every((p) => p.annual);
|
|
144
|
-
const [interval, setInterval] = useState(annualAvailable ? "annual" : "monthly");
|
|
145
188
|
|
|
146
189
|
if (plans.length === 0) return null;
|
|
147
190
|
|
|
@@ -162,31 +205,16 @@ export function PlanGrid({ context, state, appKey }) {
|
|
|
162
205
|
|
|
163
206
|
return (
|
|
164
207
|
<Flex direction="column" gap="medium">
|
|
165
|
-
<
|
|
166
|
-
<Heading>Plans</Heading>
|
|
167
|
-
{annualAvailable && (
|
|
168
|
-
<ToggleGroup
|
|
169
|
-
name="billing-interval"
|
|
170
|
-
toggleType="radioButtonList"
|
|
171
|
-
inline={true}
|
|
172
|
-
value={interval}
|
|
173
|
-
onChange={(v) => v && setInterval(v)}
|
|
174
|
-
options={[
|
|
175
|
-
{ label: "Monthly", value: "monthly" },
|
|
176
|
-
{ label: "Annual", value: "annual" },
|
|
177
|
-
]}
|
|
178
|
-
/>
|
|
179
|
-
)}
|
|
180
|
-
</Flex>
|
|
208
|
+
<Heading>Plans</Heading>
|
|
181
209
|
|
|
182
210
|
{/* AutoGrid(flexible): equal-width columns that wrap responsively. Card
|
|
183
|
-
height is equalized via the maxFeatures padding in PlanCard.
|
|
211
|
+
height is equalized via the maxFeatures padding in PlanCard. Each card
|
|
212
|
+
owns its own Monthly/Annual toggle (see PlanCard). */}
|
|
184
213
|
<AutoGrid columnWidth={240} flexible={true} gap="medium">
|
|
185
214
|
{plans.map((plan) => (
|
|
186
215
|
<PlanCard
|
|
187
216
|
key={plan.tier}
|
|
188
217
|
plan={plan}
|
|
189
|
-
interval={interval}
|
|
190
218
|
billingBaseUrl={billingBaseUrl}
|
|
191
219
|
appKey={appKey}
|
|
192
220
|
portalId={portalId}
|