@growth-operations/ui-shared 1.6.1 → 1.6.3

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.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -1,6 +1,5 @@
1
- import React, { useState } from "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 { portalUrl, loading, error, notReady } = usePortalSession({
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 the install flow's
242
- // provision_trial_install has run.)
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 very top, large and obvious (per billing feedback).
261
- A Button with href navigates to the portal (Button supports href +
262
- external) so it reads as a real button, not a text link; LoadingButton
263
- shows the prepared/loading state inline. */}
264
- {error ? (
265
- <Alert title="Couldn't open billing" variant="danger">
266
- <Text>{error}</Text>
267
- </Alert>
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
 
@@ -1,5 +1,5 @@
1
1
  import React, { useState } from "react";
2
- import { Select, LoadingSpinner, Alert, Flex, Text } from "@hubspot/ui-extensions";
2
+ import { Select, LoadingSpinner, Alert, Flex, Text, Link } from "@hubspot/ui-extensions";
3
3
  import { useToken } from "../sdk/index";
4
4
  import { getForms } from "../sdk/hubspot/forms";
5
5
  import { getLists } from "../sdk/hubspot/lists";
@@ -55,6 +55,12 @@ export function OptionSelect({
55
55
  label = "Select",
56
56
  description,
57
57
  placeholder = "Choose…",
58
+ // Optional action links rendered beside the dropdown (e.g. "Create new list",
59
+ // "Edit form") — the affordances the per-app FormSelector/SegmentSelector used
60
+ // to have. Each: { label, url }. Rendered as external links (new tab). When a
61
+ // url is null/absent the link is skipped, so callers can pass an "Edit" action
62
+ // gated on a selected value.
63
+ actions = [],
58
64
  // Extra source args (pipelineId/objectType for pipelineStages, query for lists)
59
65
  ...opts
60
66
  }) {
@@ -100,6 +106,8 @@ export function OptionSelect({
100
106
  );
101
107
  }
102
108
 
109
+ const actionLinks = (actions || []).filter((a) => a && a.url);
110
+
103
111
  return (
104
112
  <Flex direction="column" gap="extra-small">
105
113
  <Select
@@ -110,6 +118,15 @@ export function OptionSelect({
110
118
  value={value ?? ""}
111
119
  onChange={(v) => onChange?.(v)}
112
120
  />
121
+ {actionLinks.length > 0 && (
122
+ <Flex direction="row" gap="small">
123
+ {actionLinks.map((a) => (
124
+ <Link key={a.label} href={{ url: a.url, external: true }}>
125
+ {a.label}
126
+ </Link>
127
+ ))}
128
+ </Flex>
129
+ )}
113
130
  </Flex>
114
131
  );
115
132
  }