@growth-operations/ui-shared 1.5.8 → 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.8",
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,
@@ -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}