@growth-operations/ui-shared 1.6.14 → 1.6.16

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.14",
3
+ "version": "1.6.16",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -117,10 +117,17 @@ function StatusPanel({ entitlement }) {
117
117
  }
118
118
 
119
119
  if (status === "pending_purchase") {
120
+ // No active subscription — has_payment_method describes a CARD on file
121
+ // (from a prior/lapsed sub), not whether one's usable here: there's
122
+ // nothing for it to auto-charge. So unlike `trialing` (where a card on
123
+ // file means "nothing to do"), every pending_purchase account needs the
124
+ // SAME next step — pick a plan below — the copy just says so plainly
125
+ // rather than repeating the stale "add a payment method" ask when a
126
+ // card may already exist.
120
127
  const resumeCta =
121
128
  mode === "credits"
122
- ? "Choose a plan in Stripe to resume."
123
- : "Add a payment method in Stripe to resume syncing your data.";
129
+ ? "Choose a plan below to resume."
130
+ : "Choose a plan below to resume syncing your data.";
124
131
  return (
125
132
  <Alert variant="warning">
126
133
  <Flex direction="column" gap="extra-small">
@@ -131,6 +138,21 @@ function StatusPanel({ entitlement }) {
131
138
  );
132
139
  }
133
140
 
141
+ if (status === "canceled") {
142
+ const resumeCta =
143
+ mode === "credits"
144
+ ? "Choose a plan below to start a new subscription."
145
+ : "Choose a plan below to start a new subscription and resume syncing your data.";
146
+ return (
147
+ <Alert variant="warning">
148
+ <Flex direction="column" gap="extra-small">
149
+ <Text format={{ fontWeight: "bold" }}>Your subscription was canceled</Text>
150
+ <Text>{resumeCta}</Text>
151
+ </Flex>
152
+ </Alert>
153
+ );
154
+ }
155
+
134
156
  return (
135
157
  <Tile>
136
158
  <Text format={{ fontWeight: "bold" }}>Status: {status ?? "Unknown"}</Text>
@@ -166,45 +188,63 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
166
188
  // OPENED TAB. No in-iframe portal-session fetch (that round trip hit the billing
167
189
  // service's cold-start/CPU stall → the iframe's 15s "Gateway took too long").
168
190
  // Same pattern as the credits arm + PlanGrid's /checkout/start. null (button
169
- // disabled) until billing_base_url is known.
191
+ // disabled) until billing_base_url + the signed action token are both known.
192
+ // The endpoint no longer accepts a bare app_key/portal_id (that was an
193
+ // unauthenticated portal-hijack vector) — it requires the `token` the app's
194
+ // own /v1/home minted with its client_secret (state.billing_action_tokens).
170
195
  const base = state?.billing_base_url ?? null;
171
196
  const portalId = context?.portal?.id;
197
+ const portalToken = state?.billing_action_tokens?.portal ?? null;
172
198
  const returnUrl = state?.app_id
173
199
  ? `https://app.hubspot.com/app/${portalId}/${state.app_id}/billing`
174
200
  : "https://app.hubspot.com/";
175
201
  const portalStartUrl =
176
- base && appKey && portalId
202
+ base && portalToken
177
203
  ? `${base}/v1/billing/portal/start` +
178
- `?app_key=${encodeURIComponent(appKey)}` +
179
- `&portal_id=${encodeURIComponent(String(portalId))}` +
204
+ `?token=${encodeURIComponent(portalToken)}` +
180
205
  `&return_url=${encodeURIComponent(returnUrl)}`
181
206
  : null;
182
207
 
183
- // Tier picker — shown ONLY while trialing (the lifecycle: trial starts on the
184
- // entry tier at install; while trialing the customer may upgrade to a higher
185
- // tier in-app; once active, plan changes go through support). We show the FULL
186
- // ladder for context so the customer sees where they sit:
187
- // current tier -> "Current plan" (disabled marker; PlanCard keys off
188
- // plan.current, stamped server-side on the account's tier).
189
- // higher tiers -> the actionable upgrade (CTA targets /v1/billing/upgrade/
190
- // start, swapping the trialing sub's item — no new sub).
191
- // lower tiers -> shown disabled with a Talk-to-sales CTA (downgrades aren't
192
- // self-serve). PlanCard derives this from currentOrder.
208
+ // Tier picker — every status needs ONE of two distinct modes, never both:
209
+ // trialing -> UPGRADE mode: a trialing sub already
210
+ // exists, so a higher-tier click swaps its item in place
211
+ // (/v1/billing/upgrade/start) rather than starting a second
212
+ // subscription. Lower tiers show disabled (Talk-to-sales; downgrades
213
+ // aren't self-serve). currentOrder is REQUIRED here so PlanCard can
214
+ // tell upgrade from downgrade.
215
+ // pending_purchase / canceled -> CHECKOUT mode: there is NO active
216
+ // subscription to swap — every tier is a genuinely fresh purchase
217
+ // (/v1/billing/checkout/start, PlanGrid's default). currentOrder is
218
+ // intentionally omitted (undefined) so no tier reads as a downgrade;
219
+ // with nothing active, every tier is just a plan to start.
220
+ // Never both at once: a customer either has a subscription to adjust
221
+ // (upgrade) or doesn't (checkout) — showing the upgrade endpoint with no
222
+ // real subscription behind it would 404/error at click time.
193
223
  const isTrialing = ent.status === "trialing";
224
+ const needsCheckout =
225
+ ent.status === "pending_purchase" || ent.status === "canceled";
194
226
  const plans = state?.plans ?? [];
195
- const currentOrder = plans.find((p) => p.current)?.tier_order;
196
- // Render the picker only while trialing AND once we know the current tier
197
- // (currentOrder drives upgrade-vs-downgrade per card).
198
- const showPicker = isTrialing && currentOrder != null && plans.length > 0;
227
+ const currentOrder = isTrialing
228
+ ? plans.find((p) => p.current)?.tier_order
229
+ : undefined;
230
+ // Trialing: only once we know the current tier (currentOrder drives
231
+ // upgrade-vs-downgrade per card). Checkout: as soon as there's a catalog
232
+ // to pick from — there's no "current tier" gate to wait on.
233
+ const showPicker =
234
+ (isTrialing && currentOrder != null && plans.length > 0) ||
235
+ (needsCheckout && plans.length > 0);
199
236
 
200
237
  return (
201
238
  <Flex direction="column" gap="medium">
202
- {/* PRIMARY CTA at the top. Direct external link to the billing-service
203
- redirect (opens a new tab); no in-iframe pre-create fetch. */}
239
+ {/* PRIMARY CTA when there's an existing subscription to manage
240
+ (trialing/active/past_due). For pending_purchase/canceled there is
241
+ nothing for the Stripe Customer Portal to manage — the plan picker
242
+ below is the actual recovery path, so this becomes a secondary
243
+ link rather than the primary action. */}
204
244
  <LoadingButton
205
245
  href={portalStartUrl ? { url: portalStartUrl, external: true } : undefined}
206
246
  disabled={!portalStartUrl}
207
- variant="primary"
247
+ variant={needsCheckout ? "secondary" : "primary"}
208
248
  >
209
249
  {portalStartUrl ? "Manage billing in Stripe" : "Preparing billing…"}
210
250
  </LoadingButton>
@@ -212,15 +252,18 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
212
252
  <StatusPanel entitlement={state?.entitlement} />
213
253
 
214
254
  <Text format={{ fontStyle: "italic" }}>
215
- Billing is managed in Stripe across all Growth Operations apps. Use the
216
- link above to update your plan, payment method, or view invoices.
255
+ {needsCheckout
256
+ ? "Choose a plan below to resume. Once you have an active subscription, use the link above to manage payment methods or view invoices."
257
+ : "Billing is managed in Stripe across all Growth Operations apps. Use the link above to update your plan, payment method, or view invoices."}
217
258
  </Text>
218
259
 
219
- {/* Trial-only tier ladder: current (marked), higher (upgradeable), lower
220
- (disabled, Talk-to-sales). Hidden once active. Upgrading swaps the
221
- trialing sub onto the higher tier, keeping the trial end date. The full
222
- `plans` list is passed; PlanCard decides each card's state from
223
- plan.current + currentOrder. */}
260
+ {/* Trialing: current (marked), higher (upgradeable), lower (disabled,
261
+ Talk-to-sales) — upgrading swaps the trialing sub onto the higher
262
+ tier, keeping the trial end date. pending_purchase/canceled: every
263
+ tier plain-actionable (fresh checkout, PlanGrid's default props/
264
+ endpoint) — no currentOrder means PlanCard never marks a downgrade.
265
+ The full `plans` list is passed either way; PlanCard decides each
266
+ card's state from plan.current + currentOrder. */}
224
267
  {showPicker && (
225
268
  <PlanGrid
226
269
  context={context}
@@ -228,10 +271,14 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
228
271
  appKey={appKey}
229
272
  plans={plans}
230
273
  currentOrder={currentOrder}
231
- endpoint="upgrade/start"
232
- ctaLabel="Upgrade to"
233
- heading="Your plan"
234
- footnote="Upgrade any time during your trial — your trial end date stays the same, and the new tier applies when it converts. To move to a lower tier, talk to sales."
274
+ endpoint={needsCheckout ? "checkout/start" : "upgrade/start"}
275
+ ctaLabel={needsCheckout ? "Choose" : "Upgrade to"}
276
+ heading={needsCheckout ? "Choose a plan to resume" : "Your plan"}
277
+ footnote={
278
+ needsCheckout
279
+ ? "Choose a plan to start a new subscription and resume syncing your data."
280
+ : "Upgrade any time during your trial — your trial end date stays the same, and the new tier applies when it converts. To move to a lower tier, talk to sales."
281
+ }
235
282
  openIframe={openIframe}
236
283
  />
237
284
  )}
@@ -248,17 +295,18 @@ function CreditsBilling({ context, state, appKey, openIframe = null }) {
248
295
  // (that round trip hit the billing service's cold-start/CPU stall and surfaced
249
296
  // as the iframe's 15s "Gateway took too long" error). Same pattern as
250
297
  // PlanGrid's /checkout/start "Choose" button. null (button disabled) until
251
- // billing_base_url is known.
298
+ // billing_base_url + the signed action token are both known — the endpoint no
299
+ // longer accepts a bare app_key/portal_id (state.billing_action_tokens).
252
300
  const base = state?.billing_base_url ?? null;
253
301
  const portalId = context?.portal?.id;
302
+ const portalToken = state?.billing_action_tokens?.portal ?? null;
254
303
  const returnUrl = state?.app_id
255
304
  ? `https://app.hubspot.com/app/${portalId}/${state.app_id}/billing`
256
305
  : "https://app.hubspot.com/";
257
306
  const portalStartUrl =
258
- base && appKey && portalId
307
+ base && portalToken
259
308
  ? `${base}/v1/billing/portal/start` +
260
- `?app_key=${encodeURIComponent(appKey)}` +
261
- `&portal_id=${encodeURIComponent(String(portalId))}` +
309
+ `?token=${encodeURIComponent(portalToken)}` +
262
310
  `&return_url=${encodeURIComponent(returnUrl)}`
263
311
  : null;
264
312
 
@@ -49,8 +49,7 @@ import { fmtMoney } from "../lib/format";
49
49
  function PlanCard({
50
50
  plan,
51
51
  billingBaseUrl,
52
- appKey,
53
- portalId,
52
+ billingActionTokens,
54
53
  returnUrl,
55
54
  supportUrl,
56
55
  maxFeatures = 0,
@@ -137,19 +136,25 @@ function PlanCard({
137
136
  : 0;
138
137
 
139
138
  // Direct one-click link to the billing service's redirect endpoint. Null
140
- // until we have everything (billing host, appKey, portal, this interval's
141
- // price) — the button is disabled until then. The current card's in-place
142
- // interval switch is ALWAYS upgrade/start (swap the existing sub's item),
143
- // regardless of what `endpoint` this grid instance was given — it can never
144
- // be a first-time checkout/start, since plan.current + a real
145
- // current_interval only happens with an existing subscription already in
146
- // place. Getting this wrong would double-bill (a second Checkout session).
139
+ // until we have everything (billing host, a signed token for the target
140
+ // action, this interval's price) — the button is disabled until then. The
141
+ // current card's in-place interval switch is ALWAYS upgrade/start (swap the
142
+ // existing sub's item), regardless of what `endpoint` this grid instance was
143
+ // given — it can never be a first-time checkout/start, since plan.current +
144
+ // a real current_interval only happens with an existing subscription already
145
+ // in place. Getting this wrong would double-bill (a second Checkout session).
146
+ // The endpoint no longer accepts a bare app_key/portal_id (unauthenticated
147
+ // portal-hijack vector) — it requires the `token` the app's own /v1/home
148
+ // minted with its client_secret (billingActionTokens), keyed by ACTION
149
+ // ("checkout" or "upgrade") since a single card's target action can differ
150
+ // from the grid's default via the interval-switch case above.
147
151
  const startEndpoint = isIntervalSwitch ? "upgrade/start" : endpoint;
152
+ const startAction = startEndpoint === "upgrade/start" ? "upgrade" : "checkout";
153
+ const startToken = billingActionTokens?.[startAction] ?? null;
148
154
  const startUrl =
149
- billingBaseUrl && appKey && portalId && leg?.price_id
155
+ billingBaseUrl && startToken && leg?.price_id
150
156
  ? `${billingBaseUrl}/v1/billing/${startEndpoint}` +
151
- `?app_key=${encodeURIComponent(appKey)}` +
152
- `&portal_id=${encodeURIComponent(portalId)}` +
157
+ `?token=${encodeURIComponent(startToken)}` +
153
158
  `&price_id=${encodeURIComponent(leg.price_id)}` +
154
159
  `&return_url=${encodeURIComponent(returnUrl ?? "")}`
155
160
  : null;
@@ -322,6 +327,7 @@ export function PlanGrid({
322
327
 
323
328
  const supportUrl = state?.helpful_links?.supportUrl ?? null;
324
329
  const billingBaseUrl = state?.billing_base_url ?? null;
330
+ const billingActionTokens = state?.billing_action_tokens ?? null;
325
331
  const portalId = context?.portal?.id;
326
332
  // Where Stripe sends the customer back after pay/cancel — the app's Billing
327
333
  // tab. The card builds the one-click /checkout/start link from these.
@@ -348,8 +354,7 @@ export function PlanGrid({
348
354
  key={plan.tier}
349
355
  plan={plan}
350
356
  billingBaseUrl={billingBaseUrl}
351
- appKey={appKey}
352
- portalId={portalId}
357
+ billingActionTokens={billingActionTokens}
353
358
  returnUrl={returnUrl}
354
359
  supportUrl={supportUrl}
355
360
  maxFeatures={maxFeatures}
@@ -2,24 +2,38 @@
2
2
  //
3
3
  // The one sanctioned path to a sandbox install (a customer can't self-install on
4
4
  // a sandbox from the marketplace — the backend rejects that). From a prod
5
- // portal, this asks the app's mint endpoint for a signed sandbox install link
6
- // (which carries sandbox=true + the prod customer to link), then surfaces it for
7
- // the user to open on their sandbox account.
5
+ // portal, this first GETs the account's existing linked sandboxes + limit, then
6
+ // — only when still under the limit — mints a signed sandbox install link
7
+ // (carrying sandbox=true + the prod customer to link) THE MOMENT THE COMPONENT
8
+ // MOUNTS, so the link is a single, ordinary click — open it on the sandbox
9
+ // account you want to install on, no separate "generate" step first. HubSpot's
10
+ // Link/Button href must be a static value at render time (can't be set from an
11
+ // async result in the same click), so minting on mount rather than on click is
12
+ // what makes one-click possible at all.
13
+ //
14
+ // Always shows what's already linked (previously this rendered the mint action
15
+ // unconditionally with no signal a sandbox already existed, and a customer
16
+ // could keep minting more with zero visibility). At the limit, shows the
17
+ // linked list + a "X of Y used" indicator with no mint action underneath —
18
+ // not a disabled/dead button, just nothing to click.
8
19
  //
9
20
  // Extensible per app: the caller passes the mint `path` on its OWN backend
10
21
  // (base-hosted apps -> /v1/hubspot/app_pages/... no; the install mint lives at
11
22
  // /v2/hubspot/install/{app}/sandbox-link on base; self-hosted apps serve their
12
- // own), plus the portalId. callAppApi targets context.variables.BASE_URL, so
13
- // each app's BASE_URL routes to the right service. Apps not allowlisted get a
14
- // 403 from the endpoint, surfaced as a calm message.
15
- import React, { useState } from "react";
16
- import { Flex, Heading, Text, Button, Link, Alert } from "@hubspot/ui-extensions";
23
+ // own), plus the portalId. The SAME path is used for both the GET (status) and
24
+ // POST (mint) — the backend distinguishes by HTTP method. callAppApi targets
25
+ // context.variables.BASE_URL, so each app's BASE_URL routes to the right
26
+ // service. Apps not allowlisted get a 403 from the endpoint, surfaced as a
27
+ // calm message.
28
+ import React, { useEffect, useState } from "react";
29
+ import { Flex, Heading, Text, Link, Alert, StatusTag } from "@hubspot/ui-extensions";
17
30
 
18
31
  import { callAppApi, AppApiError } from "../sdk/app/base";
19
32
 
20
- // context: extension context. path: the mint endpoint on the app's backend
21
- // (e.g. `/v2/hubspot/install/${appKey}/sandbox-link`). portalId: the prod portal
22
- // minting the link. appName: copy. heading/description: optional overrides.
33
+ // context: extension context. path: the status+mint endpoint on the app's
34
+ // backend (e.g. `/v2/hubspot/install/${appKey}/sandbox-link`). portalId: the
35
+ // prod portal minting the link. appName: copy. heading/description: optional
36
+ // overrides.
23
37
  export function SandboxInstall({
24
38
  context,
25
39
  path,
@@ -28,34 +42,67 @@ export function SandboxInstall({
28
42
  heading = "Install on a sandbox",
29
43
  description,
30
44
  }) {
45
+ const [linkedSandboxes, setLinkedSandboxes] = useState([]);
46
+ const [limit, setLimit] = useState(1);
31
47
  const [installUrl, setInstallUrl] = useState(null);
32
- const [loading, setLoading] = useState(false);
48
+ const [loading, setLoading] = useState(true);
33
49
  const [error, setError] = useState(null);
34
50
 
35
- const mint = async () => {
36
- setLoading(true);
37
- setError(null);
38
- try {
39
- const pid = portalId ?? context.portal.id;
40
- const sep = path.includes("?") ? "&" : "?";
41
- const res = await callAppApi(context, `${path}${sep}portalId=${pid}`, "POST");
42
- setInstallUrl(res?.install_url ?? null);
43
- } catch (err) {
44
- // The backend 403s apps that don't allow sandbox installs — show its
45
- // message rather than a generic failure.
46
- setError(err instanceof AppApiError ? err.message : String(err));
47
- } finally {
48
- setLoading(false);
49
- }
50
- };
51
+ useEffect(() => {
52
+ let cancelled = false;
53
+ (async () => {
54
+ try {
55
+ const pid = portalId ?? context.portal.id;
56
+ const sep = path.includes("?") ? "&" : "?";
57
+ const qs = `${path}${sep}portalId=${pid}`;
58
+ const status = await callAppApi(context, qs, "GET");
59
+ if (cancelled) return;
60
+ const linked = status?.linked_sandboxes ?? [];
61
+ const cap = status?.limit ?? 1;
62
+ setLinkedSandboxes(linked);
63
+ setLimit(cap);
64
+ // Only mint (and offer) a NEW link while still under the limit — at
65
+ // the limit, the linked list above is the whole story; there's
66
+ // nothing else to click.
67
+ if (linked.length < cap) {
68
+ const res = await callAppApi(context, qs, "POST");
69
+ if (!cancelled) setInstallUrl(res?.install_url ?? null);
70
+ }
71
+ } catch (err) {
72
+ // The backend 403s apps that don't allow sandbox installs — show its
73
+ // message rather than a generic failure.
74
+ if (!cancelled) {
75
+ setError(err instanceof AppApiError ? err.message : String(err));
76
+ }
77
+ } finally {
78
+ if (!cancelled) setLoading(false);
79
+ }
80
+ })();
81
+ return () => {
82
+ cancelled = true;
83
+ };
84
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ }, [path, portalId]);
86
+
87
+ const atLimit = linkedSandboxes.length >= limit;
51
88
 
52
89
  return (
53
90
  <Flex direction="column" gap="small">
54
- <Heading>{heading}</Heading>
91
+ <Flex direction="row" gap="small" align="center">
92
+ <Heading>{heading}</Heading>
93
+ {!loading && !error && (
94
+ // At the limit is the NORMAL end state for the common case (limit
95
+ // defaults to 1) — reaching it isn't a problem, so this reads
96
+ // success/neutral either way, never a warning color.
97
+ <StatusTag variant="success">
98
+ {linkedSandboxes.length} of {limit} used
99
+ </StatusTag>
100
+ )}
101
+ </Flex>
55
102
  <Text>
56
103
  {description ??
57
104
  `Spin up ${appName} on a HubSpot sandbox account, linked to your billing. ` +
58
- `Generate a link, then open it on the sandbox account you want to install on.`}
105
+ `Open the link below on the sandbox account you want to install on.`}
59
106
  </Text>
60
107
 
61
108
  {error && (
@@ -64,26 +111,36 @@ export function SandboxInstall({
64
111
  </Alert>
65
112
  )}
66
113
 
67
- {installUrl ? (
114
+ {linkedSandboxes.length > 0 && (
115
+ <Flex direction="column" gap="extra-small">
116
+ <Text format={{ fontWeight: "bold" }}>Already linked</Text>
117
+ {linkedSandboxes.map((s) => (
118
+ <Text key={s.portal_id}>
119
+ {s.name || s.portal_id} — {s.status}
120
+ </Text>
121
+ ))}
122
+ </Flex>
123
+ )}
124
+
125
+ {installUrl && (
68
126
  <Flex direction="column" gap="extra-small">
69
127
  <Link href={{ url: installUrl, external: true }}>
70
- Open the sandbox install →
128
+ Install on a sandbox →
71
129
  </Link>
72
130
  <Text variant="microcopy">
73
- Clicking this link opens HubSpot's install screen for the
74
- sandbox account you choose — you don't need to switch accounts
75
- first.
131
+ Opens HubSpot's install screen for the sandbox account you
132
+ choose — you don't need to switch accounts first.
76
133
  </Text>
77
134
  </Flex>
78
- ) : (
79
- <Button
80
- variant="secondary"
81
- disabled={loading}
82
- onClick={mint}
83
- >
84
- {loading ? "Generating…" : "Generate sandbox install link"}
85
- </Button>
86
135
  )}
136
+
137
+ {!loading && !error && atLimit && linkedSandboxes.length > 0 && (
138
+ <Text variant="microcopy">
139
+ Need another sandbox linked? Contact us.
140
+ </Text>
141
+ )}
142
+
143
+ {loading && !error && <Text variant="microcopy">Checking your sandbox installs…</Text>}
87
144
  </Flex>
88
145
  );
89
146
  }