@growth-operations/ui-shared 1.5.7 → 1.5.9

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.7",
3
+ "version": "1.5.9",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -140,9 +140,10 @@ function StatusPanel({ entitlement }) {
140
140
  );
141
141
  }
142
142
 
143
- // Pre-creates a Stripe billing/checkout portal session on mount and returns
144
- // { portalUrl, loading, error }. Shared by both arms — the credits arm reuses
145
- // the same portal endpoint for v1 (see note in CreditsBilling below).
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).
146
147
  function usePortalSession({ context, state, appKey }) {
147
148
  const [loading, setLoading] = useState(true);
148
149
  const [error, setError] = useState(null);
@@ -169,18 +170,11 @@ function usePortalSession({ context, state, appKey }) {
169
170
  resetOnUnmount();
170
171
  return;
171
172
  }
172
- // No Stripe customer yet → don't even try to open a portal. An install
173
- // that hasn't been provisioned has nothing to manage; show the calm "not
174
- // ready" state. The credits arm has NO `status` field (only the
175
- // not_installed shell does) but DOES carry `plan` once paid — treat a set
176
- // `plan` as ready so a paid credit customer gets the Manage button. Only
177
- // the trial arm relies on `status`.
178
- const ent = state?.entitlement;
179
- const status = ent?.status;
180
- const onPaidPlan = !!ent?.plan;
181
- const notProvisioned =
182
- !ent || status === "not_installed" || (status == null && !onPaidPlan);
183
- if (notProvisioned) {
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") {
184
178
  if (mounted.current) {
185
179
  setNotReady(true);
186
180
  setLoading(false);
@@ -293,16 +287,28 @@ function TrialSubscriptionBilling({ context, state, appKey }) {
293
287
  }
294
288
 
295
289
  function CreditsBilling({ context, state, appKey }) {
296
- // The Stripe portal session lets an EXISTING paid customer manage/switch/cancel
297
- // their plan, with proration handled by Stripe.
298
- const { portalUrl, loading, error, notReady } = usePortalSession({
299
- context,
300
- state,
301
- appKey,
302
- });
303
-
304
290
  const onPaidPlan = !!state?.entitlement?.plan;
305
291
 
292
+ // Direct external link to the billing service's GET /v1/billing/portal/start,
293
+ // which resolves the customer + creates the Customer Portal session server-side
294
+ // and 303s to Stripe — IN THE OPENED TAB. No in-iframe portal-session fetch
295
+ // (that round trip hit the billing service's cold-start/CPU stall and surfaced
296
+ // as the iframe's 15s "Gateway took too long" error). Same pattern as
297
+ // PlanGrid's /checkout/start "Choose" button. null (button disabled) until
298
+ // billing_base_url is known.
299
+ const base = state?.billing_base_url ?? null;
300
+ const portalId = context?.portal?.id;
301
+ const returnUrl = state?.app_id
302
+ ? `https://app.hubspot.com/app/${portalId}/${state.app_id}/billing`
303
+ : "https://app.hubspot.com/";
304
+ const portalStartUrl =
305
+ base && appKey && portalId
306
+ ? `${base}/v1/billing/portal/start` +
307
+ `?app_key=${encodeURIComponent(appKey)}` +
308
+ `&portal_id=${encodeURIComponent(String(portalId))}` +
309
+ `&return_url=${encodeURIComponent(returnUrl)}`
310
+ : null;
311
+
306
312
  // PAID: do NOT show the plan picker. The picker's "Choose" starts a NEW Stripe
307
313
  // Checkout subscription — clicking another tier would create a SECOND
308
314
  // subscription (double-bill), not switch in place. Plan changes (upgrade/
@@ -316,21 +322,15 @@ function CreditsBilling({ context, state, appKey }) {
316
322
  entitlement={state?.entitlement}
317
323
  creditMeter={state?.credit_meter}
318
324
  />
319
- {!notReady &&
320
- (error ? (
321
- <Alert title="Couldn't open billing" variant="danger">
322
- <Text>{error}</Text>
323
- </Alert>
324
- ) : (
325
- <LoadingButton
326
- href={portalUrl ? { url: portalUrl, external: true } : undefined}
327
- loading={loading}
328
- disabled={!portalUrl}
329
- variant="primary"
330
- >
331
- {portalUrl ? "Manage subscription" : "Preparing billing…"}
332
- </LoadingButton>
333
- ))}
325
+ <LoadingButton
326
+ href={
327
+ portalStartUrl ? { url: portalStartUrl, external: true } : undefined
328
+ }
329
+ disabled={!portalStartUrl}
330
+ variant="primary"
331
+ >
332
+ {portalStartUrl ? "Manage subscription" : "Preparing billing…"}
333
+ </LoadingButton>
334
334
  <Text format={{ fontStyle: "italic" }}>
335
335
  Change or cancel your plan in Stripe — billing is managed across all
336
336
  Growth Operations apps.
@@ -350,21 +350,17 @@ function CreditsBilling({ context, state, appKey }) {
350
350
  creditMeter={state?.credit_meter}
351
351
  />
352
352
  <PlanGrid context={context} state={state} appKey={appKey} />
353
- {!notReady && !hasPlans &&
354
- (error ? (
355
- <Alert title="Couldn't open billing" variant="danger">
356
- <Text>{error}</Text>
357
- </Alert>
358
- ) : (
359
- <LoadingButton
360
- href={portalUrl ? { url: portalUrl, external: true } : undefined}
361
- loading={loading}
362
- disabled={!portalUrl}
363
- variant="primary"
364
- >
365
- {portalUrl ? "Buy credits in Stripe" : "Preparing billing…"}
366
- </LoadingButton>
367
- ))}
353
+ {!hasPlans && (
354
+ <LoadingButton
355
+ href={
356
+ portalStartUrl ? { url: portalStartUrl, external: true } : undefined
357
+ }
358
+ disabled={!portalStartUrl}
359
+ variant="primary"
360
+ >
361
+ {portalStartUrl ? "Buy credits in Stripe" : "Preparing billing…"}
362
+ </LoadingButton>
363
+ )}
368
364
  {!hasPlans && (
369
365
  <Text format={{ fontStyle: "italic" }}>
370
366
  Billing is managed in Stripe across all Growth Operations apps.
@@ -34,9 +34,14 @@ import { fmtMoney } from "../lib/format";
34
34
  // feature bullets, and a CTA: "Current" (disabled) for the active tier,
35
35
  // "Contact sales" for talk_to_sales tiers, else "Choose" which pre-creates a
36
36
  // checkout session for that tier's price in the selected interval.
37
+ //
38
+ // The Monthly/Annual toggle is PER CARD (not a single grid-wide switch): a tier
39
+ // may offer one interval and not the other (e.g. a top tier that's monthly-only),
40
+ // so each card owns its own interval state and only shows the toggle when it has
41
+ // BOTH legs. A subscription still bills on one cadence — the cadence bought is
42
+ // whatever this card's toggle shows when its "Choose" is clicked.
37
43
  function PlanCard({
38
44
  plan,
39
- interval,
40
45
  billingBaseUrl,
41
46
  appKey,
42
47
  portalId,
@@ -44,6 +49,12 @@ function PlanCard({
44
49
  supportUrl,
45
50
  maxFeatures = 0,
46
51
  }) {
52
+ // Default this card to annual when it offers annual (cheaper-per-month story);
53
+ // monthly-only tiers default to monthly. Both legs → show the toggle.
54
+ const hasMonthly = !!plan.monthly;
55
+ const hasAnnual = !!plan.annual;
56
+ const canToggle = hasMonthly && hasAnnual;
57
+ const [interval, setInterval] = useState(hasAnnual ? "annual" : "monthly");
47
58
  const leg = interval === "annual" ? plan.annual : plan.monthly;
48
59
  const periodLabel = interval === "annual" ? "/yr" : "/mo";
49
60
 
@@ -53,6 +64,17 @@ function PlanCard({
53
64
  ? `${fmtMoney(leg.unit_amount, leg.currency)}${periodLabel}`
54
65
  : "Free";
55
66
 
67
+ // Annual savings vs. paying monthly for a year. Only when this card is showing
68
+ // annual AND has both legs to compare. annualFull = the struck-through "12×
69
+ // monthly" reference price; pct = the highlighted discount.
70
+ const showAnnualSavings =
71
+ interval === "annual" && hasMonthly && hasAnnual && plan.annual?.unit_amount;
72
+ const annualFull = showAnnualSavings ? plan.monthly.unit_amount * 12 : null;
73
+ const savingsPct =
74
+ showAnnualSavings && annualFull > plan.annual.unit_amount
75
+ ? Math.round((1 - plan.annual.unit_amount / annualFull) * 100)
76
+ : 0;
77
+
56
78
  // Direct one-click checkout link to the billing service's redirect endpoint.
57
79
  // Null until we have everything (billing host, appKey, portal, this interval's
58
80
  // price) — the button is disabled until then.
@@ -85,7 +107,33 @@ function PlanCard({
85
107
  )}
86
108
  </Flex>
87
109
 
88
- <Text format={{ fontWeight: "bold", fontSize: "lg" }}>{priceText}</Text>
110
+ <Flex direction="row" gap="small" align="center">
111
+ <Text format={{ fontWeight: "bold", fontSize: "lg" }}>{priceText}</Text>
112
+ {savingsPct > 0 && (
113
+ <StatusTag variant="success">Save {savingsPct}%</StatusTag>
114
+ )}
115
+ </Flex>
116
+ {/* Struck-through "12× monthly" reference so the annual discount reads. */}
117
+ {savingsPct > 0 && (
118
+ <Text format={{ lineDecoration: "strikethrough", fontStyle: "italic" }}>
119
+ {fmtMoney(annualFull, plan.annual.currency)}/yr
120
+ </Text>
121
+ )}
122
+
123
+ {/* Per-card interval toggle — only when this tier offers both legs. */}
124
+ {canToggle && (
125
+ <ToggleGroup
126
+ name={`billing-interval-${plan.tier}`}
127
+ toggleType="radioButtonList"
128
+ inline={true}
129
+ value={interval}
130
+ onChange={(v) => v && setInterval(v)}
131
+ options={[
132
+ { label: "Monthly", value: "monthly" },
133
+ { label: "Annual", value: "annual" },
134
+ ]}
135
+ />
136
+ )}
89
137
 
90
138
  {plan.credits_per_period != null && (
91
139
  <Text>{plan.credits_per_period.toLocaleString()} credits / month</Text>
@@ -137,11 +185,6 @@ function PlanCard({
137
185
 
138
186
  export function PlanGrid({ context, state, appKey }) {
139
187
  const plans = state?.plans ?? [];
140
- // Default to annual only if EVERY paid plan offers it; else monthly.
141
- const paid = plans.filter((p) => !p.talk_to_sales && (p.monthly || p.annual));
142
- const annualAvailable =
143
- paid.length > 0 && paid.every((p) => p.annual);
144
- const [interval, setInterval] = useState(annualAvailable ? "annual" : "monthly");
145
188
 
146
189
  if (plans.length === 0) return null;
147
190
 
@@ -162,31 +205,16 @@ export function PlanGrid({ context, state, appKey }) {
162
205
 
163
206
  return (
164
207
  <Flex direction="column" gap="medium">
165
- <Flex direction="row" gap="medium" align="center" justify="start">
166
- <Heading>Plans</Heading>
167
- {annualAvailable && (
168
- <ToggleGroup
169
- name="billing-interval"
170
- toggleType="radioButtonList"
171
- inline={true}
172
- value={interval}
173
- onChange={(v) => v && setInterval(v)}
174
- options={[
175
- { label: "Monthly", value: "monthly" },
176
- { label: "Annual", value: "annual" },
177
- ]}
178
- />
179
- )}
180
- </Flex>
208
+ <Heading>Plans</Heading>
181
209
 
182
210
  {/* AutoGrid(flexible): equal-width columns that wrap responsively. Card
183
- height is equalized via the maxFeatures padding in PlanCard. */}
211
+ height is equalized via the maxFeatures padding in PlanCard. Each card
212
+ owns its own Monthly/Annual toggle (see PlanCard). */}
184
213
  <AutoGrid columnWidth={240} flexible={true} gap="medium">
185
214
  {plans.map((plan) => (
186
215
  <PlanCard
187
216
  key={plan.tier}
188
217
  plan={plan}
189
- interval={interval}
190
218
  billingBaseUrl={billingBaseUrl}
191
219
  appKey={appKey}
192
220
  portalId={portalId}