@growth-operations/ui-shared 1.19.1 → 1.21.1

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.19.1",
3
+ "version": "1.21.1",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -60,6 +60,13 @@ const LEVEL_VARIANT = {
60
60
  critical: "error",
61
61
  };
62
62
 
63
+ // The level bubble is display copy — capitalize the backend's lowercase
64
+ // level ("warning" -> "Warning").
65
+ function capitalizeLevel(level) {
66
+ if (!level) return "";
67
+ return level.charAt(0).toUpperCase() + level.slice(1);
68
+ }
69
+
63
70
  function DefaultAlertRow({
64
71
  context,
65
72
  alert,
@@ -86,7 +93,7 @@ function DefaultAlertRow({
86
93
  <TableRow>
87
94
  <TableCell>
88
95
  <Tag variant={LEVEL_VARIANT[alert.level] || "default"}>
89
- {alert.level}
96
+ {capitalizeLevel(alert.level)}
90
97
  </Tag>
91
98
  </TableCell>
92
99
  <TableCell>
@@ -0,0 +1,139 @@
1
+ import React, { useEffect, useReducer, useState } from "react";
2
+ import { Alert, Button, Flex, Heading, Text, Toggle } from "@hubspot/ui-extensions";
3
+ import {
4
+ AUTO_PROVISION_CONFIRM_NO,
5
+ AUTO_PROVISION_CONFIRM_YES,
6
+ AUTO_PROVISION_IDLE,
7
+ AUTO_PROVISION_TITLE,
8
+ autoProvisionConfirmBody,
9
+ autoProvisionReducer,
10
+ autoProvisionSubtext,
11
+ } from "../lib/autoProvision";
12
+ import { setAutoProvision } from "../sdk/app/topUps";
13
+
14
+ // "Add Additional Credits Automatically" — the auto-provision toggle on the
15
+ // Billing tab (REPLACES the old billing-limit control). ON (the default) buys
16
+ // another pack of additional credits when monthly credits run out; OFF means
17
+ // no purchase and workflow actions start failing the entitlement gate.
18
+ //
19
+ // The ON→OFF direction is dangerous, so it parks in an inline confirmation
20
+ // panel (Andrew's exact copy + buttons) instead of saving on click; OFF→ON
21
+ // saves immediately — no confirmation to turn protection back ON. The flow
22
+ // itself is a unit-tested reducer (../lib/autoProvision); this component is
23
+ // the wiring: controlled Toggle + the POST to the base service's
24
+ // /v1/hubspot/app_pages/{app}/billing/auto-provision (see setAutoProvision).
25
+ //
26
+ // Props:
27
+ // context — the UI-extension serverless context
28
+ // (context.portal.id).
29
+ // appKey — the billing app_key for THIS app.
30
+ // enabled — the resolved flag (resolveAutoProvision; true when
31
+ // the state.auto_provision block is absent).
32
+ // appName — display name templated into the copy.
33
+ // billingActionToken — the (interval-refreshed)
34
+ // state.billing_action_tokens.portal. Without it the
35
+ // toggle stays read-only — the same "hidden/disabled
36
+ // until token exists" posture as the other billing
37
+ // controls.
38
+ export function AutoProvisionToggle({
39
+ context,
40
+ appKey,
41
+ enabled: enabledProp,
42
+ appName = "this app",
43
+ billingActionToken = null,
44
+ }) {
45
+ const [enabled, setEnabled] = useState(enabledProp !== false);
46
+ const [ui, dispatch] = useReducer(autoProvisionReducer, AUTO_PROVISION_IDLE);
47
+
48
+ // The flag arrives with /v1/home; adopt a changed server-side value (the
49
+ // payload refetches after install/refresh).
50
+ useEffect(() => {
51
+ setEnabled(enabledProp !== false);
52
+ }, [enabledProp]);
53
+
54
+ const ready = !!billingActionToken;
55
+ const busy = ui.phase === "saving";
56
+
57
+ const save = async (next) => {
58
+ try {
59
+ await setAutoProvision(context, {
60
+ appKey,
61
+ portalId: context?.portal?.id,
62
+ token: billingActionToken,
63
+ enabled: next,
64
+ });
65
+ setEnabled(next);
66
+ dispatch({ type: "saved" });
67
+ } catch (e) {
68
+ dispatch({
69
+ type: "failed",
70
+ message: e?.message ?? "Couldn't update automatic credit provisioning.",
71
+ });
72
+ }
73
+ };
74
+
75
+ const onToggle = (checked) => {
76
+ if (!ready || busy) return;
77
+ dispatch({ type: "request", enabled: checked });
78
+ if (checked) save(true); // turning protection back ON needs no confirmation
79
+ };
80
+
81
+ return (
82
+ <Flex direction="column" gap="small">
83
+ <Heading>{AUTO_PROVISION_TITLE}</Heading>
84
+ <Text>{autoProvisionSubtext(appName)}</Text>
85
+ <Toggle
86
+ label={AUTO_PROVISION_TITLE}
87
+ labelDisplay="hidden"
88
+ name="auto-provision"
89
+ checked={enabled}
90
+ readOnly={!ready || busy}
91
+ onChange={onToggle}
92
+ />
93
+ {!ready && (
94
+ <Text variant="microcopy">
95
+ Preparing billing… this control unlocks in a moment. Refresh the page
96
+ if this persists.
97
+ </Text>
98
+ )}
99
+
100
+ {/* The ON→OFF confirmation (the toggle stays checked while this is up;
101
+ cancel snaps back to idle with the flag untouched). */}
102
+ {ui.phase === "confirming" && (
103
+ <Alert variant="warning" title="Disable automatic credit provisioning?">
104
+ <Flex direction="column" gap="small">
105
+ <Text>{autoProvisionConfirmBody(appName)}</Text>
106
+ <Flex direction="row" gap="small">
107
+ <Button
108
+ variant="destructive"
109
+ onClick={() => {
110
+ dispatch({ type: "confirm" });
111
+ save(false);
112
+ }}
113
+ >
114
+ {AUTO_PROVISION_CONFIRM_YES}
115
+ </Button>
116
+ <Button
117
+ variant="secondary"
118
+ onClick={() => dispatch({ type: "cancel" })}
119
+ >
120
+ {AUTO_PROVISION_CONFIRM_NO}
121
+ </Button>
122
+ </Flex>
123
+ </Flex>
124
+ </Alert>
125
+ )}
126
+
127
+ {ui.notice && (
128
+ <Alert
129
+ variant={ui.notice.variant}
130
+ title={ui.notice.variant === "success" ? "Saved" : "Couldn't save"}
131
+ >
132
+ <Text>{ui.notice.text}</Text>
133
+ </Alert>
134
+ )}
135
+ </Flex>
136
+ );
137
+ }
138
+
139
+ export default AutoProvisionToggle;
@@ -7,7 +7,6 @@ import {
7
7
  LoadingButton,
8
8
  Button,
9
9
  Alert,
10
- Input,
11
10
  Link,
12
11
  LoadingSpinner,
13
12
  Table,
@@ -18,12 +17,13 @@ import {
18
17
  TableCell,
19
18
  } from "@hubspot/ui-extensions";
20
19
  import { fmtDate, fmtMoney, daysUntil } from "../lib/format";
21
- import { parseLimitInput } from "../lib/creditDisplay";
22
20
  import { useStrictModeEffect } from "../lib/useStrictModeEffect";
23
21
  import { CreditMeter } from "../home/CreditMeter";
24
22
  import { PlanGrid } from "./PlanGrid";
23
+ import { AutoProvisionToggle } from "./AutoProvisionToggle";
24
+ import { resolveAutoProvision } from "../lib/autoProvision";
25
25
  import { refreshBillingActionTokens } from "../sdk/billing";
26
- import { getInvoices, getTopUps, setBillingLimit } from "../sdk/app/topUps";
26
+ import { getInvoices } from "../sdk/app/topUps";
27
27
 
28
28
  // Billing action tokens (see common.billing.action_token) are signed with a
29
29
  // 5-minute TTL — short enough that a customer who opens the tab and comes
@@ -393,91 +393,6 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
393
393
  );
394
394
  }
395
395
 
396
- // The top-up purchase table itself — pure rendering (exported for tests);
397
- // fetch/loading/error live in TopUpHistory below.
398
- export function TopUpHistoryTable({ topUps }) {
399
- return (
400
- <>
401
- <Table>
402
- <TableHead>
403
- <TableRow>
404
- <TableHeader>Date</TableHeader>
405
- <TableHeader>Credits</TableHeader>
406
- <TableHeader>Price</TableHeader>
407
- <TableHeader>Note</TableHeader>
408
- </TableRow>
409
- </TableHead>
410
- <TableBody>
411
- {topUps.map((topUp, i) => (
412
- <TableRow key={`${topUp.at}-${i}`}>
413
- <TableCell>{fmtDate(topUp.at)}</TableCell>
414
- <TableCell>{topUp.credits}</TableCell>
415
- <TableCell>{fmtMoney(topUp.price_cents)}</TableCell>
416
- <TableCell>Does not expire</TableCell>
417
- </TableRow>
418
- ))}
419
- </TableBody>
420
- </Table>
421
- <Text format={{ fontStyle: "italic" }}>
422
- Additional credits don't expire — they draw down only after your
423
- monthly plan credits.
424
- </Text>
425
- </>
426
- );
427
- }
428
-
429
- // "Top-up history" — the credit app's prepaid top-up purchases (the
430
- // never-expiring bank), from the base service's
431
- // GET /v1/hubspot/app_pages/{app}/billing/top-ups (newest first). Loading,
432
- // error, and empty states; the table mirrors AlertsTab's shape.
433
- function TopUpHistory({ context, appKey }) {
434
- const [status, setStatus] = useState("loading"); // loading | ready | error
435
- const [topUps, setTopUps] = useState([]);
436
-
437
- useStrictModeEffect(
438
- async ({ mounted }) => {
439
- try {
440
- const res = await getTopUps(context, {
441
- appKey,
442
- portalId: context?.portal?.id,
443
- });
444
- if (mounted.current) {
445
- setTopUps(res?.top_ups ?? []);
446
- setStatus("ready");
447
- }
448
- } catch {
449
- if (mounted.current) setStatus("error");
450
- }
451
- },
452
- [context, appKey]
453
- );
454
-
455
- if (status === "loading") {
456
- return <LoadingSpinner showLabel label="Loading top-ups…" />;
457
- }
458
- if (status === "error") {
459
- return (
460
- <Alert title="Couldn't load top-up history" variant="warning">
461
- <Text>Refresh the page to try again.</Text>
462
- </Alert>
463
- );
464
- }
465
-
466
- return (
467
- <Flex direction="column" gap="small">
468
- <Heading>Top-up history</Heading>
469
- {topUps.length === 0 ? (
470
- <Text format={{ fontStyle: "italic" }}>
471
- No top-ups yet. Additional credits you purchase are listed here —
472
- they don't expire.
473
- </Text>
474
- ) : (
475
- <TopUpHistoryTable topUps={topUps} />
476
- )}
477
- </Flex>
478
- );
479
- }
480
-
481
396
  // Invoice status -> label (Stripe's vocabulary: paid / open / void /
482
397
  // uncollectible; drafts and $0 voids are filtered server-side).
483
398
  const INVOICE_STATUS_LABELS = {
@@ -527,10 +442,13 @@ export function InvoiceHistoryTable({ invoices }) {
527
442
  }
528
443
 
529
444
  // "Invoice history" — every Stripe invoice on the portal's customer
530
- // (subscription renewals, one-off charges, top-up bands — including VOIDED
531
- // top-ups, which tell the decline story), from the billing service's
532
- // GET /v1/billing/invoices (newest first, capped at 24). Loading, error, and
533
- // empty states mirror TopUpHistory.
445
+ // (subscription renewals, one-off charges, additional-credit purchases —
446
+ // including VOIDED top-ups, which tell the decline story), from the billing
447
+ // service's GET /v1/billing/invoices (newest first, capped at 24). This is
448
+ // the one purchase history surface now — the old Top-Up History section is
449
+ // gone (Andrew's 2026-10 feedback); the base service keeps its deprecated
450
+ // GET billing/top-ups empty-list stub so OLD released clients degrade
451
+ // silently.
534
452
  //
535
453
  // Auth is the billing action token (the claim scopes the response to THIS
536
454
  // portal's Stripe customer). BillingTab re-mints the token set every 3
@@ -586,8 +504,8 @@ function InvoiceHistory({ billingBaseUrl, billingActionToken }) {
586
504
  <Heading>Invoice history</Heading>
587
505
  {invoices.length === 0 ? (
588
506
  <Text format={{ fontStyle: "italic" }}>
589
- No invoices yet. Subscription renewals, one-off charges, and credit
590
- top-ups are listed here.
507
+ No invoices yet. Subscription renewals, one-off charges, and
508
+ additional-credit purchases are listed here.
591
509
  </Text>
592
510
  ) : (
593
511
  <InvoiceHistoryTable invoices={invoices} />
@@ -596,143 +514,6 @@ function InvoiceHistory({ billingBaseUrl, billingActionToken }) {
596
514
  );
597
515
  }
598
516
 
599
- // "Billing limit" — the per-period spend cap on automatic top-ups. Shows the
600
- // current limit (when the entitlement arm carries billing_limit_cents), sets
601
- // a new one (USD input -> cents), or clears it, via the base service's
602
- // POST /v1/hubspot/app_pages/{app}/billing/limit. Success/error feedback is an
603
- // inline Alert, consistent with the rest of this tab.
604
- //
605
- // The POST requires a billing action token (?token=) on top of verify_hubspot
606
- // — billingActionToken should be the (interval-refreshed)
607
- // state.billing_action_tokens.portal. Without it (an older backend serving
608
- // /v1/home without minted tokens) the set/clear buttons stay disabled rather
609
- // than failing with a 401 — the same "hidden until token exists" posture as
610
- // the restart link.
611
- function BillingLimitControl({
612
- context,
613
- appKey,
614
- currentLimitCents = null,
615
- billingActionToken = null,
616
- }) {
617
- const [limitCents, setLimitCents] = useState(currentLimitCents ?? null);
618
- const [value, setValue] = useState("");
619
- const [saving, setSaving] = useState(false);
620
- const [notice, setNotice] = useState(null); // { variant, text }
621
-
622
- // The entitlement arrives with /v1/home; adopt a changed server-side limit.
623
- useEffect(() => {
624
- setLimitCents(currentLimitCents ?? null);
625
- }, [currentLimitCents]);
626
-
627
- const canSubmit = !saving && !!billingActionToken;
628
-
629
- const save = async () => {
630
- const parsed = parseLimitInput(value);
631
- if (!parsed.ok) {
632
- setNotice({ variant: "error", text: parsed.error });
633
- return;
634
- }
635
- setSaving(true);
636
- setNotice(null);
637
- try {
638
- await setBillingLimit(context, {
639
- appKey,
640
- portalId: context?.portal?.id,
641
- limitCents: parsed.limitCents,
642
- token: billingActionToken,
643
- });
644
- setLimitCents(parsed.limitCents);
645
- setValue("");
646
- setNotice({
647
- variant: "success",
648
- text: `Billing limit set to ${fmtMoney(parsed.limitCents)} per billing period.`,
649
- });
650
- } catch (e) {
651
- setNotice({
652
- variant: "error",
653
- text: e?.message ?? "Couldn't update the billing limit.",
654
- });
655
- } finally {
656
- setSaving(false);
657
- }
658
- };
659
-
660
- const clear = async () => {
661
- setSaving(true);
662
- setNotice(null);
663
- try {
664
- await setBillingLimit(context, {
665
- appKey,
666
- portalId: context?.portal?.id,
667
- limitCents: null,
668
- token: billingActionToken,
669
- });
670
- setLimitCents(null);
671
- setNotice({
672
- variant: "success",
673
- text: "Billing limit cleared — automatic top-ups are uncapped.",
674
- });
675
- } catch (e) {
676
- setNotice({
677
- variant: "error",
678
- text: e?.message ?? "Couldn't clear the billing limit.",
679
- });
680
- } finally {
681
- setSaving(false);
682
- }
683
- };
684
-
685
- return (
686
- <Flex direction="column" gap="small">
687
- <Heading>Billing limit</Heading>
688
- <Text>
689
- Cap how much automatic credit top-ups can spend each billing period.
690
- </Text>
691
- <Text format={{ fontWeight: "bold" }}>
692
- {limitCents != null
693
- ? `Current limit: ${fmtMoney(limitCents)} per billing period`
694
- : "No limit set — automatic top-ups are uncapped."}
695
- </Text>
696
- <Flex direction="row" gap="small" align="end">
697
- <Input
698
- label="Limit (USD)"
699
- name="billing-limit"
700
- placeholder="e.g. 100.00"
701
- value={value}
702
- onChange={setValue}
703
- />
704
- <LoadingButton
705
- variant="primary"
706
- loading={saving}
707
- disabled={!canSubmit || !value.trim()}
708
- onClick={save}
709
- >
710
- Set limit
711
- </LoadingButton>
712
- {limitCents != null && (
713
- <Button variant="secondary" disabled={!canSubmit} onClick={clear}>
714
- Clear limit
715
- </Button>
716
- )}
717
- </Flex>
718
- {!billingActionToken && (
719
- <Text format={{ fontStyle: "italic" }}>
720
- Preparing billing… the limit controls unlock in a moment. Refresh the
721
- page if this persists.
722
- </Text>
723
- )}
724
- {notice && (
725
- <Alert
726
- variant={notice.variant}
727
- title={notice.variant === "success" ? "Saved" : "Couldn't save"}
728
- >
729
- <Text>{notice.text}</Text>
730
- </Alert>
731
- )}
732
- </Flex>
733
- );
734
- }
735
-
736
517
  // Exported for tests (the billing_model gating of the top-up sections).
737
518
  export function CreditsBilling({ context, state, appKey, openIframe = null }) {
738
519
  const onPaidPlan = !!state?.entitlement?.plan;
@@ -817,8 +598,8 @@ export function CreditsBilling({ context, state, appKey, openIframe = null }) {
817
598
  sections (the safe direction — no dead UI). */}
818
599
  {state?.entitlement?.billing_model === "topup" && (
819
600
  <>
820
- <TopUpHistory context={context} appKey={appKey} />
821
- {/* Directly below Top-up history (Andrew's ask). `state` here is
601
+ {/* Invoice history is the one purchase list now (Top-Up History
602
+ is deleted — Andrew's 2026-10 feedback). `state` here is
822
603
  BillingTab's interval-refreshed copy, so the token is re-minted
823
604
  every 3 minutes; InvoiceHistory hides itself when the backend
824
605
  or billing service predates the pieces it needs. */}
@@ -826,13 +607,16 @@ export function CreditsBilling({ context, state, appKey, openIframe = null }) {
826
607
  billingBaseUrl={base}
827
608
  billingActionToken={state?.billing_action_tokens?.portal ?? null}
828
609
  />
829
- <BillingLimitControl
610
+ {/* "Add Additional Credits Automatically" — REPLACES the old
611
+ billing-limit control. The flag resolves from
612
+ state.auto_provision (default ON); the ON→OFF direction parks
613
+ in Andrew's exact confirmation copy. */}
614
+ <AutoProvisionToggle
830
615
  context={context}
831
616
  appKey={appKey}
832
- // Populated for all accounts on the current contract
833
- // (int cents | null; absent on older backends = no cap).
834
- currentLimitCents={state?.entitlement?.billing_limit_cents ?? null}
835
- // The limit POST's second auth dep (see setBillingLimit). `state`
617
+ enabled={resolveAutoProvision(state?.auto_provision).enabled}
618
+ appName={state?.app?.name ?? "this app"}
619
+ // The POST's second auth dep (see setAutoProvision). `state`
836
620
  // here is BillingTab's interval-refreshed copy, so this token is
837
621
  // re-minted every 3 minutes — a long-open tab never POSTs an
838
622
  // expired one.
@@ -4,7 +4,10 @@ import { InstallProgress } from "./InstallProgress";
4
4
  import { HealthHero } from "./HealthHero";
5
5
  import { OnboardingChecklist } from "./OnboardingChecklist";
6
6
  import { SyncPulse } from "./SyncPulse";
7
- import { CreditMeter } from "./CreditMeter";
7
+ import { FeatureUsage } from "./FeatureUsage";
8
+ import { TrialBanner } from "./TrialBanner";
9
+ import { PlanFitBanner } from "./PlanFitBanner";
10
+ import { PeriodComparison } from "./PeriodComparison";
8
11
  import { FeatureNudges } from "./FeatureNudges";
9
12
  import { HelpfulLinks } from "./HelpfulLinks";
10
13
 
@@ -44,17 +47,11 @@ function PipelineTile({ pipeline }) {
44
47
  }
45
48
 
46
49
  // Picks the single archetype activity tile (CONTRACT: at most ONE present).
47
- // credit_meter / credits mode -> CreditMeter; sync_pulse -> SyncPulse;
48
- // pipeline -> PipelineTile. Renders nothing when none apply.
50
+ // sync_pulse -> SyncPulse; pipeline -> PipelineTile. The credit meter is
51
+ // deliberately NOT on Home anymore (Andrew's 2026-10 feedback) — it lives at
52
+ // the top of the Usage tab; Home's credit story is the trial banner plus the
53
+ // post-checklist period comparison. Renders nothing when no tile applies.
49
54
  function ArchetypeTile({ state }) {
50
- if (state.credit_meter || state.entitlement?.mode === "credits") {
51
- return (
52
- <CreditMeter
53
- entitlement={state.entitlement}
54
- creditMeter={state.credit_meter}
55
- />
56
- );
57
- }
58
55
  if (state.sync_pulse) return <SyncPulse syncPulse={state.sync_pulse} />;
59
56
  if (state.pipeline) return <PipelineTile pipeline={state.pipeline} />;
60
57
  return null;
@@ -66,9 +63,17 @@ function ArchetypeTile({ state }) {
66
63
  // Lifecycle composition:
67
64
  // no state -> LoadingSpinner (still fetching /v1/home)
68
65
  // activation pending -> <InstallProgress> (install isn't finished yet)
69
- // activated cockpit -> <HealthHero>
66
+ // activated cockpit -> <TrialBanner> (credit apps on the free grant)
67
+ // + <HealthHero> (+ "...on the right plan" when
68
+ // plan_fit says so)
69
+ // + <PlanFitBanner> (upgrade_recommended only)
70
70
  // + <OnboardingChecklist> (only if not complete)
71
- // + the archetype activity tile (sync/credit/pipeline)
71
+ // + the archetype activity tile (sync/pipeline)
72
+ // + <PeriodComparison> (post-checklist, when the
73
+ // backend has a full closed period)
74
+ // + <FeatureUsage> (once the non-billing setup steps
75
+ // are done; needs the backend's state.feature_usage
76
+ // block)
72
77
  // + <FeatureNudges>
73
78
  //
74
79
  // Props:
@@ -111,20 +116,53 @@ export function AppHome({ state, onNavigate, appName, onRetry, helpfulLinks }) {
111
116
 
112
117
  const onboardingComplete = state.onboarding?.complete === true;
113
118
 
119
+ // FeatureUsage gate: render once every NON-BILLING onboarding step is done.
120
+ // Every registry app marks its Choose-a-plan step paid-gated (key "plan",
121
+ // routing to /billing, done only once the subscription is ACTIVE), so gating
122
+ // on onboarding.complete would hide these sections from free/trial installs
123
+ // forever — exactly the audience "Recommended to explore" is for. A fresh
124
+ // install still has its install/workflow steps open, so setup comes first.
125
+ // With no step model (older backends), fall back to the block-level flag.
126
+ const isBillingStep = (s) => s?.key === "plan" || s?.cta_route === "/billing";
127
+ const onboardingSteps = state.onboarding?.steps;
128
+ const setupStepsComplete =
129
+ Array.isArray(onboardingSteps) && onboardingSteps.length > 0
130
+ ? onboardingSteps.every((s) => s.done === true || isBillingStep(s))
131
+ : onboardingComplete;
132
+
114
133
  return (
115
134
  <Flex direction="column" gap="medium">
116
135
  {/* A completed-with-warnings activation still surfaces its warnings
117
136
  callout above the cockpit; InstallProgress renders null when clean. */}
118
137
  <InstallProgress state={state} onRetry={onRetry} />
119
138
 
139
+ {/* Trial states (active countdown / ended / out of credits) fly above
140
+ the hero — they're the strongest call to action a free account
141
+ has. Renders null for paid plans and non-credits modes. */}
142
+ <TrialBanner state={state} appName={appName} />
143
+
120
144
  <HealthHero appName={appName} state={state} />
121
145
 
146
+ {/* plan_fit upgrade_recommended — the savings banner. */}
147
+ <PlanFitBanner state={state} />
148
+
122
149
  {state.onboarding && !onboardingComplete && (
123
150
  <OnboardingChecklist onboarding={state.onboarding} onNavigate={onNavigate} />
124
151
  )}
125
152
 
126
153
  <ArchetypeTile state={state} />
127
154
 
155
+ {/* Post-checklist: the period-over-period comparison (null until the
156
+ install has one full closed period). */}
157
+ {onboardingComplete && <PeriodComparison state={state} />}
158
+
159
+ {/* Most used / recommended-to-explore (state.feature_usage) — renders
160
+ nothing on backends without the block or for legacy installs. Gated
161
+ on the non-billing setup steps (NOT the paid-gated plan step) so a
162
+ fresh install sees setup first but free/trial installs aren't
163
+ locked out. */}
164
+ {setupStepsComplete && <FeatureUsage state={state} />}
165
+
128
166
  <FeatureNudges nudges={state.nudges} onNavigate={onNavigate} />
129
167
 
130
168
  {helpfulLinksEl}