@growth-operations/ui-shared 1.5.8 → 1.6.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.5.8",
3
+ "version": "1.6.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -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}
@@ -0,0 +1,115 @@
1
+ import React, { useState } from "react";
2
+ import { Select, LoadingSpinner, Alert, Flex, Text } from "@hubspot/ui-extensions";
3
+ import { useToken } from "../sdk/index";
4
+ import { getForms } from "../sdk/hubspot/forms";
5
+ import { getLists } from "../sdk/hubspot/lists";
6
+ import { getPipelines, getPipelineStages } from "../sdk/hubspot/pipeline";
7
+ import { useStrictModeEffect } from "../lib/useStrictModeEffect";
8
+
9
+ // OptionSelect — one shared HubSpot-options dropdown, replacing the per-app
10
+ // FormSelector / SegmentSelector / PipelineStageSelector that every app
11
+ // re-implemented (fetch options → map to {label,value} → <Select> → save on
12
+ // change). The data-fetch half already lives in the SDK (getForms / getLists /
13
+ // getPipelineStages); this owns the loading/error/Select chrome around it.
14
+ //
15
+ // Pick options ONE of two ways:
16
+ // - source="forms" | "lists" | "pipelines" | "pipelineStages" — built-in SDK
17
+ // fetchers. "pipelines"/"pipelineStages" take opts.objectType; the latter
18
+ // also needs opts.pipelineId.
19
+ // - fetchOptions={async (context, token) => [{label, value}, ...]} — any
20
+ // custom source. Takes precedence over `source`.
21
+ //
22
+ // onChange(value) fires with the selected value; the caller owns persistence
23
+ // (e.g. ui-shared updateSettings). `value` is the controlled selection.
24
+ const SOURCES = {
25
+ forms: async (context, token) => {
26
+ const { results = [] } = await getForms(context, token);
27
+ return results.map((f) => ({ label: f.name, value: f.id }));
28
+ },
29
+ lists: async (context, token, opts) => {
30
+ const { lists = [] } = await getLists(context, token, opts?.query ?? "");
31
+ return lists.map((l) => ({ label: l.name, value: String(l.listId) }));
32
+ },
33
+ pipelines: async (context, token, opts) => {
34
+ const { results = [] } = await getPipelines(context, token, opts?.objectType);
35
+ return results.map((p) => ({ label: p.label, value: p.id }));
36
+ },
37
+ pipelineStages: async (context, token, opts) => {
38
+ if (!opts?.pipelineId) return [];
39
+ const { results = [] } = await getPipelineStages(
40
+ context,
41
+ token,
42
+ opts.pipelineId,
43
+ opts.objectType
44
+ );
45
+ return results.map((s) => ({ label: s.label, value: s.id }));
46
+ },
47
+ };
48
+
49
+ export function OptionSelect({
50
+ context,
51
+ value,
52
+ onChange,
53
+ source,
54
+ fetchOptions,
55
+ label = "Select",
56
+ description,
57
+ placeholder = "Choose…",
58
+ // Extra source args (pipelineId/objectType for pipelineStages, query for lists)
59
+ ...opts
60
+ }) {
61
+ const [options, setOptions] = useState([]);
62
+ const [loading, setLoading] = useState(true);
63
+ const [error, setError] = useState(null);
64
+ const { ensureValidToken } = useToken();
65
+
66
+ // Re-fetch when the source or its key args change (e.g. pipelineId).
67
+ const depKey = JSON.stringify({
68
+ source,
69
+ pipelineId: opts.pipelineId,
70
+ objectType: opts.objectType,
71
+ query: opts.query,
72
+ });
73
+
74
+ useStrictModeEffect(
75
+ async ({ mounted }) => {
76
+ try {
77
+ const token = await ensureValidToken();
78
+ if (!token || !mounted.current) return;
79
+ const loader =
80
+ fetchOptions ?? SOURCES[source] ?? (async () => []);
81
+ const opted = await loader(context, token, opts);
82
+ if (mounted.current) setOptions(opted);
83
+ } catch (e) {
84
+ if (mounted.current) setError(String(e?.message ?? e));
85
+ } finally {
86
+ if (mounted.current) setLoading(false);
87
+ }
88
+ },
89
+ [context, depKey]
90
+ );
91
+
92
+ if (loading) {
93
+ return <LoadingSpinner size="small" label={`Loading ${label.toLowerCase()}…`} showLabel />;
94
+ }
95
+ if (error) {
96
+ return (
97
+ <Alert title={`Couldn't load ${label.toLowerCase()}`} variant="danger">
98
+ <Text>{error}</Text>
99
+ </Alert>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <Flex direction="column" gap="extra-small">
105
+ <Select
106
+ label={label}
107
+ description={description}
108
+ placeholder={placeholder}
109
+ options={options}
110
+ value={value ?? ""}
111
+ onChange={(v) => onChange?.(v)}
112
+ />
113
+ </Flex>
114
+ );
115
+ }
package/src/index.js CHANGED
@@ -27,6 +27,9 @@ export { UsageTab } from "./usage/UsageTab";
27
27
  // Sandbox — the shared "Install on a sandbox" action (mints a signed link).
28
28
  export { SandboxInstall } from "./sandbox/SandboxInstall";
29
29
 
30
+ // Forms — shared HubSpot-options dropdown (forms/lists/pipeline-stages picker).
31
+ export { OptionSelect } from "./forms/OptionSelect";
32
+
30
33
  // Lib helpers.
31
34
  export {
32
35
  fmtDate,