@growth-operations/ui-shared 1.5.4 → 1.5.5

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.5.4",
3
+ "version": "1.5.5",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -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
- onChoose,
45
- choosing,
46
- checkoutUrl,
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
- <LoadingButton
116
- loading={choosing}
117
- onClick={() => onChoose(plan, leg)}
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
- </LoadingButton>
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
- onChoose={choose}
228
- choosing={choosingTier === plan.tier}
229
- checkoutUrl={checkoutUrls[`${plan.tier}:${interval}`]}
190
+ billingBaseUrl={billingBaseUrl}
191
+ appKey={appKey}
192
+ portalId={portalId}
193
+ returnUrl={returnUrl}
230
194
  supportUrl={supportUrl}
231
195
  maxFeatures={maxFeatures}
232
196
  />