@growth-operations/ui-shared 1.6.11 → 1.6.13

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.6.11",
3
+ "version": "1.6.13",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -31,15 +31,21 @@ import { fmtMoney } from "../lib/format";
31
31
  // appKey — the billing app_key for THIS app (always a prop, never hardcoded).
32
32
 
33
33
  // One plan card. Shows tier name, price for the selected interval, credits,
34
- // feature bullets, and a CTA: "Current" (disabled) for the active tier,
35
- // "Contact sales" for talk_to_sales tiers, else "Choose" which pre-creates a
36
- // checkout session for that tier's price in the selected interval.
34
+ // feature bullets, and a CTA: "Current plan" (disabled) for the active tier AT
35
+ // its actual interval, "Contact sales" for talk_to_sales tiers, else "Choose"
36
+ // (or "Switch to Annual/Monthly" on the current card) which pre-creates a
37
+ // checkout/upgrade session for that tier's price in the selected interval.
37
38
  //
38
39
  // The Monthly/Annual toggle is PER CARD (not a single grid-wide switch): a tier
39
40
  // may offer one interval and not the other (e.g. a top tier that's monthly-only),
40
41
  // so each card owns its own interval state and only shows the toggle when it has
41
42
  // 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.
43
+ // whatever this card's toggle shows when its CTA is clicked. The CURRENT card
44
+ // also shows the toggle (defaulting to the account's real interval via
45
+ // plan.current_interval) so a monthly customer can switch to annual in place —
46
+ // toggling it to a DIFFERENT interval than what's billed swaps the disabled
47
+ // "Current plan" marker for an actionable switch CTA (always upgrade/start,
48
+ // never a new checkout, since a real subscription already exists).
43
49
  function PlanCard({
44
50
  plan,
45
51
  billingBaseUrl,
@@ -69,9 +75,25 @@ function PlanCard({
69
75
  openIframe = null,
70
76
  }) {
71
77
  // Default this card to annual when it offers annual (cheaper-per-month story);
72
- // monthly-only tiers default to monthly. Both legs → show the toggle.
78
+ // monthly-only tiers default to monthly. Both legs → show the toggle. The
79
+ // CURRENT card is the one exception: default to the account's ACTUAL billing
80
+ // interval (plan.current_interval, stamped server-side from the mirrored
81
+ // Subscription) so the toggle opens showing reality, not a marketing default
82
+ // — falling back to the annual-preferred default only if the interval is
83
+ // unknown (e.g. an older payload before this field existed).
73
84
  const hasMonthly = !!plan.monthly;
74
85
  const hasAnnual = !!plan.annual;
86
+ const defaultInterval = plan.current
87
+ ? plan.current_interval === "month"
88
+ ? "monthly"
89
+ : plan.current_interval === "year"
90
+ ? "annual"
91
+ : hasAnnual
92
+ ? "annual"
93
+ : "monthly"
94
+ : hasAnnual
95
+ ? "annual"
96
+ : "monthly";
75
97
  // A lower-ranked tier than the customer's current one: a downgrade. Not
76
98
  // self-serve (changes go through support) — show it for context, disabled,
77
99
  // with a Talk-to-sales CTA. Only meaningful in the trial picker (currentOrder
@@ -81,10 +103,19 @@ function PlanCard({
81
103
  !plan.current &&
82
104
  plan.tier_order != null &&
83
105
  plan.tier_order < currentOrder;
84
- // The interval toggle is only useful when the card can be acted on; a current
85
- // or downgrade card is read-only, so hide its toggle.
86
- const canToggle = hasMonthly && hasAnnual && !plan.current && !isDowngrade;
87
- const [interval, setInterval] = useState(hasAnnual ? "annual" : "monthly");
106
+ // The interval toggle is useful whenever the card can be acted on OR is the
107
+ // current plan (so a monthly customer can switch to annual in place) — only
108
+ // a downgrade card is fully read-only.
109
+ const canToggle = hasMonthly && hasAnnual && !isDowngrade;
110
+ const [interval, setInterval] = useState(defaultInterval);
111
+ // On the current card, toggling to a DIFFERENT interval than what's actually
112
+ // billed is the in-place monthly<->annual switch; toggling back to the same
113
+ // interval (or current_interval being unknown) is just "Current plan".
114
+ const isIntervalSwitch =
115
+ plan.current &&
116
+ plan.current_interval != null &&
117
+ ((interval === "annual" && plan.current_interval !== "year") ||
118
+ (interval === "monthly" && plan.current_interval !== "month"));
88
119
  const leg = interval === "annual" ? plan.annual : plan.monthly;
89
120
  const periodLabel = interval === "annual" ? "/yr" : "/mo";
90
121
 
@@ -105,12 +136,18 @@ function PlanCard({
105
136
  ? Math.round((1 - plan.annual.unit_amount / annualFull) * 100)
106
137
  : 0;
107
138
 
108
- // Direct one-click checkout link to the billing service's redirect endpoint.
109
- // Null until we have everything (billing host, appKey, portal, this interval's
110
- // price) — the button is disabled until then.
139
+ // Direct one-click link to the billing service's redirect endpoint. Null
140
+ // until we have everything (billing host, appKey, portal, this interval's
141
+ // price) — the button is disabled until then. The current card's in-place
142
+ // interval switch is ALWAYS upgrade/start (swap the existing sub's item),
143
+ // regardless of what `endpoint` this grid instance was given — it can never
144
+ // be a first-time checkout/start, since plan.current + a real
145
+ // current_interval only happens with an existing subscription already in
146
+ // place. Getting this wrong would double-bill (a second Checkout session).
147
+ const startEndpoint = isIntervalSwitch ? "upgrade/start" : endpoint;
111
148
  const startUrl =
112
149
  billingBaseUrl && appKey && portalId && leg?.price_id
113
- ? `${billingBaseUrl}/v1/billing/${endpoint}` +
150
+ ? `${billingBaseUrl}/v1/billing/${startEndpoint}` +
114
151
  `?app_key=${encodeURIComponent(appKey)}` +
115
152
  `&portal_id=${encodeURIComponent(portalId)}` +
116
153
  `&price_id=${encodeURIComponent(leg.price_id)}` +
@@ -176,12 +213,22 @@ function PlanCard({
176
213
  <Text key={`pad-${i}`}>{" "}</Text>
177
214
  ))}
178
215
 
179
- {/* CTA. Current tier => disabled marker. Downgrade (lower than current,
180
- trial picker) => disabled + Talk-to-sales (not self-serve).
181
- talk_to_sales tier => contact link. Otherwise pre-create-then-link
182
- checkout/upgrade for the selected interval. */}
183
- {plan.current ? (
216
+ {/* CTA. Current tier, same interval => disabled marker. Current tier,
217
+ toggled to the OTHER interval => actionable in-place switch (same
218
+ upgrade/start swap as a tier upgrade, just same-tier). Downgrade
219
+ (lower than current, trial picker) => disabled + Talk-to-sales (not
220
+ self-serve). talk_to_sales tier => contact link. Otherwise
221
+ pre-create-then-link checkout/upgrade for the selected interval. */}
222
+ {plan.current && !isIntervalSwitch ? (
184
223
  <Button disabled>Current plan</Button>
224
+ ) : plan.current && isIntervalSwitch ? (
225
+ <Button
226
+ href={startUrl ? { url: startUrl, external: true } : undefined}
227
+ disabled={!startUrl}
228
+ variant="primary"
229
+ >
230
+ Switch to {interval === "annual" ? "Annual" : "Monthly"}
231
+ </Button>
185
232
  ) : isDowngrade ? (
186
233
  <Button
187
234
  href={supportUrl ? { url: supportUrl, external: true } : undefined}