@growth-operations/ui-shared 1.5.4 → 1.5.6
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 +45 -23
- package/src/billing/PlanGrid.jsx +36 -72
package/package.json
CHANGED
|
@@ -286,34 +286,64 @@ function TrialSubscriptionBilling({ context, state, appKey }) {
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
function CreditsBilling({ context, state, appKey }) {
|
|
289
|
-
// The Stripe portal session lets an EXISTING paid customer manage
|
|
290
|
-
//
|
|
291
|
-
// selection is the <PlanGrid> below, which pre-creates a per-tier checkout.
|
|
289
|
+
// The Stripe portal session lets an EXISTING paid customer manage/switch/cancel
|
|
290
|
+
// their plan, with proration handled by Stripe.
|
|
292
291
|
const { portalUrl, loading, error, notReady } = usePortalSession({
|
|
293
292
|
context,
|
|
294
293
|
state,
|
|
295
294
|
appKey,
|
|
296
295
|
});
|
|
297
296
|
|
|
298
|
-
const hasPlans = (state?.plans?.length ?? 0) > 0;
|
|
299
297
|
const onPaidPlan = !!state?.entitlement?.plan;
|
|
300
298
|
|
|
299
|
+
// PAID: do NOT show the plan picker. The picker's "Choose" starts a NEW Stripe
|
|
300
|
+
// Checkout subscription — clicking another tier would create a SECOND
|
|
301
|
+
// subscription (double-bill), not switch in place. Plan changes (upgrade/
|
|
302
|
+
// downgrade/cancel) go through the Stripe Customer Portal, which swaps the
|
|
303
|
+
// subscription item with proration. So on a paid plan we show the meter + a
|
|
304
|
+
// "Manage subscription" portal link only. (Matches toast/sparkfly.)
|
|
305
|
+
if (onPaidPlan) {
|
|
306
|
+
return (
|
|
307
|
+
<Flex direction="column" gap="medium">
|
|
308
|
+
<CreditMeter
|
|
309
|
+
entitlement={state?.entitlement}
|
|
310
|
+
creditMeter={state?.credit_meter}
|
|
311
|
+
/>
|
|
312
|
+
{!notReady &&
|
|
313
|
+
(error ? (
|
|
314
|
+
<Alert title="Couldn't open billing" variant="danger">
|
|
315
|
+
<Text>{error}</Text>
|
|
316
|
+
</Alert>
|
|
317
|
+
) : (
|
|
318
|
+
<LoadingButton
|
|
319
|
+
href={portalUrl ? { url: portalUrl, external: true } : undefined}
|
|
320
|
+
loading={loading}
|
|
321
|
+
disabled={!portalUrl}
|
|
322
|
+
variant="primary"
|
|
323
|
+
>
|
|
324
|
+
{portalUrl ? "Manage subscription" : "Preparing billing…"}
|
|
325
|
+
</LoadingButton>
|
|
326
|
+
))}
|
|
327
|
+
<Text format={{ fontStyle: "italic" }}>
|
|
328
|
+
Change or cancel your plan in Stripe — billing is managed across all
|
|
329
|
+
Growth Operations apps.
|
|
330
|
+
</Text>
|
|
331
|
+
</Flex>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// FREE tier: show the upgrade path (PlanGrid → first-paid Checkout). PlanGrid
|
|
336
|
+
// renders nothing if no plans are mirrored yet, so fall back to a portal CTA
|
|
337
|
+
// then so the tab isn't empty.
|
|
338
|
+
const hasPlans = (state?.plans?.length ?? 0) > 0;
|
|
301
339
|
return (
|
|
302
340
|
<Flex direction="column" gap="medium">
|
|
303
341
|
<CreditMeter
|
|
304
342
|
entitlement={state?.entitlement}
|
|
305
343
|
creditMeter={state?.credit_meter}
|
|
306
344
|
/>
|
|
307
|
-
|
|
308
|
-
{/* The dynamically-rendered upgrade path (tiers from the Stripe catalog).
|
|
309
|
-
Renders nothing if no plans are mirrored yet. */}
|
|
310
345
|
<PlanGrid context={context} state={state} appKey={appKey} />
|
|
311
|
-
|
|
312
|
-
{/* Manage-billing link: relevant once the customer is on a paid plan
|
|
313
|
-
(change/cancel/invoices in Stripe). Hidden in the not-ready
|
|
314
|
-
(un-provisioned) state. When no plans are mirrored AND not on a paid
|
|
315
|
-
plan, fall back to the portal as the only CTA so the tab isn't empty. */}
|
|
316
|
-
{!notReady && (onPaidPlan || !hasPlans) &&
|
|
346
|
+
{!notReady && !hasPlans &&
|
|
317
347
|
(error ? (
|
|
318
348
|
<Alert title="Couldn't open billing" variant="danger">
|
|
319
349
|
<Text>{error}</Text>
|
|
@@ -323,19 +353,11 @@ function CreditsBilling({ context, state, appKey }) {
|
|
|
323
353
|
href={portalUrl ? { url: portalUrl, external: true } : undefined}
|
|
324
354
|
loading={loading}
|
|
325
355
|
disabled={!portalUrl}
|
|
326
|
-
variant=
|
|
356
|
+
variant="primary"
|
|
327
357
|
>
|
|
328
|
-
{portalUrl
|
|
329
|
-
? onPaidPlan
|
|
330
|
-
? "Manage billing in Stripe"
|
|
331
|
-
: "Buy credits in Stripe"
|
|
332
|
-
: "Preparing billing…"}
|
|
358
|
+
{portalUrl ? "Buy credits in Stripe" : "Preparing billing…"}
|
|
333
359
|
</LoadingButton>
|
|
334
360
|
))}
|
|
335
|
-
|
|
336
|
-
{/* Footer note only when PlanGrid isn't rendering one (PlanGrid owns the
|
|
337
|
-
canonical "managed in Stripe" line when plans are present — avoid
|
|
338
|
-
showing it twice). */}
|
|
339
361
|
{!hasPlans && (
|
|
340
362
|
<Text format={{ fontStyle: "italic" }}>
|
|
341
363
|
Billing is managed in Stripe across all Growth Operations apps.
|
package/src/billing/PlanGrid.jsx
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import React, { useState } from "react";
|
|
2
2
|
import {
|
|
3
|
-
hubspot,
|
|
4
3
|
Flex,
|
|
5
4
|
AutoGrid,
|
|
6
5
|
Tile,
|
|
7
6
|
Text,
|
|
8
7
|
Heading,
|
|
9
8
|
StatusTag,
|
|
10
|
-
LoadingButton,
|
|
11
9
|
Button,
|
|
12
|
-
Link,
|
|
13
|
-
Alert,
|
|
14
10
|
ToggleGroup,
|
|
15
11
|
} from "@hubspot/ui-extensions";
|
|
16
12
|
import { fmtMoney } from "../lib/format";
|
|
@@ -41,9 +37,10 @@ import { fmtMoney } from "../lib/format";
|
|
|
41
37
|
function PlanCard({
|
|
42
38
|
plan,
|
|
43
39
|
interval,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
billingBaseUrl,
|
|
41
|
+
appKey,
|
|
42
|
+
portalId,
|
|
43
|
+
returnUrl,
|
|
47
44
|
supportUrl,
|
|
48
45
|
maxFeatures = 0,
|
|
49
46
|
}) {
|
|
@@ -56,6 +53,18 @@ function PlanCard({
|
|
|
56
53
|
? `${fmtMoney(leg.unit_amount, leg.currency)}${periodLabel}`
|
|
57
54
|
: "Free";
|
|
58
55
|
|
|
56
|
+
// Direct one-click checkout link to the billing service's redirect endpoint.
|
|
57
|
+
// Null until we have everything (billing host, appKey, portal, this interval's
|
|
58
|
+
// price) — the button is disabled until then.
|
|
59
|
+
const startUrl =
|
|
60
|
+
billingBaseUrl && appKey && portalId && leg?.price_id
|
|
61
|
+
? `${billingBaseUrl}/v1/billing/checkout/start` +
|
|
62
|
+
`?app_key=${encodeURIComponent(appKey)}` +
|
|
63
|
+
`&portal_id=${encodeURIComponent(portalId)}` +
|
|
64
|
+
`&price_id=${encodeURIComponent(leg.price_id)}` +
|
|
65
|
+
`&return_url=${encodeURIComponent(returnUrl ?? "")}`
|
|
66
|
+
: null;
|
|
67
|
+
|
|
59
68
|
const features = plan.features ?? [];
|
|
60
69
|
// Align the CTA buttons across cards. HubSpot's column Flex does NOT stretch
|
|
61
70
|
// to the Tile height (AutoGrid equalizes the card height but flex={1}/justify
|
|
@@ -107,18 +116,19 @@ function PlanCard({
|
|
|
107
116
|
<Text format={{ fontStyle: "italic" }}>
|
|
108
117
|
Not available {interval === "annual" ? "annually" : "monthly"}
|
|
109
118
|
</Text>
|
|
110
|
-
) : checkoutUrl ? (
|
|
111
|
-
<Button href={{ url: checkoutUrl, external: true }} variant="primary">
|
|
112
|
-
Continue to checkout
|
|
113
|
-
</Button>
|
|
114
119
|
) : (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
120
|
+
// One-click: the button is a DIRECT external link to the billing
|
|
121
|
+
// service's GET /checkout/start, which creates the Stripe session
|
|
122
|
+
// server-side (in the opened tab) and 302s to Stripe. No in-iframe
|
|
123
|
+
// pre-create fetch — avoids the old two-click flow + the 15s iframe
|
|
124
|
+
// timeout. startUrl is null until billing_base_url is known.
|
|
125
|
+
<Button
|
|
126
|
+
href={startUrl ? { url: startUrl, external: true } : undefined}
|
|
127
|
+
disabled={!startUrl}
|
|
118
128
|
variant="primary"
|
|
119
129
|
>
|
|
120
130
|
Choose {plan.name ?? plan.tier}
|
|
121
|
-
</
|
|
131
|
+
</Button>
|
|
122
132
|
)}
|
|
123
133
|
</Flex>
|
|
124
134
|
</Tile>
|
|
@@ -132,15 +142,17 @@ export function PlanGrid({ context, state, appKey }) {
|
|
|
132
142
|
const annualAvailable =
|
|
133
143
|
paid.length > 0 && paid.every((p) => p.annual);
|
|
134
144
|
const [interval, setInterval] = useState(annualAvailable ? "annual" : "monthly");
|
|
135
|
-
const [choosingTier, setChoosingTier] = useState(null);
|
|
136
|
-
// Pre-created checkout URL keyed by `${tier}:${interval}` so a card only shows
|
|
137
|
-
// "Continue to checkout" for the exact price the customer picked.
|
|
138
|
-
const [checkoutUrls, setCheckoutUrls] = useState({});
|
|
139
|
-
const [error, setError] = useState(null);
|
|
140
145
|
|
|
141
146
|
if (plans.length === 0) return null;
|
|
142
147
|
|
|
143
148
|
const supportUrl = state?.helpful_links?.supportUrl ?? null;
|
|
149
|
+
const billingBaseUrl = state?.billing_base_url ?? null;
|
|
150
|
+
const portalId = context?.portal?.id;
|
|
151
|
+
// Where Stripe sends the customer back after pay/cancel — the app's Billing
|
|
152
|
+
// tab. The card builds the one-click /checkout/start link from these.
|
|
153
|
+
const returnUrl = state?.app_id
|
|
154
|
+
? `https://app.hubspot.com/app/${portalId}/${state.app_id}/billing`
|
|
155
|
+
: "https://app.hubspot.com/";
|
|
144
156
|
// Max feature count across cards — each card pads to this so all cards share
|
|
145
157
|
// content height and the CTAs align (see PlanCard comment).
|
|
146
158
|
const maxFeatures = plans.reduce(
|
|
@@ -148,49 +160,6 @@ export function PlanGrid({ context, state, appKey }) {
|
|
|
148
160
|
0
|
|
149
161
|
);
|
|
150
162
|
|
|
151
|
-
async function choose(plan, leg) {
|
|
152
|
-
setError(null);
|
|
153
|
-
setChoosingTier(plan.tier);
|
|
154
|
-
try {
|
|
155
|
-
const base = state?.billing_base_url;
|
|
156
|
-
if (!base) throw new Error("Billing service not configured");
|
|
157
|
-
if (!appKey) throw new Error("Missing appKey for checkout");
|
|
158
|
-
const portalId = context?.portal?.id;
|
|
159
|
-
|
|
160
|
-
const returnUrl = state?.app_id
|
|
161
|
-
? `https://app.hubspot.com/app/${portalId}/${state.app_id}/billing`
|
|
162
|
-
: "https://app.hubspot.com/";
|
|
163
|
-
|
|
164
|
-
// hubspot.fetch: body is a plain OBJECT (not a JSON string); only
|
|
165
|
-
// Authorization survives as a header — don't set Content-Type.
|
|
166
|
-
// presentation:"hosted" — a UI extension runs in a sandboxed iframe and
|
|
167
|
-
// can't mount Stripe's EMBEDDED checkout (no Stripe.js, nested-iframe 3DS
|
|
168
|
-
// fails), so request the HOSTED page and open its URL externally. The
|
|
169
|
-
// embedded default returns a client_secret (no url) → "No checkout URL".
|
|
170
|
-
const res = await hubspot.fetch(`${base}/v1/billing/checkout`, {
|
|
171
|
-
method: "POST",
|
|
172
|
-
body: {
|
|
173
|
-
app_key: appKey,
|
|
174
|
-
portal_id: String(portalId),
|
|
175
|
-
price_id: leg.price_id,
|
|
176
|
-
presentation: "hosted",
|
|
177
|
-
return_url: returnUrl,
|
|
178
|
-
},
|
|
179
|
-
});
|
|
180
|
-
if (!res.ok) {
|
|
181
|
-
const text = await res.text();
|
|
182
|
-
throw new Error(`Checkout request failed (${res.status}): ${text}`);
|
|
183
|
-
}
|
|
184
|
-
const { url } = await res.json();
|
|
185
|
-
if (!url) throw new Error("No checkout URL returned");
|
|
186
|
-
setCheckoutUrls((prev) => ({ ...prev, [`${plan.tier}:${interval}`]: url }));
|
|
187
|
-
} catch (err) {
|
|
188
|
-
setError(String(err));
|
|
189
|
-
} finally {
|
|
190
|
-
setChoosingTier(null);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
163
|
return (
|
|
195
164
|
<Flex direction="column" gap="medium">
|
|
196
165
|
<Flex direction="row" gap="medium" align="center" justify="start">
|
|
@@ -210,12 +179,6 @@ export function PlanGrid({ context, state, appKey }) {
|
|
|
210
179
|
)}
|
|
211
180
|
</Flex>
|
|
212
181
|
|
|
213
|
-
{error && (
|
|
214
|
-
<Alert title="Couldn't start checkout" variant="danger">
|
|
215
|
-
<Text>{error}</Text>
|
|
216
|
-
</Alert>
|
|
217
|
-
)}
|
|
218
|
-
|
|
219
182
|
{/* AutoGrid(flexible): equal-width columns that wrap responsively. Card
|
|
220
183
|
height is equalized via the maxFeatures padding in PlanCard. */}
|
|
221
184
|
<AutoGrid columnWidth={240} flexible={true} gap="medium">
|
|
@@ -224,9 +187,10 @@ export function PlanGrid({ context, state, appKey }) {
|
|
|
224
187
|
key={plan.tier}
|
|
225
188
|
plan={plan}
|
|
226
189
|
interval={interval}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
190
|
+
billingBaseUrl={billingBaseUrl}
|
|
191
|
+
appKey={appKey}
|
|
192
|
+
portalId={portalId}
|
|
193
|
+
returnUrl={returnUrl}
|
|
230
194
|
supportUrl={supportUrl}
|
|
231
195
|
maxFeatures={maxFeatures}
|
|
232
196
|
/>
|