@growth-operations/ui-shared 1.6.1 → 1.6.2
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 +34 -118
package/package.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import React
|
|
1
|
+
import React from "react";
|
|
2
2
|
import {
|
|
3
|
-
hubspot,
|
|
4
3
|
Flex,
|
|
5
4
|
Heading,
|
|
6
5
|
Text,
|
|
@@ -9,7 +8,6 @@ import {
|
|
|
9
8
|
Button,
|
|
10
9
|
Alert,
|
|
11
10
|
} from "@hubspot/ui-extensions";
|
|
12
|
-
import { useStrictModeEffect } from "../lib/useStrictModeEffect";
|
|
13
11
|
import { fmtDate, daysUntil } from "../lib/format";
|
|
14
12
|
import { CreditMeter } from "../home/CreditMeter";
|
|
15
13
|
import { PlanGrid } from "./PlanGrid";
|
|
@@ -140,106 +138,14 @@ function StatusPanel({ entitlement }) {
|
|
|
140
138
|
);
|
|
141
139
|
}
|
|
142
140
|
|
|
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).
|
|
147
|
-
function usePortalSession({ context, state, appKey }) {
|
|
148
|
-
const [loading, setLoading] = useState(true);
|
|
149
|
-
const [error, setError] = useState(null);
|
|
150
|
-
const [portalUrl, setPortalUrl] = useState(null);
|
|
151
|
-
// "Not ready" = the install hasn't been provisioned with a Stripe customer
|
|
152
|
-
// yet (fresh/old account: no entitlement.status, or the portal endpoint 409s
|
|
153
|
-
// with "no Stripe customer"). That's an expected pre-purchase state, not an
|
|
154
|
-
// error — the UI shows a calm "finish setup" message instead of a red alert.
|
|
155
|
-
const [notReady, setNotReady] = useState(false);
|
|
156
|
-
|
|
157
|
-
// Pre-create on mount so the link is one real click (Stripe can't be iframed;
|
|
158
|
-
// UI Extensions have no open-URL action — only Link, whose href must exist
|
|
159
|
-
// before render).
|
|
160
|
-
useStrictModeEffect(
|
|
161
|
-
async ({ mounted, resetOnUnmount }) => {
|
|
162
|
-
// `state` is the /v1/home payload, fetched async by the host page — it's
|
|
163
|
-
// null on the first render(s). Don't mistake "not loaded yet" for
|
|
164
|
-
// "misconfigured": stay in loading until state arrives, then decide.
|
|
165
|
-
// IMPORTANT: useStrictModeEffect latches an internal fetchedRef on any
|
|
166
|
-
// normal completion, so a bare `return` here would block the re-run when
|
|
167
|
-
// state later arrives (→ spinner forever). resetOnUnmount() clears that
|
|
168
|
-
// ref so the deps-change re-run (state?.billing_base_url) actually fires.
|
|
169
|
-
if (!state) {
|
|
170
|
-
resetOnUnmount();
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
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") {
|
|
178
|
-
if (mounted.current) {
|
|
179
|
-
setNotReady(true);
|
|
180
|
-
setLoading(false);
|
|
181
|
-
}
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
try {
|
|
185
|
-
const base = state.billing_base_url;
|
|
186
|
-
if (!base) throw new Error("Billing service not configured");
|
|
187
|
-
if (!appKey) throw new Error("Missing appKey for billing portal");
|
|
188
|
-
const portalId = context?.portal?.id;
|
|
189
|
-
|
|
190
|
-
// Return to THIS app's Billing page after Stripe (named app-page deep
|
|
191
|
-
// link /app/{portalId}/{appId}/{pagePath}); fall back to HubSpot home.
|
|
192
|
-
const returnUrl = state?.app_id
|
|
193
|
-
? `https://app.hubspot.com/app/${portalId}/${state.app_id}/billing`
|
|
194
|
-
: "https://app.hubspot.com/";
|
|
195
|
-
|
|
196
|
-
// hubspot.fetch: body must be a plain OBJECT (HubSpot serializes it),
|
|
197
|
-
// NOT a JSON string, and only Authorization survives as a header —
|
|
198
|
-
// do not set Content-Type or any other header.
|
|
199
|
-
const res = await hubspot.fetch(`${base}/v1/billing/portal`, {
|
|
200
|
-
method: "POST",
|
|
201
|
-
body: {
|
|
202
|
-
app_key: appKey,
|
|
203
|
-
portal_id: String(portalId),
|
|
204
|
-
return_url: returnUrl,
|
|
205
|
-
},
|
|
206
|
-
});
|
|
207
|
-
if (!res.ok) {
|
|
208
|
-
const text = await res.text();
|
|
209
|
-
// 409 "no Stripe customer yet" is the not-provisioned case, not a
|
|
210
|
-
// failure — surface it as the calm not-ready state.
|
|
211
|
-
if (res.status === 409 && /no stripe customer/i.test(text)) {
|
|
212
|
-
if (mounted.current) setNotReady(true);
|
|
213
|
-
} else {
|
|
214
|
-
throw new Error(`Billing portal request failed (${res.status}): ${text}`);
|
|
215
|
-
}
|
|
216
|
-
} else {
|
|
217
|
-
const { url } = await res.json();
|
|
218
|
-
if (!url) throw new Error("No billing portal URL returned");
|
|
219
|
-
if (mounted.current) setPortalUrl(url);
|
|
220
|
-
}
|
|
221
|
-
} catch (perr) {
|
|
222
|
-
if (mounted.current) setError(String(perr));
|
|
223
|
-
} finally {
|
|
224
|
-
if (mounted.current) setLoading(false);
|
|
225
|
-
}
|
|
226
|
-
},
|
|
227
|
-
[context, appKey, state?.billing_base_url, state?.app_id, state?.entitlement?.status]
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
return { portalUrl, loading, error, notReady };
|
|
231
|
-
}
|
|
232
|
-
|
|
233
141
|
function TrialSubscriptionBilling({ context, state, appKey }) {
|
|
234
|
-
const
|
|
235
|
-
context,
|
|
236
|
-
state,
|
|
237
|
-
appKey,
|
|
238
|
-
});
|
|
142
|
+
const ent = state?.entitlement;
|
|
239
143
|
|
|
240
144
|
// Install not provisioned with a Stripe customer yet — calm "finish setup"
|
|
241
|
-
// state, not a red error. (Old/stale installs, or before
|
|
242
|
-
//
|
|
145
|
+
// state, not a red error. (Old/stale installs, or before provision_trial_install
|
|
146
|
+
// has run.) The trial arm has a `status`; not_installed / missing means
|
|
147
|
+
// un-provisioned.
|
|
148
|
+
const notReady = !ent || ent.status == null || ent.status === "not_installed";
|
|
243
149
|
if (notReady) {
|
|
244
150
|
return (
|
|
245
151
|
<Flex direction="column" gap="medium">
|
|
@@ -255,26 +161,36 @@ function TrialSubscriptionBilling({ context, state, appKey }) {
|
|
|
255
161
|
);
|
|
256
162
|
}
|
|
257
163
|
|
|
164
|
+
// Direct external link to GET /v1/billing/portal/start — resolves the customer
|
|
165
|
+
// + creates the Customer Portal session server-side and 303s to Stripe IN THE
|
|
166
|
+
// OPENED TAB. No in-iframe portal-session fetch (that round trip hit the billing
|
|
167
|
+
// service's cold-start/CPU stall → the iframe's 15s "Gateway took too long").
|
|
168
|
+
// Same pattern as the credits arm + PlanGrid's /checkout/start. null (button
|
|
169
|
+
// disabled) until billing_base_url is known.
|
|
170
|
+
const base = state?.billing_base_url ?? null;
|
|
171
|
+
const portalId = context?.portal?.id;
|
|
172
|
+
const returnUrl = state?.app_id
|
|
173
|
+
? `https://app.hubspot.com/app/${portalId}/${state.app_id}/billing`
|
|
174
|
+
: "https://app.hubspot.com/";
|
|
175
|
+
const portalStartUrl =
|
|
176
|
+
base && appKey && portalId
|
|
177
|
+
? `${base}/v1/billing/portal/start` +
|
|
178
|
+
`?app_key=${encodeURIComponent(appKey)}` +
|
|
179
|
+
`&portal_id=${encodeURIComponent(String(portalId))}` +
|
|
180
|
+
`&return_url=${encodeURIComponent(returnUrl)}`
|
|
181
|
+
: null;
|
|
182
|
+
|
|
258
183
|
return (
|
|
259
184
|
<Flex direction="column" gap="medium">
|
|
260
|
-
{/* PRIMARY CTA at the
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
<LoadingButton
|
|
270
|
-
href={portalUrl ? { url: portalUrl, external: true } : undefined}
|
|
271
|
-
loading={loading}
|
|
272
|
-
disabled={!portalUrl}
|
|
273
|
-
variant="primary"
|
|
274
|
-
>
|
|
275
|
-
{portalUrl ? "Manage billing in Stripe" : "Preparing billing…"}
|
|
276
|
-
</LoadingButton>
|
|
277
|
-
)}
|
|
185
|
+
{/* PRIMARY CTA at the top. Direct external link to the billing-service
|
|
186
|
+
redirect (opens a new tab); no in-iframe pre-create fetch. */}
|
|
187
|
+
<LoadingButton
|
|
188
|
+
href={portalStartUrl ? { url: portalStartUrl, external: true } : undefined}
|
|
189
|
+
disabled={!portalStartUrl}
|
|
190
|
+
variant="primary"
|
|
191
|
+
>
|
|
192
|
+
{portalStartUrl ? "Manage billing in Stripe" : "Preparing billing…"}
|
|
193
|
+
</LoadingButton>
|
|
278
194
|
|
|
279
195
|
<StatusPanel entitlement={state?.entitlement} />
|
|
280
196
|
|