@growth-operations/ui-shared 1.0.10 → 1.1.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.0.10",
3
+ "version": "1.1.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -11,6 +11,7 @@ import {
11
11
  import { useStrictModeEffect } from "../lib/useStrictModeEffect";
12
12
  import { fmtDate, daysUntil } from "../lib/format";
13
13
  import { CreditMeter } from "../home/CreditMeter";
14
+ import { PlanGrid } from "./PlanGrid";
14
15
 
15
16
  // The shared Billing tab for all Growth Operations apps. Branches on the
16
17
  // entitlement union (CONTRACT.md):
@@ -284,11 +285,17 @@ function TrialSubscriptionBilling({ context, state, appKey }) {
284
285
  }
285
286
 
286
287
  function CreditsBilling({ context, state, appKey }) {
287
- // NOTE: the credit-pack / paid-allowance checkout backend is net-new and
288
- // pending. For v1 the "Pick a plan" / "Buy credits" CTA reuses the same
289
- // Stripe portal session pattern (routes to the portal); swap the endpoint to
290
- // the credit checkout once that backend lands.
291
- const { portalUrl, loading, error } = usePortalSession({ context, state, appKey });
288
+ // The Stripe portal session lets an EXISTING paid customer manage their plan
289
+ // / payment method / invoices (same pattern as the trial arm). NEW plan
290
+ // selection is the <PlanGrid> below, which pre-creates a per-tier checkout.
291
+ const { portalUrl, loading, error, notReady } = usePortalSession({
292
+ context,
293
+ state,
294
+ appKey,
295
+ });
296
+
297
+ const hasPlans = (state?.plans?.length ?? 0) > 0;
298
+ const onPaidPlan = !!state?.entitlement?.plan;
292
299
 
293
300
  return (
294
301
  <Flex direction="column" gap="medium">
@@ -297,26 +304,36 @@ function CreditsBilling({ context, state, appKey }) {
297
304
  creditMeter={state?.credit_meter}
298
305
  />
299
306
 
300
- {/* PRIMARY CTA at top: pick a plan / buy credits. Button-with-href so it
301
- reads as a real button; LoadingButton shows the prepared/loading state. */}
302
- {error ? (
303
- <Alert title="Couldn't open checkout" variant="danger">
304
- <Text>{error}</Text>
305
- </Alert>
306
- ) : (
307
- <LoadingButton
308
- href={portalUrl ? { url: portalUrl, external: true } : undefined}
309
- loading={loading}
310
- disabled={!portalUrl}
311
- variant="primary"
312
- >
313
- {portalUrl ? "Pick a plan or buy credits" : "Preparing checkout…"}
314
- </LoadingButton>
315
- )}
307
+ {/* The dynamically-rendered upgrade path (tiers from the Stripe catalog).
308
+ Renders nothing if no plans are mirrored yet. */}
309
+ <PlanGrid context={context} state={state} appKey={appKey} />
310
+
311
+ {/* Manage-billing link: relevant once the customer is on a paid plan
312
+ (change/cancel/invoices in Stripe). Hidden in the not-ready
313
+ (un-provisioned) state. When no plans are mirrored AND not on a paid
314
+ plan, fall back to the portal as the only CTA so the tab isn't empty. */}
315
+ {!notReady && (onPaidPlan || !hasPlans) &&
316
+ (error ? (
317
+ <Alert title="Couldn't open billing" variant="danger">
318
+ <Text>{error}</Text>
319
+ </Alert>
320
+ ) : (
321
+ <LoadingButton
322
+ href={portalUrl ? { url: portalUrl, external: true } : undefined}
323
+ loading={loading}
324
+ disabled={!portalUrl}
325
+ variant={onPaidPlan ? "secondary" : "primary"}
326
+ >
327
+ {portalUrl
328
+ ? onPaidPlan
329
+ ? "Manage billing in Stripe"
330
+ : "Buy credits in Stripe"
331
+ : "Preparing billing…"}
332
+ </LoadingButton>
333
+ ))}
316
334
 
317
335
  <Text format={{ fontStyle: "italic" }}>
318
- Top up or move to a paid monthly allowance to keep going. Billing is
319
- managed in Stripe across all Growth Operations apps.
336
+ Billing is managed in Stripe across all Growth Operations apps.
320
337
  </Text>
321
338
  </Flex>
322
339
  );
@@ -0,0 +1,208 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ hubspot,
4
+ Flex,
5
+ Tile,
6
+ Text,
7
+ Heading,
8
+ StatusTag,
9
+ LoadingButton,
10
+ Button,
11
+ Link,
12
+ Alert,
13
+ ToggleGroup,
14
+ } from "@hubspot/ui-extensions";
15
+ import { fmtMoney } from "../lib/format";
16
+
17
+ // The credit-app upgrade path: a dynamically-rendered grid of the pickable
18
+ // plans (tiers) a customer can move to, driven entirely by the backend `plans`
19
+ // block (CONTRACT.md → common.billing.catalog.get_plans). The shared package is
20
+ // presentational — it never hardcodes tiers/prices/features; everything comes
21
+ // from the Stripe catalog mirror via the payload.
22
+ //
23
+ // Per-tier checkout follows the §5.3 UI-extension gotchas: Stripe can't be
24
+ // iframed and there's no open-URL action — only <Link>/Button-with-href, whose
25
+ // href must exist before render. So a tier's "Choose" click POSTs to the
26
+ // billing service to PRE-CREATE a hosted checkout session for that price_id,
27
+ // then renders the returned URL as an external link the customer clicks. The
28
+ // extension can't read Stripe's redirect params (sandboxed iframe), so the
29
+ // post-payment credit grant is server-side (Stripe webhook → grant_credits).
30
+ //
31
+ // Props:
32
+ // context — UI-extension serverless context (context.portal.id).
33
+ // state — the /v1/home payload (plans[], billing_base_url, app_id).
34
+ // appKey — the billing app_key for THIS app (always a prop, never hardcoded).
35
+
36
+ // One plan card. Shows tier name, price for the selected interval, credits,
37
+ // feature bullets, and a CTA: "Current" (disabled) for the active tier,
38
+ // "Contact sales" for talk_to_sales tiers, else "Choose" which pre-creates a
39
+ // checkout session for that tier's price in the selected interval.
40
+ function PlanCard({ plan, interval, onChoose, choosing, checkoutUrl, supportUrl }) {
41
+ const leg = interval === "annual" ? plan.annual : plan.monthly;
42
+ const periodLabel = interval === "annual" ? "/yr" : "/mo";
43
+
44
+ const priceText = plan.talk_to_sales
45
+ ? "Custom"
46
+ : leg
47
+ ? `${fmtMoney(leg.unit_amount, leg.currency)}${periodLabel}`
48
+ : "Free";
49
+
50
+ return (
51
+ <Tile>
52
+ <Flex direction="column" gap="small">
53
+ <Flex direction="row" gap="small" align="center">
54
+ <Heading>{plan.name ?? plan.tier}</Heading>
55
+ {plan.current && <StatusTag variant="success">Current plan</StatusTag>}
56
+ </Flex>
57
+
58
+ <Text format={{ fontWeight: "bold", fontSize: "lg" }}>{priceText}</Text>
59
+
60
+ {plan.credits_per_period != null && (
61
+ <Text>
62
+ {plan.credits_per_period.toLocaleString()} credits / month
63
+ </Text>
64
+ )}
65
+
66
+ {(plan.features ?? []).map((f, i) => (
67
+ <Text key={i}>• {f}</Text>
68
+ ))}
69
+
70
+ {/* CTA. Current tier => disabled marker. talk_to_sales => contact link.
71
+ Otherwise pre-create-then-link checkout for the selected interval. */}
72
+ {plan.current ? (
73
+ <Button disabled>Current plan</Button>
74
+ ) : plan.talk_to_sales ? (
75
+ <Button
76
+ href={supportUrl ? { url: supportUrl, external: true } : undefined}
77
+ disabled={!supportUrl}
78
+ variant="secondary"
79
+ >
80
+ Contact sales
81
+ </Button>
82
+ ) : !leg ? (
83
+ // No price for the selected interval (e.g. annual not offered) — guide
84
+ // the customer to the other toggle rather than dead-ending.
85
+ <Text format={{ fontStyle: "italic" }}>
86
+ Not available {interval === "annual" ? "annually" : "monthly"}
87
+ </Text>
88
+ ) : checkoutUrl ? (
89
+ <Button href={{ url: checkoutUrl, external: true }} variant="primary">
90
+ Continue to checkout
91
+ </Button>
92
+ ) : (
93
+ <LoadingButton
94
+ loading={choosing}
95
+ onClick={() => onChoose(plan, leg)}
96
+ variant="primary"
97
+ >
98
+ Choose {plan.name ?? plan.tier}
99
+ </LoadingButton>
100
+ )}
101
+ </Flex>
102
+ </Tile>
103
+ );
104
+ }
105
+
106
+ export function PlanGrid({ context, state, appKey }) {
107
+ const plans = state?.plans ?? [];
108
+ // Default to annual only if EVERY paid plan offers it; else monthly.
109
+ const paid = plans.filter((p) => !p.talk_to_sales && (p.monthly || p.annual));
110
+ const annualAvailable =
111
+ paid.length > 0 && paid.every((p) => p.annual);
112
+ const [interval, setInterval] = useState(annualAvailable ? "annual" : "monthly");
113
+ const [choosingTier, setChoosingTier] = useState(null);
114
+ // Pre-created checkout URL keyed by `${tier}:${interval}` so a card only shows
115
+ // "Continue to checkout" for the exact price the customer picked.
116
+ const [checkoutUrls, setCheckoutUrls] = useState({});
117
+ const [error, setError] = useState(null);
118
+
119
+ if (plans.length === 0) return null;
120
+
121
+ const supportUrl = state?.helpful_links?.supportUrl ?? null;
122
+
123
+ async function choose(plan, leg) {
124
+ setError(null);
125
+ setChoosingTier(plan.tier);
126
+ try {
127
+ const base = state?.billing_base_url;
128
+ if (!base) throw new Error("Billing service not configured");
129
+ if (!appKey) throw new Error("Missing appKey for checkout");
130
+ const portalId = context?.portal?.id;
131
+
132
+ const returnUrl = state?.app_id
133
+ ? `https://app.hubspot.com/app/${portalId}/${state.app_id}/billing`
134
+ : "https://app.hubspot.com/";
135
+
136
+ // hubspot.fetch: body is a plain OBJECT (not a JSON string); only
137
+ // Authorization survives as a header — don't set Content-Type.
138
+ const res = await hubspot.fetch(`${base}/v1/billing/checkout`, {
139
+ method: "POST",
140
+ body: {
141
+ app_key: appKey,
142
+ portal_id: String(portalId),
143
+ price_id: leg.price_id,
144
+ return_url: returnUrl,
145
+ },
146
+ });
147
+ if (!res.ok) {
148
+ const text = await res.text();
149
+ throw new Error(`Checkout request failed (${res.status}): ${text}`);
150
+ }
151
+ const { url } = await res.json();
152
+ if (!url) throw new Error("No checkout URL returned");
153
+ setCheckoutUrls((prev) => ({ ...prev, [`${plan.tier}:${interval}`]: url }));
154
+ } catch (err) {
155
+ setError(String(err));
156
+ } finally {
157
+ setChoosingTier(null);
158
+ }
159
+ }
160
+
161
+ return (
162
+ <Flex direction="column" gap="medium">
163
+ <Flex direction="row" gap="medium" align="center" justify="start">
164
+ <Heading>Plans</Heading>
165
+ {annualAvailable && (
166
+ <ToggleGroup
167
+ name="billing-interval"
168
+ toggleType="radioButtonList"
169
+ inline={true}
170
+ value={interval}
171
+ onChange={(v) => v && setInterval(v)}
172
+ options={[
173
+ { label: "Monthly", value: "monthly" },
174
+ { label: "Annual", value: "annual" },
175
+ ]}
176
+ />
177
+ )}
178
+ </Flex>
179
+
180
+ {error && (
181
+ <Alert title="Couldn't start checkout" variant="danger">
182
+ <Text>{error}</Text>
183
+ </Alert>
184
+ )}
185
+
186
+ <Flex direction="row" gap="medium" wrap="wrap">
187
+ {plans.map((plan) => (
188
+ <PlanCard
189
+ key={plan.tier}
190
+ plan={plan}
191
+ interval={interval}
192
+ onChoose={choose}
193
+ choosing={choosingTier === plan.tier}
194
+ checkoutUrl={checkoutUrls[`${plan.tier}:${interval}`]}
195
+ supportUrl={supportUrl}
196
+ />
197
+ ))}
198
+ </Flex>
199
+
200
+ <Text format={{ fontStyle: "italic" }}>
201
+ Billing is managed in Stripe across all Growth Operations apps. Your new
202
+ credits are added as soon as payment completes.
203
+ </Text>
204
+ </Flex>
205
+ );
206
+ }
207
+
208
+ export default PlanGrid;
package/src/index.js CHANGED
@@ -16,10 +16,12 @@ export { HelpfulLinks } from "./home/HelpfulLinks";
16
16
 
17
17
  // Billing.
18
18
  export { BillingTab } from "./billing/BillingTab";
19
+ export { PlanGrid } from "./billing/PlanGrid";
19
20
 
20
21
  // Lib helpers.
21
22
  export {
22
23
  fmtDate,
24
+ fmtMoney,
23
25
  daysUntil,
24
26
  humanizeStatus,
25
27
  statusVariant,
package/src/lib/format.js CHANGED
@@ -22,6 +22,23 @@ export function daysUntil(value) {
22
22
  return Math.ceil((ms - Date.now()) / (1000 * 60 * 60 * 24));
23
23
  }
24
24
 
25
+ // Stripe amount (smallest currency unit, e.g. cents) -> "$29" / "€29.50".
26
+ // Whole amounts drop the decimals; null/undefined -> "—". Currency defaults to
27
+ // USD; falls back to an uppercased code prefix for currencies Intl can't symbol.
28
+ export function fmtMoney(unitAmount, currency = "usd") {
29
+ if (unitAmount == null) return "—";
30
+ const amount = unitAmount / 100;
31
+ try {
32
+ return new Intl.NumberFormat(undefined, {
33
+ style: "currency",
34
+ currency: (currency || "usd").toUpperCase(),
35
+ minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
36
+ }).format(amount);
37
+ } catch {
38
+ return `${(currency || "usd").toUpperCase()} ${amount}`;
39
+ }
40
+ }
41
+
25
42
  // Friendly labels for the AppInstallStatus enum (shared across apps).
26
43
  export const STATUS_LABELS = {
27
44
  trialing: "Free trial",