@growth-operations/ui-shared 1.0.9 → 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.9",
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;
@@ -6,6 +6,7 @@ import { OnboardingChecklist } from "./OnboardingChecklist";
6
6
  import { SyncPulse } from "./SyncPulse";
7
7
  import { CreditMeter } from "./CreditMeter";
8
8
  import { FeatureNudges } from "./FeatureNudges";
9
+ import { HelpfulLinks } from "./HelpfulLinks";
9
10
 
10
11
  // Minimal inline ATS tile (the `pipeline` block). Kept tiny here rather than a
11
12
  // separate file — it's the simplest of the three archetype tiles. Renders
@@ -76,7 +77,13 @@ function ArchetypeTile({ state }) {
76
77
  // PageLink navigation (the host owns the router).
77
78
  // appName — display name override; defaults to state.app.name.
78
79
  // onRetry — optional, passed to InstallProgress for the failed state.
79
- export function AppHome({ state, onNavigate, appName, onRetry }) {
80
+ // helpfulLinks — optional props for the standard HelpfulLinks section
81
+ // (e.g. { portalId, appId, appName, docsUrl, supportUrl,
82
+ // links }). Omit to hide the section. The host passes this
83
+ // (it owns context.portal.id + the app's docs/support); it can
84
+ // also come from a state.helpful_links block if the backend
85
+ // builds one.
86
+ export function AppHome({ state, onNavigate, appName, onRetry, helpfulLinks }) {
80
87
  if (!state) {
81
88
  return <LoadingSpinner showLabel label="Loading…" />;
82
89
  }
@@ -85,10 +92,21 @@ export function AppHome({ state, onNavigate, appName, onRetry }) {
85
92
  const activationComplete =
86
93
  !activation || activation.complete === true || activation.status === "completed";
87
94
 
95
+ // HelpfulLinks props: explicit prop wins, else a state.helpful_links block if
96
+ // the backend builds one. Shown in both the install and cockpit views so help
97
+ // is reachable even mid-setup.
98
+ const links = helpfulLinks ?? state.helpful_links ?? null;
99
+ const helpfulLinksEl = links ? <HelpfulLinks {...links} /> : null;
100
+
88
101
  // Install isn't finished (running / failed / completed-with-warnings) — show
89
102
  // the install experience instead of the cockpit.
90
103
  if (!activationComplete) {
91
- return <InstallProgress state={state} onRetry={onRetry} />;
104
+ return (
105
+ <Flex direction="column" gap="medium">
106
+ <InstallProgress state={state} onRetry={onRetry} />
107
+ {helpfulLinksEl}
108
+ </Flex>
109
+ );
92
110
  }
93
111
 
94
112
  const onboardingComplete = state.onboarding?.complete === true;
@@ -108,6 +126,8 @@ export function AppHome({ state, onNavigate, appName, onRetry }) {
108
126
  <ArchetypeTile state={state} />
109
127
 
110
128
  <FeatureNudges nudges={state.nudges} onNavigate={onNavigate} />
129
+
130
+ {helpfulLinksEl}
111
131
  </Flex>
112
132
  );
113
133
  }
@@ -35,18 +35,26 @@ export function HelpfulLinks({
35
35
  links = [],
36
36
  title = "Helpful Links",
37
37
  }) {
38
+ // external=false keeps navigation in the SAME tab. The app-pages deep link is
39
+ // an in-app HubSpot URL, so it must stay same-tab (external:true would force a
40
+ // new tab). Docs/support and app-specific extras are off-platform → external.
38
41
  const items = [];
39
42
 
40
43
  if (appPages && appId && portalId != null) {
41
44
  items.push({
42
45
  label: `Open ${appName} →`,
43
46
  url: appPagesUrl(portalId, appId, appPagesPath),
47
+ external: false,
44
48
  });
45
49
  }
46
- if (docsUrl) items.push({ label: "Documentation", url: docsUrl });
47
- if (supportUrl) items.push({ label: "Support", url: supportUrl });
50
+ if (docsUrl) items.push({ label: "Documentation", url: docsUrl, external: true });
51
+ if (supportUrl) items.push({ label: "Support", url: supportUrl, external: true });
48
52
  for (const l of links) {
49
- if (l && l.url && l.label) items.push({ label: l.label, url: l.url });
53
+ if (l && l.url && l.label) {
54
+ // Default app-specific extras to external (they're usually HubSpot record
55
+ // URLs or off-platform), but let a link opt into same-tab via external:false.
56
+ items.push({ label: l.label, url: l.url, external: l.external !== false });
57
+ }
50
58
  }
51
59
 
52
60
  if (items.length === 0) return null;
@@ -56,7 +64,7 @@ export function HelpfulLinks({
56
64
  <Text format={{ fontWeight: "bold" }}>{title}</Text>
57
65
  <Divider />
58
66
  {items.map((item) => (
59
- <Link key={item.url} href={{ url: item.url, external: true }}>
67
+ <Link key={item.url} href={{ url: item.url, external: item.external }}>
60
68
  {item.label}
61
69
  </Link>
62
70
  ))}
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",