@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 +1 -1
- package/src/billing/BillingTab.jsx +86 -38
- package/src/billing/PlanGrid.jsx +19 -14
- package/src/sandbox/SandboxInstall.jsx +100 -43
package/package.json
CHANGED
|
@@ -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
|
|
123
|
-
: "
|
|
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
|
|
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 &&
|
|
202
|
+
base && portalToken
|
|
177
203
|
? `${base}/v1/billing/portal/start` +
|
|
178
|
-
`?
|
|
179
|
-
`&portal_id=${encodeURIComponent(String(portalId))}` +
|
|
204
|
+
`?token=${encodeURIComponent(portalToken)}` +
|
|
180
205
|
`&return_url=${encodeURIComponent(returnUrl)}`
|
|
181
206
|
: null;
|
|
182
207
|
|
|
183
|
-
// Tier picker —
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
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 =
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
203
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
{/*
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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=
|
|
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
|
|
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 &&
|
|
307
|
+
base && portalToken
|
|
259
308
|
? `${base}/v1/billing/portal/start` +
|
|
260
|
-
`?
|
|
261
|
-
`&portal_id=${encodeURIComponent(String(portalId))}` +
|
|
309
|
+
`?token=${encodeURIComponent(portalToken)}` +
|
|
262
310
|
`&return_url=${encodeURIComponent(returnUrl)}`
|
|
263
311
|
: null;
|
|
264
312
|
|
package/src/billing/PlanGrid.jsx
CHANGED
|
@@ -49,8 +49,7 @@ import { fmtMoney } from "../lib/format";
|
|
|
49
49
|
function PlanCard({
|
|
50
50
|
plan,
|
|
51
51
|
billingBaseUrl,
|
|
52
|
-
|
|
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,
|
|
141
|
-
// price) — the button is disabled until then. The
|
|
142
|
-
// interval switch is ALWAYS upgrade/start (swap the
|
|
143
|
-
// regardless of what `endpoint` this grid instance was
|
|
144
|
-
// be a first-time checkout/start, since plan.current +
|
|
145
|
-
// current_interval only happens with an existing subscription already
|
|
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 &&
|
|
155
|
+
billingBaseUrl && startToken && leg?.price_id
|
|
150
156
|
? `${billingBaseUrl}/v1/billing/${startEndpoint}` +
|
|
151
|
-
`?
|
|
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
|
-
|
|
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
|
|
6
|
-
//
|
|
7
|
-
// the
|
|
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.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
21
|
-
// (e.g. `/v2/hubspot/install/${appKey}/sandbox-link`). portalId: the
|
|
22
|
-
// minting the link. appName: copy. heading/description: optional
|
|
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(
|
|
48
|
+
const [loading, setLoading] = useState(true);
|
|
33
49
|
const [error, setError] = useState(null);
|
|
34
50
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
<
|
|
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
|
-
`
|
|
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
|
-
{
|
|
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
|
-
|
|
128
|
+
Install on a sandbox →
|
|
71
129
|
</Link>
|
|
72
130
|
<Text variant="microcopy">
|
|
73
|
-
|
|
74
|
-
|
|
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
|
}
|