@growth-operations/ui-shared 1.0.10 → 1.2.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 +1 -1
- package/src/alerts/AlertsTab.jsx +246 -0
- package/src/billing/BillingTab.jsx +40 -23
- package/src/billing/PlanGrid.jsx +208 -0
- package/src/index.js +5 -0
- package/src/lib/format.js +17 -0
- package/src/sdk/app/alerts.js +28 -7
package/package.json
CHANGED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// AlertsTab — the shared customer alerts table, extensible per app.
|
|
2
|
+
//
|
|
3
|
+
// Every Growth Ops app surfaces the same base Alert shape ({ id, level, title,
|
|
4
|
+
// message, created_at, dismissable, action_name, action_url }) from the same
|
|
5
|
+
// serve/dismiss contract (common.alerts + the app's alerts router). This owns
|
|
6
|
+
// the shared chrome — level filter, pagination, fetch, and per-row dismiss —
|
|
7
|
+
// and exposes two extension points so apps with richer alert shapes (sparkfly's
|
|
8
|
+
// merge / replace-email / skip; toast's connection-lost retry) keep one table
|
|
9
|
+
// instead of forking it:
|
|
10
|
+
//
|
|
11
|
+
// - renderRowActions(alert, { context, refresh }) -> node
|
|
12
|
+
// Extra action UI rendered in the actions cell ALONGSIDE Dismiss. Use for
|
|
13
|
+
// app-specific buttons that act on the alert then refresh the table.
|
|
14
|
+
// - renderRow(alert, { context, refresh, dismiss, fetchOptions }) -> <TableRow>
|
|
15
|
+
// Full per-row override (escape hatch) for apps whose row needs custom
|
|
16
|
+
// layout — expandable merge panels, inline inputs, extra columns. When
|
|
17
|
+
// provided it replaces the default row entirely; the app owns the cells.
|
|
18
|
+
//
|
|
19
|
+
// Omit both -> default rows (level tag, title/message + action link, time,
|
|
20
|
+
// Dismiss). Self-hosted apps (sparkfly, toast) call with default getAlerts/
|
|
21
|
+
// dismissAlert paths; base-hosted apps pass `basePath` + `portalId` so the SDK
|
|
22
|
+
// targets the base alerts route.
|
|
23
|
+
import React, { useState, useEffect } from "react";
|
|
24
|
+
import {
|
|
25
|
+
Alert,
|
|
26
|
+
EmptyState,
|
|
27
|
+
Text,
|
|
28
|
+
Link,
|
|
29
|
+
Button,
|
|
30
|
+
Table,
|
|
31
|
+
TableHead,
|
|
32
|
+
TableHeader,
|
|
33
|
+
TableBody,
|
|
34
|
+
TableRow,
|
|
35
|
+
TableCell,
|
|
36
|
+
Select,
|
|
37
|
+
Tag,
|
|
38
|
+
Flex,
|
|
39
|
+
LoadingSpinner,
|
|
40
|
+
} from "@hubspot/ui-extensions";
|
|
41
|
+
|
|
42
|
+
import { getAlerts, dismissAlert } from "../sdk/app/alerts";
|
|
43
|
+
import { fmtDate } from "../lib/format";
|
|
44
|
+
|
|
45
|
+
const PAGE_SIZE = 10;
|
|
46
|
+
|
|
47
|
+
const LEVEL_OPTIONS = [
|
|
48
|
+
{ label: "All Levels", value: "all" },
|
|
49
|
+
{ label: "Info", value: "info" },
|
|
50
|
+
{ label: "Warning", value: "warning" },
|
|
51
|
+
{ label: "Error", value: "error" },
|
|
52
|
+
{ label: "Critical", value: "critical" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// Alert level -> HubSpot Tag variant.
|
|
56
|
+
const LEVEL_VARIANT = {
|
|
57
|
+
info: "default",
|
|
58
|
+
warning: "warning",
|
|
59
|
+
error: "error",
|
|
60
|
+
critical: "error",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function DefaultAlertRow({
|
|
64
|
+
context,
|
|
65
|
+
alert,
|
|
66
|
+
refresh,
|
|
67
|
+
fetchOptions,
|
|
68
|
+
renderRowActions,
|
|
69
|
+
}) {
|
|
70
|
+
const [dismissing, setDismissing] = useState(false);
|
|
71
|
+
|
|
72
|
+
const dismiss = async () => {
|
|
73
|
+
setDismissing(true);
|
|
74
|
+
try {
|
|
75
|
+
await dismissAlert(context, alert.id, fetchOptions);
|
|
76
|
+
await refresh();
|
|
77
|
+
} finally {
|
|
78
|
+
setDismissing(false);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<TableRow>
|
|
84
|
+
<TableCell>
|
|
85
|
+
<Tag variant={LEVEL_VARIANT[alert.level] || "default"}>
|
|
86
|
+
{alert.level}
|
|
87
|
+
</Tag>
|
|
88
|
+
</TableCell>
|
|
89
|
+
<TableCell>
|
|
90
|
+
<Flex direction="column" gap="extra-small">
|
|
91
|
+
<Text format={{ fontWeight: "bold" }}>{alert.title}</Text>
|
|
92
|
+
<Text>{alert.message}</Text>
|
|
93
|
+
{alert.action_url && alert.action_name && (
|
|
94
|
+
<Link href={alert.action_url}>{alert.action_name}</Link>
|
|
95
|
+
)}
|
|
96
|
+
</Flex>
|
|
97
|
+
</TableCell>
|
|
98
|
+
<TableCell>{fmtDate(alert.created_at)}</TableCell>
|
|
99
|
+
<TableCell>
|
|
100
|
+
<Flex direction="row" gap="small" align="center">
|
|
101
|
+
{/* App-specific actions render before Dismiss. */}
|
|
102
|
+
{renderRowActions && renderRowActions(alert, { context, refresh })}
|
|
103
|
+
{alert.dismissable && (
|
|
104
|
+
<Button
|
|
105
|
+
variant="secondary"
|
|
106
|
+
size="xs"
|
|
107
|
+
disabled={dismissing}
|
|
108
|
+
onClick={dismiss}
|
|
109
|
+
>
|
|
110
|
+
{dismissing ? "Dismissing…" : "Dismiss"}
|
|
111
|
+
</Button>
|
|
112
|
+
)}
|
|
113
|
+
</Flex>
|
|
114
|
+
</TableCell>
|
|
115
|
+
</TableRow>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// context: extension context. basePath/portalId: forwarded to getAlerts/
|
|
120
|
+
// dismissAlert (base-hosted apps pass both; self-hosted apps omit them).
|
|
121
|
+
// renderRowActions / renderRow: per-app extension points (see file header).
|
|
122
|
+
export function AlertsTab({
|
|
123
|
+
context,
|
|
124
|
+
basePath,
|
|
125
|
+
portalId,
|
|
126
|
+
renderRowActions,
|
|
127
|
+
renderRow,
|
|
128
|
+
}) {
|
|
129
|
+
const [alerts, setAlerts] = useState([]);
|
|
130
|
+
const [total, setTotal] = useState(0);
|
|
131
|
+
const [page, setPage] = useState(1);
|
|
132
|
+
const [level, setLevel] = useState("all");
|
|
133
|
+
const [loading, setLoading] = useState(true);
|
|
134
|
+
const [error, setError] = useState(null);
|
|
135
|
+
|
|
136
|
+
// The options the SDK needs to target the right backend (base vs self-hosted).
|
|
137
|
+
const fetchOptions = { basePath, portalId };
|
|
138
|
+
|
|
139
|
+
const load = async (pageNumber, levelValue) => {
|
|
140
|
+
setLoading(true);
|
|
141
|
+
try {
|
|
142
|
+
const response = await getAlerts(context, {
|
|
143
|
+
offset: (pageNumber - 1) * PAGE_SIZE,
|
|
144
|
+
limit: PAGE_SIZE,
|
|
145
|
+
orderBy: "created_at",
|
|
146
|
+
orderDirection: "DESCENDING",
|
|
147
|
+
filterField: levelValue !== "all" ? "level" : undefined,
|
|
148
|
+
filterValue: levelValue !== "all" ? levelValue : undefined,
|
|
149
|
+
...fetchOptions,
|
|
150
|
+
});
|
|
151
|
+
setAlerts(response.alerts || []);
|
|
152
|
+
setTotal(response.total || 0);
|
|
153
|
+
setPage(pageNumber);
|
|
154
|
+
setError(null);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
setError(err.message);
|
|
157
|
+
setAlerts([]);
|
|
158
|
+
setTotal(0);
|
|
159
|
+
} finally {
|
|
160
|
+
setLoading(false);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
load(1, level);
|
|
166
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
+
}, [context]);
|
|
168
|
+
|
|
169
|
+
const onLevelChange = (value) => {
|
|
170
|
+
setLevel(value);
|
|
171
|
+
load(1, value);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (error) {
|
|
175
|
+
return (
|
|
176
|
+
<Alert title="Couldn't load alerts" variant="error">
|
|
177
|
+
{error}
|
|
178
|
+
</Alert>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<Flex direction="column" gap="medium">
|
|
184
|
+
<Select
|
|
185
|
+
label="Filter by level"
|
|
186
|
+
name="levelFilter"
|
|
187
|
+
options={LEVEL_OPTIONS}
|
|
188
|
+
value={level}
|
|
189
|
+
onChange={onLevelChange}
|
|
190
|
+
/>
|
|
191
|
+
{loading ? (
|
|
192
|
+
<LoadingSpinner showLabel label="Loading alerts…" />
|
|
193
|
+
) : !alerts.length ? (
|
|
194
|
+
<EmptyState title="No alerts" layout="vertical">
|
|
195
|
+
<Text>There are currently no alerts that need your attention.</Text>
|
|
196
|
+
</EmptyState>
|
|
197
|
+
) : (
|
|
198
|
+
<Table
|
|
199
|
+
paginated={total > PAGE_SIZE}
|
|
200
|
+
page={page}
|
|
201
|
+
pageCount={Math.ceil(total / PAGE_SIZE)}
|
|
202
|
+
onPageChange={(p) => load(p, level)}
|
|
203
|
+
>
|
|
204
|
+
<TableHead>
|
|
205
|
+
<TableHeader width="min">Level</TableHeader>
|
|
206
|
+
<TableHeader width="max">Alert</TableHeader>
|
|
207
|
+
<TableHeader width="min">Time</TableHeader>
|
|
208
|
+
<TableHeader width="min"></TableHeader>
|
|
209
|
+
</TableHead>
|
|
210
|
+
<TableBody>
|
|
211
|
+
{alerts.map((alert) => {
|
|
212
|
+
const refresh = () => load(page, level);
|
|
213
|
+
// Full-row override escape hatch: the app owns the cells.
|
|
214
|
+
if (renderRow) {
|
|
215
|
+
const dismiss = () =>
|
|
216
|
+
dismissAlert(context, alert.id, fetchOptions).then(refresh);
|
|
217
|
+
return (
|
|
218
|
+
<React.Fragment key={alert.id}>
|
|
219
|
+
{renderRow(alert, {
|
|
220
|
+
context,
|
|
221
|
+
refresh,
|
|
222
|
+
dismiss,
|
|
223
|
+
fetchOptions,
|
|
224
|
+
})}
|
|
225
|
+
</React.Fragment>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return (
|
|
229
|
+
<DefaultAlertRow
|
|
230
|
+
key={alert.id}
|
|
231
|
+
context={context}
|
|
232
|
+
alert={alert}
|
|
233
|
+
fetchOptions={fetchOptions}
|
|
234
|
+
refresh={refresh}
|
|
235
|
+
renderRowActions={renderRowActions}
|
|
236
|
+
/>
|
|
237
|
+
);
|
|
238
|
+
})}
|
|
239
|
+
</TableBody>
|
|
240
|
+
</Table>
|
|
241
|
+
)}
|
|
242
|
+
</Flex>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export default AlertsTab;
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { useStrictModeEffect } from "../lib/useStrictModeEffect";
|
|
12
12
|
import { fmtDate, daysUntil } from "../lib/format";
|
|
13
13
|
import { CreditMeter } from "../home/CreditMeter";
|
|
14
|
+
import { PlanGrid } from "./PlanGrid";
|
|
14
15
|
|
|
15
16
|
// The shared Billing tab for all Growth Operations apps. Branches on the
|
|
16
17
|
// entitlement union (CONTRACT.md):
|
|
@@ -284,11 +285,17 @@ function TrialSubscriptionBilling({ context, state, appKey }) {
|
|
|
284
285
|
}
|
|
285
286
|
|
|
286
287
|
function CreditsBilling({ context, state, appKey }) {
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
288
|
+
// The Stripe portal session lets an EXISTING paid customer manage their plan
|
|
289
|
+
// / payment method / invoices (same pattern as the trial arm). NEW plan
|
|
290
|
+
// selection is the <PlanGrid> below, which pre-creates a per-tier checkout.
|
|
291
|
+
const { portalUrl, loading, error, notReady } = usePortalSession({
|
|
292
|
+
context,
|
|
293
|
+
state,
|
|
294
|
+
appKey,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const hasPlans = (state?.plans?.length ?? 0) > 0;
|
|
298
|
+
const onPaidPlan = !!state?.entitlement?.plan;
|
|
292
299
|
|
|
293
300
|
return (
|
|
294
301
|
<Flex direction="column" gap="medium">
|
|
@@ -297,26 +304,36 @@ function CreditsBilling({ context, state, appKey }) {
|
|
|
297
304
|
creditMeter={state?.credit_meter}
|
|
298
305
|
/>
|
|
299
306
|
|
|
300
|
-
{/*
|
|
301
|
-
|
|
302
|
-
{
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
307
|
+
{/* The dynamically-rendered upgrade path (tiers from the Stripe catalog).
|
|
308
|
+
Renders nothing if no plans are mirrored yet. */}
|
|
309
|
+
<PlanGrid context={context} state={state} appKey={appKey} />
|
|
310
|
+
|
|
311
|
+
{/* Manage-billing link: relevant once the customer is on a paid plan
|
|
312
|
+
(change/cancel/invoices in Stripe). Hidden in the not-ready
|
|
313
|
+
(un-provisioned) state. When no plans are mirrored AND not on a paid
|
|
314
|
+
plan, fall back to the portal as the only CTA so the tab isn't empty. */}
|
|
315
|
+
{!notReady && (onPaidPlan || !hasPlans) &&
|
|
316
|
+
(error ? (
|
|
317
|
+
<Alert title="Couldn't open billing" variant="danger">
|
|
318
|
+
<Text>{error}</Text>
|
|
319
|
+
</Alert>
|
|
320
|
+
) : (
|
|
321
|
+
<LoadingButton
|
|
322
|
+
href={portalUrl ? { url: portalUrl, external: true } : undefined}
|
|
323
|
+
loading={loading}
|
|
324
|
+
disabled={!portalUrl}
|
|
325
|
+
variant={onPaidPlan ? "secondary" : "primary"}
|
|
326
|
+
>
|
|
327
|
+
{portalUrl
|
|
328
|
+
? onPaidPlan
|
|
329
|
+
? "Manage billing in Stripe"
|
|
330
|
+
: "Buy credits in Stripe"
|
|
331
|
+
: "Preparing billing…"}
|
|
332
|
+
</LoadingButton>
|
|
333
|
+
))}
|
|
316
334
|
|
|
317
335
|
<Text format={{ fontStyle: "italic" }}>
|
|
318
|
-
|
|
319
|
-
managed in Stripe across all Growth Operations apps.
|
|
336
|
+
Billing is managed in Stripe across all Growth Operations apps.
|
|
320
337
|
</Text>
|
|
321
338
|
</Flex>
|
|
322
339
|
);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
hubspot,
|
|
4
|
+
Flex,
|
|
5
|
+
Tile,
|
|
6
|
+
Text,
|
|
7
|
+
Heading,
|
|
8
|
+
StatusTag,
|
|
9
|
+
LoadingButton,
|
|
10
|
+
Button,
|
|
11
|
+
Link,
|
|
12
|
+
Alert,
|
|
13
|
+
ToggleGroup,
|
|
14
|
+
} from "@hubspot/ui-extensions";
|
|
15
|
+
import { fmtMoney } from "../lib/format";
|
|
16
|
+
|
|
17
|
+
// The credit-app upgrade path: a dynamically-rendered grid of the pickable
|
|
18
|
+
// plans (tiers) a customer can move to, driven entirely by the backend `plans`
|
|
19
|
+
// block (CONTRACT.md → common.billing.catalog.get_plans). The shared package is
|
|
20
|
+
// presentational — it never hardcodes tiers/prices/features; everything comes
|
|
21
|
+
// from the Stripe catalog mirror via the payload.
|
|
22
|
+
//
|
|
23
|
+
// Per-tier checkout follows the §5.3 UI-extension gotchas: Stripe can't be
|
|
24
|
+
// iframed and there's no open-URL action — only <Link>/Button-with-href, whose
|
|
25
|
+
// href must exist before render. So a tier's "Choose" click POSTs to the
|
|
26
|
+
// billing service to PRE-CREATE a hosted checkout session for that price_id,
|
|
27
|
+
// then renders the returned URL as an external link the customer clicks. The
|
|
28
|
+
// extension can't read Stripe's redirect params (sandboxed iframe), so the
|
|
29
|
+
// post-payment credit grant is server-side (Stripe webhook → grant_credits).
|
|
30
|
+
//
|
|
31
|
+
// Props:
|
|
32
|
+
// context — UI-extension serverless context (context.portal.id).
|
|
33
|
+
// state — the /v1/home payload (plans[], billing_base_url, app_id).
|
|
34
|
+
// appKey — the billing app_key for THIS app (always a prop, never hardcoded).
|
|
35
|
+
|
|
36
|
+
// One plan card. Shows tier name, price for the selected interval, credits,
|
|
37
|
+
// feature bullets, and a CTA: "Current" (disabled) for the active tier,
|
|
38
|
+
// "Contact sales" for talk_to_sales tiers, else "Choose" which pre-creates a
|
|
39
|
+
// checkout session for that tier's price in the selected interval.
|
|
40
|
+
function PlanCard({ plan, interval, onChoose, choosing, checkoutUrl, supportUrl }) {
|
|
41
|
+
const leg = interval === "annual" ? plan.annual : plan.monthly;
|
|
42
|
+
const periodLabel = interval === "annual" ? "/yr" : "/mo";
|
|
43
|
+
|
|
44
|
+
const priceText = plan.talk_to_sales
|
|
45
|
+
? "Custom"
|
|
46
|
+
: leg
|
|
47
|
+
? `${fmtMoney(leg.unit_amount, leg.currency)}${periodLabel}`
|
|
48
|
+
: "Free";
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Tile>
|
|
52
|
+
<Flex direction="column" gap="small">
|
|
53
|
+
<Flex direction="row" gap="small" align="center">
|
|
54
|
+
<Heading>{plan.name ?? plan.tier}</Heading>
|
|
55
|
+
{plan.current && <StatusTag variant="success">Current plan</StatusTag>}
|
|
56
|
+
</Flex>
|
|
57
|
+
|
|
58
|
+
<Text format={{ fontWeight: "bold", fontSize: "lg" }}>{priceText}</Text>
|
|
59
|
+
|
|
60
|
+
{plan.credits_per_period != null && (
|
|
61
|
+
<Text>
|
|
62
|
+
{plan.credits_per_period.toLocaleString()} credits / month
|
|
63
|
+
</Text>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
{(plan.features ?? []).map((f, i) => (
|
|
67
|
+
<Text key={i}>• {f}</Text>
|
|
68
|
+
))}
|
|
69
|
+
|
|
70
|
+
{/* CTA. Current tier => disabled marker. talk_to_sales => contact link.
|
|
71
|
+
Otherwise pre-create-then-link checkout for the selected interval. */}
|
|
72
|
+
{plan.current ? (
|
|
73
|
+
<Button disabled>Current plan</Button>
|
|
74
|
+
) : plan.talk_to_sales ? (
|
|
75
|
+
<Button
|
|
76
|
+
href={supportUrl ? { url: supportUrl, external: true } : undefined}
|
|
77
|
+
disabled={!supportUrl}
|
|
78
|
+
variant="secondary"
|
|
79
|
+
>
|
|
80
|
+
Contact sales
|
|
81
|
+
</Button>
|
|
82
|
+
) : !leg ? (
|
|
83
|
+
// No price for the selected interval (e.g. annual not offered) — guide
|
|
84
|
+
// the customer to the other toggle rather than dead-ending.
|
|
85
|
+
<Text format={{ fontStyle: "italic" }}>
|
|
86
|
+
Not available {interval === "annual" ? "annually" : "monthly"}
|
|
87
|
+
</Text>
|
|
88
|
+
) : checkoutUrl ? (
|
|
89
|
+
<Button href={{ url: checkoutUrl, external: true }} variant="primary">
|
|
90
|
+
Continue to checkout
|
|
91
|
+
</Button>
|
|
92
|
+
) : (
|
|
93
|
+
<LoadingButton
|
|
94
|
+
loading={choosing}
|
|
95
|
+
onClick={() => onChoose(plan, leg)}
|
|
96
|
+
variant="primary"
|
|
97
|
+
>
|
|
98
|
+
Choose {plan.name ?? plan.tier}
|
|
99
|
+
</LoadingButton>
|
|
100
|
+
)}
|
|
101
|
+
</Flex>
|
|
102
|
+
</Tile>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function PlanGrid({ context, state, appKey }) {
|
|
107
|
+
const plans = state?.plans ?? [];
|
|
108
|
+
// Default to annual only if EVERY paid plan offers it; else monthly.
|
|
109
|
+
const paid = plans.filter((p) => !p.talk_to_sales && (p.monthly || p.annual));
|
|
110
|
+
const annualAvailable =
|
|
111
|
+
paid.length > 0 && paid.every((p) => p.annual);
|
|
112
|
+
const [interval, setInterval] = useState(annualAvailable ? "annual" : "monthly");
|
|
113
|
+
const [choosingTier, setChoosingTier] = useState(null);
|
|
114
|
+
// Pre-created checkout URL keyed by `${tier}:${interval}` so a card only shows
|
|
115
|
+
// "Continue to checkout" for the exact price the customer picked.
|
|
116
|
+
const [checkoutUrls, setCheckoutUrls] = useState({});
|
|
117
|
+
const [error, setError] = useState(null);
|
|
118
|
+
|
|
119
|
+
if (plans.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
const supportUrl = state?.helpful_links?.supportUrl ?? null;
|
|
122
|
+
|
|
123
|
+
async function choose(plan, leg) {
|
|
124
|
+
setError(null);
|
|
125
|
+
setChoosingTier(plan.tier);
|
|
126
|
+
try {
|
|
127
|
+
const base = state?.billing_base_url;
|
|
128
|
+
if (!base) throw new Error("Billing service not configured");
|
|
129
|
+
if (!appKey) throw new Error("Missing appKey for checkout");
|
|
130
|
+
const portalId = context?.portal?.id;
|
|
131
|
+
|
|
132
|
+
const returnUrl = state?.app_id
|
|
133
|
+
? `https://app.hubspot.com/app/${portalId}/${state.app_id}/billing`
|
|
134
|
+
: "https://app.hubspot.com/";
|
|
135
|
+
|
|
136
|
+
// hubspot.fetch: body is a plain OBJECT (not a JSON string); only
|
|
137
|
+
// Authorization survives as a header — don't set Content-Type.
|
|
138
|
+
const res = await hubspot.fetch(`${base}/v1/billing/checkout`, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
body: {
|
|
141
|
+
app_key: appKey,
|
|
142
|
+
portal_id: String(portalId),
|
|
143
|
+
price_id: leg.price_id,
|
|
144
|
+
return_url: returnUrl,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
const text = await res.text();
|
|
149
|
+
throw new Error(`Checkout request failed (${res.status}): ${text}`);
|
|
150
|
+
}
|
|
151
|
+
const { url } = await res.json();
|
|
152
|
+
if (!url) throw new Error("No checkout URL returned");
|
|
153
|
+
setCheckoutUrls((prev) => ({ ...prev, [`${plan.tier}:${interval}`]: url }));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
setError(String(err));
|
|
156
|
+
} finally {
|
|
157
|
+
setChoosingTier(null);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<Flex direction="column" gap="medium">
|
|
163
|
+
<Flex direction="row" gap="medium" align="center" justify="start">
|
|
164
|
+
<Heading>Plans</Heading>
|
|
165
|
+
{annualAvailable && (
|
|
166
|
+
<ToggleGroup
|
|
167
|
+
name="billing-interval"
|
|
168
|
+
toggleType="radioButtonList"
|
|
169
|
+
inline={true}
|
|
170
|
+
value={interval}
|
|
171
|
+
onChange={(v) => v && setInterval(v)}
|
|
172
|
+
options={[
|
|
173
|
+
{ label: "Monthly", value: "monthly" },
|
|
174
|
+
{ label: "Annual", value: "annual" },
|
|
175
|
+
]}
|
|
176
|
+
/>
|
|
177
|
+
)}
|
|
178
|
+
</Flex>
|
|
179
|
+
|
|
180
|
+
{error && (
|
|
181
|
+
<Alert title="Couldn't start checkout" variant="danger">
|
|
182
|
+
<Text>{error}</Text>
|
|
183
|
+
</Alert>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<Flex direction="row" gap="medium" wrap="wrap">
|
|
187
|
+
{plans.map((plan) => (
|
|
188
|
+
<PlanCard
|
|
189
|
+
key={plan.tier}
|
|
190
|
+
plan={plan}
|
|
191
|
+
interval={interval}
|
|
192
|
+
onChoose={choose}
|
|
193
|
+
choosing={choosingTier === plan.tier}
|
|
194
|
+
checkoutUrl={checkoutUrls[`${plan.tier}:${interval}`]}
|
|
195
|
+
supportUrl={supportUrl}
|
|
196
|
+
/>
|
|
197
|
+
))}
|
|
198
|
+
</Flex>
|
|
199
|
+
|
|
200
|
+
<Text format={{ fontStyle: "italic" }}>
|
|
201
|
+
Billing is managed in Stripe across all Growth Operations apps. Your new
|
|
202
|
+
credits are added as soon as payment completes.
|
|
203
|
+
</Text>
|
|
204
|
+
</Flex>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export default PlanGrid;
|
package/src/index.js
CHANGED
|
@@ -16,10 +16,15 @@ export { HelpfulLinks } from "./home/HelpfulLinks";
|
|
|
16
16
|
|
|
17
17
|
// Billing.
|
|
18
18
|
export { BillingTab } from "./billing/BillingTab";
|
|
19
|
+
export { PlanGrid } from "./billing/PlanGrid";
|
|
20
|
+
|
|
21
|
+
// Alerts — the shared customer alerts table (level filter, sort, dismiss).
|
|
22
|
+
export { AlertsTab } from "./alerts/AlertsTab";
|
|
19
23
|
|
|
20
24
|
// Lib helpers.
|
|
21
25
|
export {
|
|
22
26
|
fmtDate,
|
|
27
|
+
fmtMoney,
|
|
23
28
|
daysUntil,
|
|
24
29
|
humanizeStatus,
|
|
25
30
|
statusVariant,
|
package/src/lib/format.js
CHANGED
|
@@ -22,6 +22,23 @@ export function daysUntil(value) {
|
|
|
22
22
|
return Math.ceil((ms - Date.now()) / (1000 * 60 * 60 * 24));
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// Stripe amount (smallest currency unit, e.g. cents) -> "$29" / "€29.50".
|
|
26
|
+
// Whole amounts drop the decimals; null/undefined -> "—". Currency defaults to
|
|
27
|
+
// USD; falls back to an uppercased code prefix for currencies Intl can't symbol.
|
|
28
|
+
export function fmtMoney(unitAmount, currency = "usd") {
|
|
29
|
+
if (unitAmount == null) return "—";
|
|
30
|
+
const amount = unitAmount / 100;
|
|
31
|
+
try {
|
|
32
|
+
return new Intl.NumberFormat(undefined, {
|
|
33
|
+
style: "currency",
|
|
34
|
+
currency: (currency || "usd").toUpperCase(),
|
|
35
|
+
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
|
|
36
|
+
}).format(amount);
|
|
37
|
+
} catch {
|
|
38
|
+
return `${(currency || "usd").toUpperCase()} ${amount}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
// Friendly labels for the AppInstallStatus enum (shared across apps).
|
|
26
43
|
export const STATUS_LABELS = {
|
|
27
44
|
trialing: "Free trial",
|
package/src/sdk/app/alerts.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
// getAlerts / dismissAlert — generic alerts API client.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Two backend shapes share this client:
|
|
4
|
+
// - SELF-HOSTED apps (sparkfly, toast) serve /api/v1/alerts from their OWN
|
|
5
|
+
// service and resolve portalId from the verified token. Default basePath,
|
|
6
|
+
// no portalId needed.
|
|
7
|
+
// - BASE-HOSTED credit apps (line-item, …) serve from the base service at
|
|
8
|
+
// /v1/hubspot/app_pages/{app}/alerts and require ?portalId=. Pass
|
|
9
|
+
// `basePath` + `portalId` to target that route.
|
|
10
|
+
//
|
|
5
11
|
// Goes through callAppApi, so it inherits the hubspot.fetch rules (object body,
|
|
6
|
-
// no custom headers).
|
|
12
|
+
// no custom headers). BASE_URL (the app's own service, or base for base-hosted
|
|
13
|
+
// apps) comes from context.variables.
|
|
7
14
|
import { callAppApi } from "./base";
|
|
8
15
|
|
|
16
|
+
const DEFAULT_BASE_PATH = "/api/v1/alerts";
|
|
17
|
+
|
|
9
18
|
// context: extension context. params: { offset, limit, orderBy, orderDirection,
|
|
10
|
-
// filterField, filterValue }.
|
|
19
|
+
// filterField, filterValue, basePath, portalId }. basePath defaults to the
|
|
20
|
+
// self-hosted /api/v1/alerts; portalId is appended when set (base-hosted apps).
|
|
21
|
+
// Returns the backend's alerts response.
|
|
11
22
|
export async function getAlerts(context, params = {}) {
|
|
12
23
|
const {
|
|
13
24
|
offset = 0,
|
|
@@ -16,6 +27,8 @@ export async function getAlerts(context, params = {}) {
|
|
|
16
27
|
orderDirection,
|
|
17
28
|
filterField,
|
|
18
29
|
filterValue,
|
|
30
|
+
basePath = DEFAULT_BASE_PATH,
|
|
31
|
+
portalId,
|
|
19
32
|
} = params;
|
|
20
33
|
const queryParams = new URLSearchParams();
|
|
21
34
|
queryParams.append("offset", offset);
|
|
@@ -31,11 +44,19 @@ export async function getAlerts(context, params = {}) {
|
|
|
31
44
|
queryParams.append("filter_field", filterField);
|
|
32
45
|
queryParams.append("filter_value", filterValue);
|
|
33
46
|
}
|
|
34
|
-
|
|
47
|
+
if (portalId != null) {
|
|
48
|
+
queryParams.append("portalId", portalId);
|
|
49
|
+
}
|
|
50
|
+
const path = `${basePath}?${queryParams.toString()}`;
|
|
35
51
|
return callAppApi(context, path, "GET");
|
|
36
52
|
}
|
|
37
53
|
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
// context, alertId, options: { basePath, portalId }. basePath defaults to the
|
|
55
|
+
// self-hosted /api/v1/alerts; portalId is appended when set (base-hosted apps).
|
|
56
|
+
export async function dismissAlert(context, alertId, options = {}) {
|
|
57
|
+
const { basePath = DEFAULT_BASE_PATH, portalId } = options;
|
|
58
|
+
const query =
|
|
59
|
+
portalId != null ? `?portalId=${encodeURIComponent(portalId)}` : "";
|
|
60
|
+
const path = `${basePath}/${alertId}/dismiss${query}`;
|
|
40
61
|
return callAppApi(context, path, "POST");
|
|
41
62
|
}
|