@growth-operations/ui-shared 1.0.9 → 1.1.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/billing/BillingTab.jsx +40 -23
- package/src/billing/PlanGrid.jsx +208 -0
- package/src/home/AppHome.jsx +22 -2
- package/src/home/HelpfulLinks.jsx +12 -4
- package/src/index.js +2 -0
- package/src/lib/format.js +17 -0
package/package.json
CHANGED
|
@@ -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/home/AppHome.jsx
CHANGED
|
@@ -6,6 +6,7 @@ import { OnboardingChecklist } from "./OnboardingChecklist";
|
|
|
6
6
|
import { SyncPulse } from "./SyncPulse";
|
|
7
7
|
import { CreditMeter } from "./CreditMeter";
|
|
8
8
|
import { FeatureNudges } from "./FeatureNudges";
|
|
9
|
+
import { HelpfulLinks } from "./HelpfulLinks";
|
|
9
10
|
|
|
10
11
|
// Minimal inline ATS tile (the `pipeline` block). Kept tiny here rather than a
|
|
11
12
|
// separate file — it's the simplest of the three archetype tiles. Renders
|
|
@@ -76,7 +77,13 @@ function ArchetypeTile({ state }) {
|
|
|
76
77
|
// PageLink navigation (the host owns the router).
|
|
77
78
|
// appName — display name override; defaults to state.app.name.
|
|
78
79
|
// onRetry — optional, passed to InstallProgress for the failed state.
|
|
79
|
-
|
|
80
|
+
// helpfulLinks — optional props for the standard HelpfulLinks section
|
|
81
|
+
// (e.g. { portalId, appId, appName, docsUrl, supportUrl,
|
|
82
|
+
// links }). Omit to hide the section. The host passes this
|
|
83
|
+
// (it owns context.portal.id + the app's docs/support); it can
|
|
84
|
+
// also come from a state.helpful_links block if the backend
|
|
85
|
+
// builds one.
|
|
86
|
+
export function AppHome({ state, onNavigate, appName, onRetry, helpfulLinks }) {
|
|
80
87
|
if (!state) {
|
|
81
88
|
return <LoadingSpinner showLabel label="Loading…" />;
|
|
82
89
|
}
|
|
@@ -85,10 +92,21 @@ export function AppHome({ state, onNavigate, appName, onRetry }) {
|
|
|
85
92
|
const activationComplete =
|
|
86
93
|
!activation || activation.complete === true || activation.status === "completed";
|
|
87
94
|
|
|
95
|
+
// HelpfulLinks props: explicit prop wins, else a state.helpful_links block if
|
|
96
|
+
// the backend builds one. Shown in both the install and cockpit views so help
|
|
97
|
+
// is reachable even mid-setup.
|
|
98
|
+
const links = helpfulLinks ?? state.helpful_links ?? null;
|
|
99
|
+
const helpfulLinksEl = links ? <HelpfulLinks {...links} /> : null;
|
|
100
|
+
|
|
88
101
|
// Install isn't finished (running / failed / completed-with-warnings) — show
|
|
89
102
|
// the install experience instead of the cockpit.
|
|
90
103
|
if (!activationComplete) {
|
|
91
|
-
return
|
|
104
|
+
return (
|
|
105
|
+
<Flex direction="column" gap="medium">
|
|
106
|
+
<InstallProgress state={state} onRetry={onRetry} />
|
|
107
|
+
{helpfulLinksEl}
|
|
108
|
+
</Flex>
|
|
109
|
+
);
|
|
92
110
|
}
|
|
93
111
|
|
|
94
112
|
const onboardingComplete = state.onboarding?.complete === true;
|
|
@@ -108,6 +126,8 @@ export function AppHome({ state, onNavigate, appName, onRetry }) {
|
|
|
108
126
|
<ArchetypeTile state={state} />
|
|
109
127
|
|
|
110
128
|
<FeatureNudges nudges={state.nudges} onNavigate={onNavigate} />
|
|
129
|
+
|
|
130
|
+
{helpfulLinksEl}
|
|
111
131
|
</Flex>
|
|
112
132
|
);
|
|
113
133
|
}
|
|
@@ -35,18 +35,26 @@ export function HelpfulLinks({
|
|
|
35
35
|
links = [],
|
|
36
36
|
title = "Helpful Links",
|
|
37
37
|
}) {
|
|
38
|
+
// external=false keeps navigation in the SAME tab. The app-pages deep link is
|
|
39
|
+
// an in-app HubSpot URL, so it must stay same-tab (external:true would force a
|
|
40
|
+
// new tab). Docs/support and app-specific extras are off-platform → external.
|
|
38
41
|
const items = [];
|
|
39
42
|
|
|
40
43
|
if (appPages && appId && portalId != null) {
|
|
41
44
|
items.push({
|
|
42
45
|
label: `Open ${appName} →`,
|
|
43
46
|
url: appPagesUrl(portalId, appId, appPagesPath),
|
|
47
|
+
external: false,
|
|
44
48
|
});
|
|
45
49
|
}
|
|
46
|
-
if (docsUrl) items.push({ label: "Documentation", url: docsUrl });
|
|
47
|
-
if (supportUrl) items.push({ label: "Support", url: supportUrl });
|
|
50
|
+
if (docsUrl) items.push({ label: "Documentation", url: docsUrl, external: true });
|
|
51
|
+
if (supportUrl) items.push({ label: "Support", url: supportUrl, external: true });
|
|
48
52
|
for (const l of links) {
|
|
49
|
-
if (l && l.url && l.label)
|
|
53
|
+
if (l && l.url && l.label) {
|
|
54
|
+
// Default app-specific extras to external (they're usually HubSpot record
|
|
55
|
+
// URLs or off-platform), but let a link opt into same-tab via external:false.
|
|
56
|
+
items.push({ label: l.label, url: l.url, external: l.external !== false });
|
|
57
|
+
}
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
if (items.length === 0) return null;
|
|
@@ -56,7 +64,7 @@ export function HelpfulLinks({
|
|
|
56
64
|
<Text format={{ fontWeight: "bold" }}>{title}</Text>
|
|
57
65
|
<Divider />
|
|
58
66
|
{items.map((item) => (
|
|
59
|
-
<Link key={item.url} href={{ url: item.url, external:
|
|
67
|
+
<Link key={item.url} href={{ url: item.url, external: item.external }}>
|
|
60
68
|
{item.label}
|
|
61
69
|
</Link>
|
|
62
70
|
))}
|
package/src/index.js
CHANGED
|
@@ -16,10 +16,12 @@ export { HelpfulLinks } from "./home/HelpfulLinks";
|
|
|
16
16
|
|
|
17
17
|
// Billing.
|
|
18
18
|
export { BillingTab } from "./billing/BillingTab";
|
|
19
|
+
export { PlanGrid } from "./billing/PlanGrid";
|
|
19
20
|
|
|
20
21
|
// Lib helpers.
|
|
21
22
|
export {
|
|
22
23
|
fmtDate,
|
|
24
|
+
fmtMoney,
|
|
23
25
|
daysUntil,
|
|
24
26
|
humanizeStatus,
|
|
25
27
|
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",
|