@growth-operations/ui-shared 1.29.0 → 1.30.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 +1 -1
- package/src/index.js +10 -1
- package/src/usage/BillingPeriodUsage.jsx +235 -0
- package/src/usage/UsageTab.jsx +32 -3
package/package.json
CHANGED
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,235 @@
|
|
|
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({
|
|
80
|
+
data,
|
|
81
|
+
periods,
|
|
82
|
+
appName,
|
|
83
|
+
showStats,
|
|
84
|
+
emptyTitle = "No usage in this window",
|
|
85
|
+
// Customer-facing default; internal hosts (the GOAA card) override — their
|
|
86
|
+
// viewer isn't the user, so "you run a metered action" reads wrong there.
|
|
87
|
+
emptyBody = null,
|
|
88
|
+
}) {
|
|
89
|
+
const series = data?.series ?? [];
|
|
90
|
+
const usedInWindow = data?.used_in_window ?? 0;
|
|
91
|
+
const remaining = data?.remaining;
|
|
92
|
+
const granted = data?.granted;
|
|
93
|
+
const hasBalance = remaining != null && granted != null;
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Flex direction="column" gap="medium">
|
|
97
|
+
{showStats && (
|
|
98
|
+
<Tile>
|
|
99
|
+
<Statistics>
|
|
100
|
+
<StatisticsItem label={usedWindowLabel(periods)} number={usedInWindow} />
|
|
101
|
+
{hasBalance && <StatisticsItem label="Remaining" number={remaining} />}
|
|
102
|
+
{hasBalance && <StatisticsItem label="Granted" number={granted} />}
|
|
103
|
+
</Statistics>
|
|
104
|
+
</Tile>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{series.length === 0 ? (
|
|
108
|
+
<EmptyState title={emptyTitle} layout="vertical">
|
|
109
|
+
<Text>
|
|
110
|
+
{emptyBody ??
|
|
111
|
+
`Once you run a metered ${appName} action, your credit usage shows here by billing period. Try a longer lookback.`}
|
|
112
|
+
</Text>
|
|
113
|
+
</EmptyState>
|
|
114
|
+
) : (
|
|
115
|
+
<Tile>
|
|
116
|
+
<Flex direction="column" gap="small">
|
|
117
|
+
<Text format={{ fontWeight: "bold" }}>Credits used per billing period</Text>
|
|
118
|
+
<BarChart
|
|
119
|
+
data={series}
|
|
120
|
+
axes={{
|
|
121
|
+
x: { field: "period", fieldType: "category", label: "Billing period" },
|
|
122
|
+
y: { field: "credits", fieldType: "linear", label: "Credits" },
|
|
123
|
+
// Category axes render in DATA order (ui-extensions docs), so
|
|
124
|
+
// the backend's oldest-first series gives chronological bars.
|
|
125
|
+
options: { groupFieldByColor: "feature", stacking: true },
|
|
126
|
+
}}
|
|
127
|
+
options={{ showLegend: true, showTooltips: true }}
|
|
128
|
+
/>
|
|
129
|
+
</Flex>
|
|
130
|
+
</Tile>
|
|
131
|
+
)}
|
|
132
|
+
</Flex>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function BillingPeriodUsage({
|
|
137
|
+
context,
|
|
138
|
+
basePath = DEFAULT_BASE_PATH,
|
|
139
|
+
portalId,
|
|
140
|
+
fetchUsage,
|
|
141
|
+
appName = "this app",
|
|
142
|
+
title = "Usage",
|
|
143
|
+
titleAs = "heading", // "text" for CRM-card contexts (bold Text, not Heading)
|
|
144
|
+
showStats = true,
|
|
145
|
+
emptyTitle,
|
|
146
|
+
emptyBody,
|
|
147
|
+
}) {
|
|
148
|
+
const [periods, setPeriods] = useState("1");
|
|
149
|
+
const fetcher = fetchUsage ?? makeUsageFetcher({ context, basePath, portalId });
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<Flex direction="column" gap="medium">
|
|
153
|
+
<Flex direction="row" justify="between" align="end">
|
|
154
|
+
{titleAs === "heading" ? (
|
|
155
|
+
<Heading>{title}</Heading>
|
|
156
|
+
) : (
|
|
157
|
+
<Text format={{ fontWeight: "bold" }}>{title}</Text>
|
|
158
|
+
)}
|
|
159
|
+
<Select
|
|
160
|
+
label="Billing period"
|
|
161
|
+
name="billing-period"
|
|
162
|
+
options={BILLING_PERIOD_OPTIONS}
|
|
163
|
+
value={periods}
|
|
164
|
+
onChange={setPeriods}
|
|
165
|
+
/>
|
|
166
|
+
</Flex>
|
|
167
|
+
|
|
168
|
+
{/* key={periods}: a lookback change must hard-remount everything below
|
|
169
|
+
the Select, not just re-render with new props. BarChart is a HubSpot
|
|
170
|
+
"remote component" (rendered outside this iframe via HubSpot's own
|
|
171
|
+
bridge) — a prop/data change alone was confirmed NOT to update the
|
|
172
|
+
rendered chart (the UsageTab LineChart lesson). The Select stays
|
|
173
|
+
OUTSIDE this key boundary so the control triggering the change is
|
|
174
|
+
never torn down mid-interaction. */}
|
|
175
|
+
<BillingPeriodContent
|
|
176
|
+
key={periods}
|
|
177
|
+
periods={periods}
|
|
178
|
+
fetchUsage={fetcher}
|
|
179
|
+
appName={appName}
|
|
180
|
+
showStats={showStats}
|
|
181
|
+
emptyTitle={emptyTitle}
|
|
182
|
+
emptyBody={emptyBody}
|
|
183
|
+
/>
|
|
184
|
+
</Flex>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function BillingPeriodContent({ periods, fetchUsage, appName, showStats, emptyTitle, emptyBody }) {
|
|
189
|
+
const [data, setData] = useState(null);
|
|
190
|
+
const [loading, setLoading] = useState(true);
|
|
191
|
+
const [error, setError] = useState(null);
|
|
192
|
+
|
|
193
|
+
useStrictModeEffect(
|
|
194
|
+
async ({ mounted }) => {
|
|
195
|
+
setLoading(true);
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetchUsage(periods);
|
|
198
|
+
if (mounted.current) {
|
|
199
|
+
setData(res);
|
|
200
|
+
setError(null);
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (mounted.current) setError(String(err));
|
|
204
|
+
} finally {
|
|
205
|
+
if (mounted.current) setLoading(false);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
[periods, fetchUsage]
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (loading) return <LoadingSpinner showLabel label="Loading usage…" />;
|
|
212
|
+
|
|
213
|
+
if (error) {
|
|
214
|
+
return (
|
|
215
|
+
<Alert title="Couldn't load usage" variant="warning">
|
|
216
|
+
<Box flex="auto">
|
|
217
|
+
<Text>{error}</Text>
|
|
218
|
+
</Box>
|
|
219
|
+
</Alert>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<BillingPeriodUsageView
|
|
225
|
+
data={data}
|
|
226
|
+
periods={periods}
|
|
227
|
+
appName={appName}
|
|
228
|
+
showStats={showStats}
|
|
229
|
+
emptyTitle={emptyTitle}
|
|
230
|
+
emptyBody={emptyBody}
|
|
231
|
+
/>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export default BillingPeriodUsage;
|
package/src/usage/UsageTab.jsx
CHANGED
|
@@ -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={
|
|
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
|
-
|
|
127
|
+
</>
|
|
99
128
|
);
|
|
100
129
|
}
|
|
101
130
|
|