@growth-operations/ui-shared 1.28.0 → 1.30.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.28.0",
3
+ "version": "1.30.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -61,19 +61,25 @@ function RecommendedTile({ notInUse, isSuperAdmin, onNavigate }) {
61
61
  <Flex direction="column" gap="small">
62
62
  <Text format={{ fontWeight: "bold" }}>Recommended to explore</Text>
63
63
  {picks.map((f) => {
64
- // Setup CTAs are gated PER ITEM (1.28.0): adminOnly items point at
65
- // HubSpot permission-wall surfaces (the layout editor above all) —
66
- // super admins only, since a non-admin clicking into a permission
67
- // wall is worse than no link. Items marked adminOnly: false are
68
- // per-user surfaces (install the Chrome extension, open the in-app
69
- // extension page) and render for everyone. Old backends emit no
70
- // marker → adminOnly defaults true → the blanket gate is preserved.
64
+ // Setup CTAs render for EVERYONE (1.29.0, Jasper 2026-10-14: "why
65
+ // gate so much on super admin?"). The old blanket super-admin gate
66
+ // assumed the targets were admin-only surfaces — but HubSpot
67
+ // permissions are granular (CRM customization ≠ super admin), and
68
+ // hiding the CTA kept non-admins from even knowing what to ask
69
+ // their admin for. adminOnly items instead carry a "(Super Admin)"
70
+ // hint for non-admin viewers, so the permission wall is never a
71
+ // blind click. The adminOnly marker still distinguishes the two
72
+ // surface kinds; old backends emit no marker → every item gets the
73
+ // hint for non-admins (conservative, and matches the old gate's
74
+ // assumption that the targets are admin surfaces).
71
75
  //
72
76
  // "Learn more →" (docs_url) intentionally NOT rendered (Jasper
73
77
  // 2026-10-14): every feature's docs_url points at the same single
74
78
  // setup guide — noise until per-feature articles exist. docs_url
75
79
  // stays in the wire contract; re-enabling is a one-line revert.
76
- const ctas = f.ctas.filter((c) => !c.adminOnly || isSuperAdmin);
80
+ const ctas = f.ctas;
81
+ const hint = (c) =>
82
+ c.adminOnly && !isSuperAdmin ? " (Super Admin)" : "";
77
83
  return (
78
84
  <Flex key={f.key} direction="column" gap="extra-small">
79
85
  <Text format={{ fontWeight: "demibold" }}>{f.name}</Text>
@@ -81,7 +87,8 @@ function RecommendedTile({ notInUse, isSuperAdmin, onNavigate }) {
81
87
  {ctas.map((cta) =>
82
88
  cta.url ? (
83
89
  <Link key={cta.label} href={{ url: cta.url, external: true }}>
84
- {cta.label} →
90
+ {cta.label}
91
+ {hint(cta)} →
85
92
  </Link>
86
93
  ) : onNavigate ? (
87
94
  <Button
@@ -90,6 +97,7 @@ function RecommendedTile({ notInUse, isSuperAdmin, onNavigate }) {
90
97
  onClick={() => onNavigate(cta.route)}
91
98
  >
92
99
  {cta.label}
100
+ {hint(cta)}
93
101
  </Button>
94
102
  ) : null
95
103
  )}
package/src/index.js CHANGED
@@ -28,8 +28,17 @@ export { AutoProvisionToggle } from "./billing/AutoProvisionToggle";
28
28
  // Alerts — the shared customer alerts table (level filter, sort, dismiss).
29
29
  export { AlertsTab } from "./alerts/AlertsTab";
30
30
 
31
- // Usage — the shared credit usage view (per-day LineChart + window stats).
31
+ // Usage — the shared credit usage view (per-day LineChart + window stats, or
32
+ // the billing-period stacked BarChart via UsageTab chart="billingPeriods").
32
33
  export { UsageTab } from "./usage/UsageTab";
34
+ export {
35
+ BillingPeriodUsage,
36
+ BillingPeriodUsageView,
37
+ BILLING_PERIOD_OPTIONS,
38
+ makeUsageFetcher,
39
+ usageUrl,
40
+ usedWindowLabel,
41
+ } from "./usage/BillingPeriodUsage";
33
42
 
34
43
  // Sandbox — the shared "Install on a sandbox" action (mints a signed link).
35
44
  export { SandboxInstall } from "./sandbox/SandboxInstall";
@@ -0,0 +1,220 @@
1
+ // BillingPeriodUsage — usage-over-time chart bucketed by BILLING PERIOD.
2
+ //
3
+ // The billing-period alternative to UsageTab's rolling-days LineChart
4
+ // (Jasper 2026-10-14: the internal Installation card went billing-period —
5
+ // "Current / Last 2 / Last 3 / Last 6 / Last 12" — and the customer Usage
6
+ // tab converts to the exact same style; one shared component serves both).
7
+ // One stacked BarChart, one bar per billing period (backend-supplied label,
8
+ // e.g. "Oct 12 – Nov 12"), stacks = features, with the lookback Select in
9
+ // the heading row.
10
+ //
11
+ // Backend contract: GET {base}{basePath}?portalId=<id>&periods=<N> →
12
+ // { series: [{ period: "Oct 12 – Nov 12", feature: "Fit Scores", credits: 3 }],
13
+ // used_in_window: <int>, granted: <int|null>, remaining: <int|null> }
14
+ // `series` is sparse (no zero-filled bars); window math lives in common's
15
+ // billing_period_windows + CreditService.usage_series_by_period.
16
+ //
17
+ // fetchUsage override: non-standard hosts (the GOAA internal card, whose
18
+ // endpoint is keyed by installation id, not portalId) pass their own
19
+ // (periods) => Promise<{series, ...}>; the default hits the standard
20
+ // basePath/portalId URL via hubspot.fetch.
21
+ import React, { useState } from "react";
22
+ import {
23
+ hubspot,
24
+ Alert,
25
+ BarChart,
26
+ Box,
27
+ EmptyState,
28
+ Flex,
29
+ Heading,
30
+ LoadingSpinner,
31
+ Select,
32
+ Statistics,
33
+ StatisticsItem,
34
+ Text,
35
+ Tile,
36
+ } from "@hubspot/ui-extensions";
37
+
38
+ import { useStrictModeEffect } from "../lib/useStrictModeEffect";
39
+
40
+ const DEFAULT_BASE_PATH = "/api/v1/usage";
41
+
42
+ // The dropdown is billing-period scoped, not day scoped: "Current" = just
43
+ // the in-flight period; "Last N" = N periods ending with the current one.
44
+ export const BILLING_PERIOD_OPTIONS = [
45
+ { label: "Current", value: "1" },
46
+ { label: "Last 2", value: "2" },
47
+ { label: "Last 3", value: "3" },
48
+ { label: "Last 6", value: "6" },
49
+ { label: "Last 12", value: "12" },
50
+ ];
51
+
52
+ export function usedWindowLabel(periodsValue) {
53
+ return periodsValue === "1"
54
+ ? "Used (current billing period)"
55
+ : `Used (last ${periodsValue} billing periods)`;
56
+ }
57
+
58
+ export function usageUrl({ baseUrl = "", basePath, portalId, periods }) {
59
+ return `${baseUrl}${basePath}?portalId=${portalId}&periods=${periods}`;
60
+ }
61
+
62
+ // The standard fetcher: the app's own usage endpoint with a periods param.
63
+ // Exported so hosts with the standard URL shape but custom fetch needs (auth
64
+ // headers, etc.) can wrap it.
65
+ export function makeUsageFetcher({ context, basePath = DEFAULT_BASE_PATH, portalId }) {
66
+ return async (periods) => {
67
+ const base = context.variables?.BASE_URL ?? "";
68
+ const pid = portalId ?? context.portal.id;
69
+ const res = await hubspot.fetch(usageUrl({ baseUrl: base, basePath, portalId: pid, periods }), {
70
+ method: "GET",
71
+ timeout: 15000,
72
+ });
73
+ return res.json();
74
+ };
75
+ }
76
+
77
+ // Hook-free presentational core (exported for tests): stats + stacked chart
78
+ // from an already-loaded payload.
79
+ export function BillingPeriodUsageView({ data, periods, appName, showStats }) {
80
+ const series = data?.series ?? [];
81
+ const usedInWindow = data?.used_in_window ?? 0;
82
+ const remaining = data?.remaining;
83
+ const granted = data?.granted;
84
+ const hasBalance = remaining != null && granted != null;
85
+
86
+ return (
87
+ <Flex direction="column" gap="medium">
88
+ {showStats && (
89
+ <Tile>
90
+ <Statistics>
91
+ <StatisticsItem label={usedWindowLabel(periods)} number={usedInWindow} />
92
+ {hasBalance && <StatisticsItem label="Remaining" number={remaining} />}
93
+ {hasBalance && <StatisticsItem label="Granted" number={granted} />}
94
+ </Statistics>
95
+ </Tile>
96
+ )}
97
+
98
+ {series.length === 0 ? (
99
+ <EmptyState title="No usage in this window" layout="vertical">
100
+ <Text>
101
+ Once you run a metered {appName} action, your credit usage shows
102
+ here by billing period. Try a longer lookback.
103
+ </Text>
104
+ </EmptyState>
105
+ ) : (
106
+ <Tile>
107
+ <Flex direction="column" gap="small">
108
+ <Text format={{ fontWeight: "bold" }}>Credits used per billing period</Text>
109
+ <BarChart
110
+ data={series}
111
+ axes={{
112
+ x: { field: "period", fieldType: "category", label: "Billing period" },
113
+ y: { field: "credits", fieldType: "linear", label: "Credits" },
114
+ // Category axes render in DATA order (ui-extensions docs), so
115
+ // the backend's oldest-first series gives chronological bars.
116
+ options: { groupFieldByColor: "feature", stacking: true },
117
+ }}
118
+ options={{ showLegend: true, showTooltips: true }}
119
+ />
120
+ </Flex>
121
+ </Tile>
122
+ )}
123
+ </Flex>
124
+ );
125
+ }
126
+
127
+ export function BillingPeriodUsage({
128
+ context,
129
+ basePath = DEFAULT_BASE_PATH,
130
+ portalId,
131
+ fetchUsage,
132
+ appName = "this app",
133
+ title = "Usage",
134
+ titleAs = "heading", // "text" for CRM-card contexts (bold Text, not Heading)
135
+ showStats = true,
136
+ }) {
137
+ const [periods, setPeriods] = useState("1");
138
+ const fetcher = fetchUsage ?? makeUsageFetcher({ context, basePath, portalId });
139
+
140
+ return (
141
+ <Flex direction="column" gap="medium">
142
+ <Flex direction="row" justify="between" align="end">
143
+ {titleAs === "heading" ? (
144
+ <Heading>{title}</Heading>
145
+ ) : (
146
+ <Text format={{ fontWeight: "bold" }}>{title}</Text>
147
+ )}
148
+ <Select
149
+ label="Billing period"
150
+ name="billing-period"
151
+ options={BILLING_PERIOD_OPTIONS}
152
+ value={periods}
153
+ onChange={setPeriods}
154
+ />
155
+ </Flex>
156
+
157
+ {/* key={periods}: a lookback change must hard-remount everything below
158
+ the Select, not just re-render with new props. BarChart is a HubSpot
159
+ "remote component" (rendered outside this iframe via HubSpot's own
160
+ bridge) — a prop/data change alone was confirmed NOT to update the
161
+ rendered chart (the UsageTab LineChart lesson). The Select stays
162
+ OUTSIDE this key boundary so the control triggering the change is
163
+ never torn down mid-interaction. */}
164
+ <BillingPeriodContent
165
+ key={periods}
166
+ periods={periods}
167
+ fetchUsage={fetcher}
168
+ appName={appName}
169
+ showStats={showStats}
170
+ />
171
+ </Flex>
172
+ );
173
+ }
174
+
175
+ function BillingPeriodContent({ periods, fetchUsage, appName, showStats }) {
176
+ const [data, setData] = useState(null);
177
+ const [loading, setLoading] = useState(true);
178
+ const [error, setError] = useState(null);
179
+
180
+ useStrictModeEffect(
181
+ async ({ mounted }) => {
182
+ setLoading(true);
183
+ try {
184
+ const res = await fetchUsage(periods);
185
+ if (mounted.current) {
186
+ setData(res);
187
+ setError(null);
188
+ }
189
+ } catch (err) {
190
+ if (mounted.current) setError(String(err));
191
+ } finally {
192
+ if (mounted.current) setLoading(false);
193
+ }
194
+ },
195
+ [periods, fetchUsage]
196
+ );
197
+
198
+ if (loading) return <LoadingSpinner showLabel label="Loading usage…" />;
199
+
200
+ if (error) {
201
+ return (
202
+ <Alert title="Couldn't load usage" variant="warning">
203
+ <Box flex="auto">
204
+ <Text>{error}</Text>
205
+ </Box>
206
+ </Alert>
207
+ );
208
+ }
209
+
210
+ return (
211
+ <BillingPeriodUsageView
212
+ data={data}
213
+ periods={periods}
214
+ appName={appName}
215
+ showStats={showStats}
216
+ />
217
+ );
218
+ }
219
+
220
+ export default BillingPeriodUsage;
@@ -30,6 +30,7 @@ import {
30
30
  import { useStrictModeEffect } from "../lib/useStrictModeEffect";
31
31
  import { creditsBlockOf } from "../lib/creditDisplay";
32
32
  import { CreditMeter } from "../home/CreditMeter";
33
+ import { BillingPeriodUsage } from "./BillingPeriodUsage";
33
34
 
34
35
  const DEFAULT_BASE_PATH = "/api/v1/usage";
35
36
 
@@ -45,13 +46,16 @@ const WINDOW_OPTIONS = [
45
46
  // (base-hosted apps pass both). appName: chart copy. state: the /v1/home
46
47
  // payload (optional) — when present, the credit meter renders at the TOP of
47
48
  // the tab (Andrew's 2026-10 feedback: the meter moved off Home); without it
48
- // the tab is chart-only, same as before.
49
+ // the tab is chart-only, same as before. chart: "days" (default, rolling-day
50
+ // LineChart) or "billingPeriods" (stacked BarChart bucketed by billing period
51
+ // — requires the backend's ?periods= mode, shipped in common v1.35.0).
49
52
  export function UsageTab({
50
53
  context,
51
54
  basePath = DEFAULT_BASE_PATH,
52
55
  portalId,
53
56
  appName = "this app",
54
57
  state = null,
58
+ chart = "days",
55
59
  }) {
56
60
  const [days, setDays] = useState("30");
57
61
 
@@ -68,6 +72,31 @@ export function UsageTab({
68
72
  />
69
73
  )}
70
74
 
75
+ {chart === "billingPeriods" ? (
76
+ <BillingPeriodUsage
77
+ context={context}
78
+ basePath={basePath}
79
+ portalId={portalId}
80
+ appName={appName}
81
+ title="Usage"
82
+ />
83
+ ) : (
84
+ <DaysUsage
85
+ days={days}
86
+ onDaysChange={setDays}
87
+ context={context}
88
+ basePath={basePath}
89
+ portalId={portalId}
90
+ appName={appName}
91
+ />
92
+ )}
93
+ </Flex>
94
+ );
95
+ }
96
+
97
+ function DaysUsage({ days, onDaysChange, context, basePath, portalId, appName }) {
98
+ return (
99
+ <>
71
100
  <Flex direction="row" justify="between" align="end">
72
101
  <Heading>Usage</Heading>
73
102
  <Select
@@ -75,7 +104,7 @@ export function UsageTab({
75
104
  name="time-period"
76
105
  options={WINDOW_OPTIONS}
77
106
  value={days}
78
- onChange={setDays}
107
+ onChange={onDaysChange}
79
108
  />
80
109
  </Flex>
81
110
 
@@ -95,7 +124,7 @@ export function UsageTab({
95
124
  appName={appName}
96
125
  days={days}
97
126
  />
98
- </Flex>
127
+ </>
99
128
  );
100
129
  }
101
130