@growth-operations/ui-shared 1.8.0 → 1.10.0

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.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -1,4 +1,4 @@
1
- import React from "react";
1
+ import React, { useEffect, useState } from "react";
2
2
  import {
3
3
  Flex,
4
4
  Heading,
@@ -11,6 +11,46 @@ import {
11
11
  import { fmtDate, daysUntil } from "../lib/format";
12
12
  import { CreditMeter } from "../home/CreditMeter";
13
13
  import { PlanGrid } from "./PlanGrid";
14
+ import { refreshBillingActionTokens } from "../sdk/billing";
15
+
16
+ // Billing action tokens (see common.billing.action_token) are signed with a
17
+ // 5-minute TTL — short enough that a customer who opens the tab and comes
18
+ // back later hits an expired Stripe link. Refresh at a fixed interval well
19
+ // under that TTL so a normally-paced re-render always has a token with
20
+ // several minutes of life left; if a refresh call is slow or fails, the
21
+ // still-live token already in state keeps working until the next tick.
22
+ const TOKEN_REFRESH_INTERVAL_MS = 3 * 60 * 1000;
23
+
24
+ // Re-mints state.billing_action_tokens on an interval so long-open tabs never
25
+ // hand the customer an expired Stripe link. Returns the latest known token
26
+ // set — the initial props value until the first successful refresh, then
27
+ // whatever the service last minted. Silently keeps the previous tokens on a
28
+ // failed refresh (e.g. transient network blip); the tokens simply expire on
29
+ // their own schedule as before this loop existed, which is a strict
30
+ // improvement (some refreshes succeeding beats none).
31
+ function useRefreshedBillingActionTokens(billingBaseUrl, initialTokens) {
32
+ const [tokens, setTokens] = useState(initialTokens ?? null);
33
+
34
+ useEffect(() => {
35
+ setTokens(initialTokens ?? null);
36
+ }, [initialTokens]);
37
+
38
+ useEffect(() => {
39
+ const portalToken = initialTokens?.portal;
40
+ if (!billingBaseUrl || !portalToken) return undefined;
41
+
42
+ const id = setInterval(() => {
43
+ refreshBillingActionTokens(billingBaseUrl, portalToken)
44
+ .then((fresh) => setTokens(fresh))
45
+ .catch(() => {});
46
+ }, TOKEN_REFRESH_INTERVAL_MS);
47
+
48
+ return () => clearInterval(id);
49
+ // eslint-disable-next-line react-hooks/exhaustive-deps
50
+ }, [billingBaseUrl, initialTokens?.portal]);
51
+
52
+ return tokens;
53
+ }
14
54
 
15
55
  // The shared Billing tab for all Growth Operations apps. Branches on the
16
56
  // entitlement union (CONTRACT.md):
@@ -412,6 +452,20 @@ export function BillingTab({ context, state, appKey, openIframe = null }) {
412
452
  const mode = state?.entitlement?.mode;
413
453
  const isSuperAdmin = state?.user?.is_super_admin === true;
414
454
 
455
+ // Keep the billing action tokens (portal/checkout/upgrade Stripe links)
456
+ // fresh for as long as this tab stays open. Overriding billing_action_tokens
457
+ // on a copy of `state` means every descendant (TrialSubscriptionBilling,
458
+ // CreditsBilling, PlanGrid) picks up refreshed tokens through the same
459
+ // `state?.billing_action_tokens` reads they already had — no prop threading.
460
+ const refreshedTokens = useRefreshedBillingActionTokens(
461
+ state?.billing_base_url,
462
+ state?.billing_action_tokens
463
+ );
464
+ const billingState =
465
+ refreshedTokens && refreshedTokens !== state?.billing_action_tokens
466
+ ? { ...state, billing_action_tokens: refreshedTokens }
467
+ : state;
468
+
415
469
  return (
416
470
  <Flex direction="column" gap="medium">
417
471
  <Heading>Billing</Heading>
@@ -420,13 +474,13 @@ export function BillingTab({ context, state, appKey, openIframe = null }) {
420
474
  <Text>Billing settings are only available to super admins.</Text>
421
475
  </Alert>
422
476
  ) : mode === "legacy" ? (
423
- <LegacyBilling state={state} />
477
+ <LegacyBilling state={billingState} />
424
478
  ) : mode === "credits" ? (
425
- <CreditsBilling context={context} state={state} appKey={appKey} openIframe={openIframe} />
479
+ <CreditsBilling context={context} state={billingState} appKey={appKey} openIframe={openIframe} />
426
480
  ) : mode === "comped" ? (
427
481
  <CompedBilling />
428
482
  ) : (
429
- <TrialSubscriptionBilling context={context} state={state} appKey={appKey} openIframe={openIframe} />
483
+ <TrialSubscriptionBilling context={context} state={billingState} appKey={appKey} openIframe={openIframe} />
430
484
  )}
431
485
  </Flex>
432
486
  );
@@ -118,15 +118,24 @@ function PlanCard({
118
118
  const leg = interval === "annual" ? plan.annual : plan.monthly;
119
119
  const periodLabel = interval === "annual" ? "/yr" : "/mo";
120
120
 
121
+ // Annual is billed as one yearly charge, but SaaS pricing convention shows
122
+ // it as an equivalent MONTHLY rate up front (the full annual total reads as
123
+ // sticker shock) — the actual "$X/yr" total moves to a smaller line below.
124
+ const monthlyEquivalent =
125
+ interval === "annual" && leg ? Math.round(leg.unit_amount / 12) : null;
126
+
121
127
  const priceText = plan.talk_to_sales
122
128
  ? "Custom"
123
129
  : leg
124
- ? `${fmtMoney(leg.unit_amount, leg.currency)}${periodLabel}`
130
+ ? monthlyEquivalent != null
131
+ ? `${fmtMoney(monthlyEquivalent, leg.currency)}/mo`
132
+ : `${fmtMoney(leg.unit_amount, leg.currency)}${periodLabel}`
125
133
  : "Free";
126
134
 
127
135
  // Annual savings vs. paying monthly for a year. Only when this card is showing
128
- // annual AND has both legs to compare. annualFull = the struck-through "12×
129
- // monthly" reference price; pct = the highlighted discount.
136
+ // annual AND has both legs to compare. annualFull = the un-discounted "12×
137
+ // monthly" reference used only to compute savingsPct — never rendered as a
138
+ // dollar figure itself (see the "no full-year price shown" note above).
130
139
  const showAnnualSavings =
131
140
  interval === "annual" && hasMonthly && hasAnnual && plan.annual?.unit_amount;
132
141
  const annualFull = showAnnualSavings ? plan.monthly.unit_amount * 12 : null;
@@ -185,10 +194,16 @@ function PlanCard({
185
194
  <StatusTag variant="success">Save {savingsPct}%</StatusTag>
186
195
  )}
187
196
  </Flex>
188
- {/* Struck-through "12× monthly" reference so the annual discount reads. */}
189
- {savingsPct > 0 && (
190
- <Text format={{ lineDecoration: "strikethrough", fontStyle: "italic" }}>
191
- {fmtMoney(annualFull, plan.annual.currency)}/yr
197
+ {/* Annual is billed as one yearly charge — show the real total in a
198
+ lighter line under the monthly-equivalent headline price, instead
199
+ of a struck-through full-year dollar figure. Deliberately no
200
+ "$X/yr" reference price shown as a big number anywhere on this
201
+ card — only the small parenthetical and the Save % badge above.
202
+ variant "microcopy" is the SDK's de-emphasized text style (no
203
+ fontColor option exists on Text). */}
204
+ {monthlyEquivalent != null && (
205
+ <Text variant="microcopy">
206
+ (or {fmtMoney(leg.unit_amount, leg.currency)}/yr)
192
207
  </Text>
193
208
  )}
194
209
 
package/src/index.js CHANGED
@@ -91,4 +91,6 @@ export {
91
91
  TokenProvider,
92
92
  useToken,
93
93
  TokenContext,
94
+ // Billing service
95
+ refreshBillingActionTokens,
94
96
  } from "./sdk/index";
@@ -0,0 +1,28 @@
1
+ // Billing-service SDK — direct calls to growth-ops-apps-app's billing
2
+ // endpoints (NOT the calling app's own backend, NOT api.hubapi.com). Every
3
+ // consuming app already allowlists ${BILLING_BASE_URL} under
4
+ // permittedUrls.fetch (it's also used for the Stripe-redirect <a href> links
5
+ // and the embedded-checkout iframe host), so hubspot.fetch can reach it
6
+ // directly with no new hsmeta changes.
7
+ //
8
+ // Auth here is the signed `token` query param (see
9
+ // common.billing.action_token) — NOT an Authorization header — so this is a
10
+ // bare hubspot.fetch, not callHubSpotApi (which always attaches a bearer
11
+ // token these endpoints don't expect).
12
+ import { hubspot, logger } from "@hubspot/ui-extensions";
13
+
14
+ // Re-mint a fresh { portal, checkout, upgrade } token set from a still-valid
15
+ // one. See billing.py's /v1/billing/refresh-tokens: it verifies `token`
16
+ // (whichever action it authorizes — BillingTab always holds/passes `portal`)
17
+ // and returns a brand-new set signed for the same app_key/portal_id. Throws
18
+ // on a 401 (token already expired) — the caller's next /v1/home load is the
19
+ // recovery path for that, same as before this refresh loop existed.
20
+ export async function refreshBillingActionTokens(billingBaseUrl, token) {
21
+ const url = `${billingBaseUrl}/v1/billing/refresh-tokens?token=${encodeURIComponent(token)}`;
22
+ const response = await hubspot.fetch(url, { timeout: 10000, method: "GET" });
23
+ if (!response.ok) {
24
+ logger.warn(`[Billing] refresh-tokens failed: ${response.status}`);
25
+ throw new Error(`refresh-tokens failed with status ${response.status}`);
26
+ }
27
+ return response.json();
28
+ }
package/src/sdk/index.js CHANGED
@@ -32,3 +32,6 @@ export {
32
32
  // Token lifecycle.
33
33
  export { getShortLivedToken, isTokenExpired } from "./token/getShortLivedToken";
34
34
  export { TokenProvider, useToken, TokenContext } from "./token/TokenContext";
35
+
36
+ // Billing service (growth-ops-apps-app), NOT the calling app's own backend.
37
+ export { refreshBillingActionTokens } from "./billing";