@growth-operations/ui-shared 1.6.4 → 1.6.6

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.4",
3
+ "version": "1.6.6",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -138,7 +138,7 @@ function StatusPanel({ entitlement }) {
138
138
  );
139
139
  }
140
140
 
141
- function TrialSubscriptionBilling({ context, state, appKey }) {
141
+ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null }) {
142
142
  const ent = state?.entitlement;
143
143
 
144
144
  // Install not provisioned with a Stripe customer yet — calm "finish setup"
@@ -180,21 +180,22 @@ function TrialSubscriptionBilling({ context, state, appKey }) {
180
180
  `&return_url=${encodeURIComponent(returnUrl)}`
181
181
  : null;
182
182
 
183
- // Upgrade-only tier picker — shown ONLY while trialing (the lifecycle: trial
184
- // starts on the entry tier at install; while trialing the customer may upgrade
185
- // to a higher tier; once active, plan changes go through support, so no picker).
186
- // The cards target /v1/billing/upgrade/start (swap the trialing sub's item, no
187
- // new sub). "Higher tiers" = plans ranked above the current tier by tier_order;
188
- // the backend stamps Plan.current on the account's tier.
183
+ // Tier picker — shown ONLY while trialing (the lifecycle: trial starts on the
184
+ // entry tier at install; while trialing the customer may upgrade to a higher
185
+ // tier in-app; once active, plan changes go through support). We show the FULL
186
+ // ladder for context so the customer sees where they sit:
187
+ // current tier -> "Current plan" (disabled marker; PlanCard keys off
188
+ // plan.current, stamped server-side on the account's tier).
189
+ // higher tiers -> the actionable upgrade (CTA targets /v1/billing/upgrade/
190
+ // start, swapping the trialing sub's item — no new sub).
191
+ // lower tiers -> shown disabled with a Talk-to-sales CTA (downgrades aren't
192
+ // self-serve). PlanCard derives this from currentOrder.
189
193
  const isTrialing = ent.status === "trialing";
190
194
  const plans = state?.plans ?? [];
191
195
  const currentOrder = plans.find((p) => p.current)?.tier_order;
192
- const upgradePlans =
193
- isTrialing && currentOrder != null
194
- ? plans.filter(
195
- (p) => !p.talk_to_sales && p.tier_order != null && p.tier_order > currentOrder
196
- )
197
- : [];
196
+ // Render the picker only while trialing AND once we know the current tier
197
+ // (currentOrder drives upgrade-vs-downgrade per card).
198
+ const showPicker = isTrialing && currentOrder != null && plans.length > 0;
198
199
 
199
200
  return (
200
201
  <Flex direction="column" gap="medium">
@@ -215,26 +216,30 @@ function TrialSubscriptionBilling({ context, state, appKey }) {
215
216
  link above to update your plan, payment method, or view invoices.
216
217
  </Text>
217
218
 
218
- {/* Trial-only upgrade path. Hidden once active (no plans pass the filter
219
- when not trialing). Upgrading swaps the trialing sub onto the higher
220
- tier, keeping the trial end date. */}
221
- {upgradePlans.length > 0 && (
219
+ {/* Trial-only tier ladder: current (marked), higher (upgradeable), lower
220
+ (disabled, Talk-to-sales). Hidden once active. Upgrading swaps the
221
+ trialing sub onto the higher tier, keeping the trial end date. The full
222
+ `plans` list is passed; PlanCard decides each card's state from
223
+ plan.current + currentOrder. */}
224
+ {showPicker && (
222
225
  <PlanGrid
223
226
  context={context}
224
227
  state={state}
225
228
  appKey={appKey}
226
- plans={upgradePlans}
229
+ plans={plans}
230
+ currentOrder={currentOrder}
227
231
  endpoint="upgrade/start"
228
232
  ctaLabel="Upgrade to"
229
- heading="Upgrade your plan"
230
- footnote="Upgrade any time during your trial — your trial end date stays the same, and the new tier applies when it converts."
233
+ heading="Your plan"
234
+ footnote="Upgrade any time during your trial — your trial end date stays the same, and the new tier applies when it converts. To move to a lower tier, talk to sales."
235
+ openIframe={openIframe}
231
236
  />
232
237
  )}
233
238
  </Flex>
234
239
  );
235
240
  }
236
241
 
237
- function CreditsBilling({ context, state, appKey }) {
242
+ function CreditsBilling({ context, state, appKey, openIframe = null }) {
238
243
  const onPaidPlan = !!state?.entitlement?.plan;
239
244
 
240
245
  // Direct external link to the billing service's GET /v1/billing/portal/start,
@@ -297,7 +302,7 @@ function CreditsBilling({ context, state, appKey }) {
297
302
  entitlement={state?.entitlement}
298
303
  creditMeter={state?.credit_meter}
299
304
  />
300
- <PlanGrid context={context} state={state} appKey={appKey} />
305
+ <PlanGrid context={context} state={state} appKey={appKey} openIframe={openIframe} />
301
306
  {!hasPlans && (
302
307
  <LoadingButton
303
308
  href={
@@ -341,7 +346,7 @@ function LegacyBilling({ state }) {
341
346
  );
342
347
  }
343
348
 
344
- export function BillingTab({ context, state, appKey }) {
349
+ export function BillingTab({ context, state, appKey, openIframe = null }) {
345
350
  const mode = state?.entitlement?.mode;
346
351
 
347
352
  return (
@@ -350,9 +355,9 @@ export function BillingTab({ context, state, appKey }) {
350
355
  {mode === "legacy" ? (
351
356
  <LegacyBilling state={state} />
352
357
  ) : mode === "credits" ? (
353
- <CreditsBilling context={context} state={state} appKey={appKey} />
358
+ <CreditsBilling context={context} state={state} appKey={appKey} openIframe={openIframe} />
354
359
  ) : (
355
- <TrialSubscriptionBilling context={context} state={state} appKey={appKey} />
360
+ <TrialSubscriptionBilling context={context} state={state} appKey={appKey} openIframe={openIframe} />
356
361
  )}
357
362
  </Flex>
358
363
  );
@@ -54,12 +54,36 @@ function PlanCard({
54
54
  // to this tier (the trial-archetype in-app upgrade) — no new sub.
55
55
  endpoint = "checkout/start",
56
56
  ctaLabel = "Choose",
57
+ // Trial-picker only: tier_order of the customer's current tier. When set, a
58
+ // card ranked BELOW it is a downgrade — shown for context but not self-serve
59
+ // (downgrades go through support), so it renders disabled with a Talk-to-sales
60
+ // CTA. Cards ranked ABOVE are upgrades (the normal CTA). null/undefined => the
61
+ // credit first-purchase grid, where this relation doesn't apply.
62
+ currentOrder = null,
63
+ // actions.openIframeModal from the card root (threaded BillingTab → PlanGrid).
64
+ // When present, a talk_to_sales tier with plan.meetings_url opens the meetings
65
+ // scheduler in an IN-CARD modal (the Anvil "Meet with Andrew" experience).
66
+ // Absent (or no meetings_url) → fall back to a new-tab href. UI Extensions
67
+ // can't read whether the external meetings page is CSP-frameable, so the modal
68
+ // is best-effort; the href fallback always works.
69
+ openIframe = null,
57
70
  }) {
58
71
  // Default this card to annual when it offers annual (cheaper-per-month story);
59
72
  // monthly-only tiers default to monthly. Both legs → show the toggle.
60
73
  const hasMonthly = !!plan.monthly;
61
74
  const hasAnnual = !!plan.annual;
62
- const canToggle = hasMonthly && hasAnnual;
75
+ // A lower-ranked tier than the customer's current one: a downgrade. Not
76
+ // self-serve (changes go through support) — show it for context, disabled,
77
+ // with a Talk-to-sales CTA. Only meaningful in the trial picker (currentOrder
78
+ // set); never flag the current tier itself as a downgrade.
79
+ const isDowngrade =
80
+ currentOrder != null &&
81
+ !plan.current &&
82
+ plan.tier_order != null &&
83
+ 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;
63
87
  const [interval, setInterval] = useState(hasAnnual ? "annual" : "monthly");
64
88
  const leg = interval === "annual" ? plan.annual : plan.monthly;
65
89
  const periodLabel = interval === "annual" ? "/yr" : "/mo";
@@ -152,18 +176,54 @@ function PlanCard({
152
176
  <Text key={`pad-${i}`}>{" "}</Text>
153
177
  ))}
154
178
 
155
- {/* CTA. Current tier => disabled marker. talk_to_sales => contact link.
156
- Otherwise pre-create-then-link checkout for the selected interval. */}
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. */}
157
183
  {plan.current ? (
158
184
  <Button disabled>Current plan</Button>
159
- ) : plan.talk_to_sales ? (
185
+ ) : isDowngrade ? (
160
186
  <Button
161
187
  href={supportUrl ? { url: supportUrl, external: true } : undefined}
162
188
  disabled={!supportUrl}
163
189
  variant="secondary"
164
190
  >
165
- Contact sales
191
+ Talk to sales to switch
166
192
  </Button>
193
+ ) : plan.talk_to_sales ? (
194
+ // Talk-to-sales tier (e.g. Enterprise+): no price/checkout. If we have
195
+ // a meetings link AND the openIframeModal action, open the scheduler in
196
+ // an in-card modal (Anvil "Meet with Andrew"). Otherwise a new-tab href
197
+ // to the meetings link (preferred) or support page.
198
+ openIframe && plan.meetings_url ? (
199
+ <Button
200
+ variant="secondary"
201
+ onClick={() =>
202
+ openIframe({
203
+ uri: plan.meetings_url,
204
+ title: `Talk to sales — ${plan.name ?? plan.tier}`,
205
+ width: 1100,
206
+ height: 760,
207
+ })
208
+ }
209
+ >
210
+ Talk to sales
211
+ </Button>
212
+ ) : (
213
+ <Button
214
+ href={
215
+ plan.meetings_url
216
+ ? { url: plan.meetings_url, external: true }
217
+ : supportUrl
218
+ ? { url: supportUrl, external: true }
219
+ : undefined
220
+ }
221
+ disabled={!plan.meetings_url && !supportUrl}
222
+ variant="secondary"
223
+ >
224
+ Talk to sales
225
+ </Button>
226
+ )
167
227
  ) : !leg ? (
168
228
  // No price for the selected interval (e.g. annual not offered) — guide
169
229
  // the customer to the other toggle rather than dead-ending.
@@ -202,6 +262,12 @@ export function PlanGrid({
202
262
  ctaLabel = "Choose",
203
263
  heading = "Plans",
204
264
  footnote = "Billing is managed in Stripe across all Growth Operations apps. Your new credits are added as soon as payment completes.",
265
+ // Trial-picker only: tier_order of the current tier, so cards below it render
266
+ // as disabled downgrades (Talk-to-sales). Omitted for the credit grid.
267
+ currentOrder = null,
268
+ // actions.openIframeModal (from the hosting card root) — lets a talk_to_sales
269
+ // tier open its meetings_url scheduler in an in-card modal. Optional.
270
+ openIframe = null,
205
271
  }) {
206
272
  const plans = plansOverride ?? state?.plans ?? [];
207
273
 
@@ -242,6 +308,8 @@ export function PlanGrid({
242
308
  maxFeatures={maxFeatures}
243
309
  endpoint={endpoint}
244
310
  ctaLabel={ctaLabel}
311
+ currentOrder={currentOrder}
312
+ openIframe={openIframe}
245
313
  />
246
314
  ))}
247
315
  </AutoGrid>