@growth-operations/ui-shared 1.19.0 → 1.21.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 +8 -1
- package/src/billing/AutoProvisionToggle.jsx +139 -0
- package/src/billing/BillingTab.jsx +22 -238
- package/src/home/AppHome.jsx +51 -13
- package/src/home/CreditMeter.jsx +31 -13
- package/src/home/FeatureUsage.jsx +75 -0
- package/src/home/HealthHero.jsx +6 -0
- package/src/home/OnboardingChecklist.jsx +6 -0
- package/src/home/PeriodComparison.jsx +60 -0
- package/src/home/PlanFitBanner.jsx +27 -0
- package/src/home/TrialBanner.jsx +32 -0
- package/src/home/UsageHighlights.jsx +10 -0
- package/src/index.js +24 -0
- package/src/lib/autoProvision.js +82 -0
- package/src/lib/creditDisplay.js +97 -34
- package/src/lib/featureUsage.js +54 -0
- package/src/lib/homeBlocks.js +146 -0
- package/src/sdk/app/topUps.js +55 -18
- package/src/sdk/index.js +1 -1
- package/src/usage/UsageTab.jsx +19 -4
package/package.json
CHANGED
package/src/alerts/AlertsTab.jsx
CHANGED
|
@@ -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
|
|
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,
|
|
531
|
-
// top-ups, which tell the decline story), from the billing
|
|
532
|
-
// GET /v1/billing/invoices (newest first, capped at 24).
|
|
533
|
-
//
|
|
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
|
|
590
|
-
|
|
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
|
-
|
|
821
|
-
|
|
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
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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.
|
package/src/home/AppHome.jsx
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
48
|
-
//
|
|
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 -> <
|
|
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/
|
|
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}
|