@growth-operations/ui-shared 1.19.1 → 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 +19 -9
- 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 +87 -30
- 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/src/home/CreditMeter.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Flex, Tile, Text, ProgressBar, StatusTag, Alert } from "@hubspot/ui-extensions";
|
|
3
|
-
import { daysUntil } from "../lib/format";
|
|
3
|
+
import { daysUntil, fmtDate } from "../lib/format";
|
|
4
4
|
import { resolveCreditMeter } from "../lib/creditDisplay";
|
|
5
5
|
|
|
6
6
|
// THE credit-archetype activity tile + hero signal. For credit apps, "credits
|
|
@@ -40,8 +40,11 @@ import { resolveCreditMeter } from "../lib/creditDisplay";
|
|
|
40
40
|
// Props:
|
|
41
41
|
// entitlement — the /v1/home entitlement union (uses the credits arm).
|
|
42
42
|
// creditMeter — the /v1/home credit_meter block (optional).
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// credits — the /v1/home credits block (optional; the authoritative
|
|
44
|
+
// two-bucket monthly/additional numbers + renews_on when the
|
|
45
|
+
// backend carries it — see CONTRACT.md).
|
|
46
|
+
export function CreditMeter({ entitlement, creditMeter, credits = null }) {
|
|
47
|
+
const view = resolveCreditMeter(entitlement, creditMeter, credits);
|
|
45
48
|
if (!view) return null;
|
|
46
49
|
|
|
47
50
|
const { granted, used, remaining, bank, tagVariant, tagLabel, poolBarVariant, bankBarVariant, alert } = view;
|
|
@@ -63,32 +66,40 @@ export function CreditMeter({ entitlement, creditMeter }) {
|
|
|
63
66
|
Monthly plan credits: {bank.poolRemaining} of {bank.poolGrant}{" "}
|
|
64
67
|
left
|
|
65
68
|
</Text>
|
|
69
|
+
{view.renewsOn && (
|
|
70
|
+
<Text variant="microcopy">Renews on {fmtDate(view.renewsOn)}</Text>
|
|
71
|
+
)}
|
|
66
72
|
<ProgressBar
|
|
67
73
|
title={`${bank.poolUsed} of ${bank.poolGrant} used`}
|
|
68
74
|
value={bank.poolUsed}
|
|
69
75
|
maxValue={bank.poolGrant > 0 ? bank.poolGrant : 1}
|
|
70
76
|
showPercentage={true}
|
|
77
|
+
valueDescription={`${bank.poolUsed} of ${bank.poolGrant} used`}
|
|
71
78
|
variant={poolBarVariant}
|
|
72
79
|
/>
|
|
73
80
|
</Flex>
|
|
74
|
-
{
|
|
81
|
+
{/* The additional-credits line rides along only when the account
|
|
82
|
+
has (or ever bought) a bank — never "0 of 0 left". */}
|
|
83
|
+
{bank.showAdditional && (bank.bankGranted > 0 ? (
|
|
75
84
|
// Bank — level gauge over the granted total (refills on
|
|
76
85
|
// purchase, drains on spend; never expires).
|
|
77
86
|
<Flex direction="column" gap="extra-small">
|
|
78
87
|
<Flex direction="row" gap="extra-small" align="center">
|
|
79
88
|
<Text>
|
|
80
|
-
Additional credits
|
|
89
|
+
Additional credits: {bank.bankRemaining} of{" "}
|
|
81
90
|
{bank.bankGranted} left
|
|
82
91
|
</Text>
|
|
83
92
|
{bank.bankLow && (
|
|
84
93
|
<StatusTag variant="warning">Running low</StatusTag>
|
|
85
94
|
)}
|
|
86
95
|
</Flex>
|
|
96
|
+
<Text variant="microcopy">Does not expire</Text>
|
|
87
97
|
<ProgressBar
|
|
88
98
|
title={`${bank.bankRemaining} of ${bank.bankGranted} left`}
|
|
89
99
|
value={bank.bankRemaining}
|
|
90
100
|
maxValue={bank.bankGranted > 0 ? bank.bankGranted : 1}
|
|
91
101
|
showPercentage={true}
|
|
102
|
+
valueDescription={`${bank.bankRemaining} of ${bank.bankGranted} left`}
|
|
92
103
|
variant={bankBarVariant}
|
|
93
104
|
/>
|
|
94
105
|
</Flex>
|
|
@@ -98,11 +109,9 @@ export function CreditMeter({ entitlement, creditMeter }) {
|
|
|
98
109
|
// only, no bar (never divide by zero, never "of 0").
|
|
99
110
|
<Flex direction="column" gap="extra-small">
|
|
100
111
|
<Text>Additional credits: {bank.bankRemaining} left</Text>
|
|
101
|
-
<Text
|
|
102
|
-
Additional credits don't expire.
|
|
103
|
-
</Text>
|
|
112
|
+
<Text variant="microcopy">Does not expire</Text>
|
|
104
113
|
</Flex>
|
|
105
|
-
)}
|
|
114
|
+
))}
|
|
106
115
|
</>
|
|
107
116
|
) : (
|
|
108
117
|
<>
|
|
@@ -115,6 +124,7 @@ export function CreditMeter({ entitlement, creditMeter }) {
|
|
|
115
124
|
value={used}
|
|
116
125
|
maxValue={granted > 0 ? granted : 1}
|
|
117
126
|
showPercentage={true}
|
|
127
|
+
valueDescription={`${used} of ${granted} used`}
|
|
118
128
|
variant={poolBarVariant}
|
|
119
129
|
/>
|
|
120
130
|
</>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Flex, Tile, Text, Link } from "@hubspot/ui-extensions";
|
|
3
|
+
import { featureUsageOf } from "../lib/featureUsage";
|
|
4
|
+
|
|
5
|
+
// The Home "Most used features" / "Recommended to explore" sections, fed by
|
|
6
|
+
// the additive state.feature_usage block (per-app feature registry + credit
|
|
7
|
+
// ledger aggregation in growth-ops-apps-base).
|
|
8
|
+
//
|
|
9
|
+
// Composition: most-used first (what this install actually runs on), then
|
|
10
|
+
// the not-in-use catalog as "Recommended to explore" — each with its
|
|
11
|
+
// one-line rationale and setup-guide link. Each section renders only when it
|
|
12
|
+
// has rows, and the whole thing renders NOTHING when the block is absent
|
|
13
|
+
// (older backends, legacy installs) — absence is never an empty box.
|
|
14
|
+
|
|
15
|
+
// Singular/plural label for a row's unit ("credits" | "events") — the wire
|
|
16
|
+
// field is credits_or_events, so an event-denominated feature must not be
|
|
17
|
+
// mislabeled as credits.
|
|
18
|
+
const unitLabel = (unit, count) => {
|
|
19
|
+
const singular = unit === "events" ? "event" : "credit";
|
|
20
|
+
return count === 1 ? singular : `${singular}s`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function MostUsedTile({ mostUsed }) {
|
|
24
|
+
if (mostUsed.length === 0) return null;
|
|
25
|
+
return (
|
|
26
|
+
<Tile>
|
|
27
|
+
<Flex direction="column" gap="small">
|
|
28
|
+
<Text format={{ fontWeight: "bold" }}>Most used features</Text>
|
|
29
|
+
{mostUsed.map((f) => (
|
|
30
|
+
<Flex key={f.key} direction="row" gap="small">
|
|
31
|
+
<Text>{f.name}</Text>
|
|
32
|
+
<Text variant="microcopy">
|
|
33
|
+
{f.count} {unitLabel(f.unit, f.count)}
|
|
34
|
+
</Text>
|
|
35
|
+
</Flex>
|
|
36
|
+
))}
|
|
37
|
+
</Flex>
|
|
38
|
+
</Tile>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function RecommendedTile({ notInUse }) {
|
|
43
|
+
if (notInUse.length === 0) return null;
|
|
44
|
+
return (
|
|
45
|
+
<Tile>
|
|
46
|
+
<Flex direction="column" gap="small">
|
|
47
|
+
<Text format={{ fontWeight: "bold" }}>Recommended to explore</Text>
|
|
48
|
+
{notInUse.map((f) => (
|
|
49
|
+
<Flex key={f.key} direction="column" gap="extra-small">
|
|
50
|
+
<Text format={{ fontWeight: "demibold" }}>{f.name}</Text>
|
|
51
|
+
{f.rationale ? <Text>{f.rationale}</Text> : null}
|
|
52
|
+
{f.docsUrl ? (
|
|
53
|
+
<Link href={{ url: f.docsUrl, external: true }}>
|
|
54
|
+
Read the guide →
|
|
55
|
+
</Link>
|
|
56
|
+
) : null}
|
|
57
|
+
</Flex>
|
|
58
|
+
))}
|
|
59
|
+
</Flex>
|
|
60
|
+
</Tile>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function FeatureUsage({ state }) {
|
|
65
|
+
const view = featureUsageOf(state);
|
|
66
|
+
if (!view) return null;
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
<MostUsedTile mostUsed={view.mostUsed} />
|
|
70
|
+
<RecommendedTile notInUse={view.notInUse} />
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default FeatureUsage;
|
package/src/home/HealthHero.jsx
CHANGED
|
@@ -23,6 +23,11 @@ export function HealthHero({ appName, state }) {
|
|
|
23
23
|
const name = appName ?? state.app?.name;
|
|
24
24
|
|
|
25
25
|
if (health?.level) {
|
|
26
|
+
// Plan-fit reinforcement: a healthy verdict on the right plan gets the
|
|
27
|
+
// extra reassurance line; upgrade_recommended is carried by health
|
|
28
|
+
// v2's "attention" reasons + the PlanFitBanner, not here.
|
|
29
|
+
const onRightPlan =
|
|
30
|
+
health.level === "healthy" && state.plan_fit?.verdict === "right_plan";
|
|
26
31
|
return (
|
|
27
32
|
<Flex direction="column" gap="small">
|
|
28
33
|
{name && <Heading>{name}</Heading>}
|
|
@@ -34,6 +39,7 @@ export function HealthHero({ appName, state }) {
|
|
|
34
39
|
{(health.reasons ?? []).map((r, i) => (
|
|
35
40
|
<Text key={i}>{r}</Text>
|
|
36
41
|
))}
|
|
42
|
+
{onRightPlan && <Text>...and you're on the right plan</Text>}
|
|
37
43
|
</Flex>
|
|
38
44
|
</Alert>
|
|
39
45
|
</Flex>
|
|
@@ -10,6 +10,12 @@ import { Flex, Tile, Text, ProgressBar, Link, Button } from "@hubspot/ui-extensi
|
|
|
10
10
|
// `onNavigate(route)` is supplied by the host app (PageLink can't be created
|
|
11
11
|
// here without the router); cta_url renders as an external Link.
|
|
12
12
|
export function OnboardingChecklist({ onboarding, onNavigate }) {
|
|
13
|
+
// The Getting Started checklist disappears entirely once complete — no
|
|
14
|
+
// "all done" state (Andrew's 2026-10 feedback). AppHome gates on the same
|
|
15
|
+
// flag; this guard covers direct consumers.
|
|
16
|
+
if (onboarding?.complete === true) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
13
19
|
if (!onboarding || !Array.isArray(onboarding.steps) || onboarding.steps.length === 0) {
|
|
14
20
|
return null;
|
|
15
21
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Flex, Tile, Text, ProgressBar } from "@hubspot/ui-extensions";
|
|
3
|
+
import { resolvePeriodComparison } from "../lib/homeBlocks";
|
|
4
|
+
|
|
5
|
+
// The post-checklist Home usage block: the last full billing period vs the
|
|
6
|
+
// current period-to-date, side by side — one bar per period (shared
|
|
7
|
+
// denominator, so the bars are visually comparable) with the per-action
|
|
8
|
+
// credit breakdown beneath each. Fed by state.period_comparison, which the
|
|
9
|
+
// backend leaves null until the install has one full closed period; this
|
|
10
|
+
// renders nothing then.
|
|
11
|
+
//
|
|
12
|
+
// Bars are sentiment-free (always "success") — this is a comparison, not a
|
|
13
|
+
// health signal. Normalization (labels, totals, by-action lists, the shared
|
|
14
|
+
// maxTotal) lives in ../lib/homeBlocks (unit-tested).
|
|
15
|
+
function PeriodColumn({ period, maxTotal }) {
|
|
16
|
+
return (
|
|
17
|
+
<Flex direction="column" gap="extra-small">
|
|
18
|
+
<Text format={{ fontWeight: "demibold" }}>{period.label}</Text>
|
|
19
|
+
<Text>
|
|
20
|
+
{period.total} {period.total === 1 ? "credit" : "credits"}
|
|
21
|
+
</Text>
|
|
22
|
+
<ProgressBar
|
|
23
|
+
title={`${period.label}: ${period.total} credits`}
|
|
24
|
+
value={period.total}
|
|
25
|
+
maxValue={maxTotal}
|
|
26
|
+
showPercentage={false}
|
|
27
|
+
valueDescription={`${period.total} credits`}
|
|
28
|
+
variant="success"
|
|
29
|
+
/>
|
|
30
|
+
{period.byAction.length > 0 && (
|
|
31
|
+
<Flex direction="column" gap="extra-small">
|
|
32
|
+
{period.byAction.map((a) => (
|
|
33
|
+
<Text key={a.action} variant="microcopy">
|
|
34
|
+
{a.action}: {a.credits} {a.credits === 1 ? "credit" : "credits"}
|
|
35
|
+
</Text>
|
|
36
|
+
))}
|
|
37
|
+
</Flex>
|
|
38
|
+
)}
|
|
39
|
+
</Flex>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function PeriodComparison({ state }) {
|
|
44
|
+
const view = resolvePeriodComparison(state?.period_comparison);
|
|
45
|
+
if (!view) return null;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Tile>
|
|
49
|
+
<Flex direction="column" gap="small">
|
|
50
|
+
<Text format={{ fontWeight: "bold" }}>Credits by period</Text>
|
|
51
|
+
<Flex direction="row" gap="large">
|
|
52
|
+
<PeriodColumn period={view.last} maxTotal={view.maxTotal} />
|
|
53
|
+
<PeriodColumn period={view.current} maxTotal={view.maxTotal} />
|
|
54
|
+
</Flex>
|
|
55
|
+
</Flex>
|
|
56
|
+
</Tile>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default PeriodComparison;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text, Alert } from "@hubspot/ui-extensions";
|
|
3
|
+
import { resolvePlanFit } from "../lib/homeBlocks";
|
|
4
|
+
|
|
5
|
+
// The plan-fit savings banner: when state.plan_fit.verdict is
|
|
6
|
+
// "upgrade_recommended", tell the customer what staying on their current tier
|
|
7
|
+
// is costing them ("You could save $X/mo by moving to Professional — you
|
|
8
|
+
// bought 3 additional credit packs this period."). The right_plan verdict is
|
|
9
|
+
// NOT a banner — it reinforces the healthy hero instead (HealthHero reads
|
|
10
|
+
// state.plan_fit directly). Copy assembly lives in ../lib/homeBlocks
|
|
11
|
+
// (unit-tested), including the degraded phrasings when the backend can't
|
|
12
|
+
// price the savings yet.
|
|
13
|
+
//
|
|
14
|
+
// Renders nothing when the block is absent or the verdict isn't
|
|
15
|
+
// upgrade_recommended.
|
|
16
|
+
export function PlanFitBanner({ state }) {
|
|
17
|
+
const fit = resolvePlanFit(state?.plan_fit);
|
|
18
|
+
if (!fit || fit.kind !== "upgrade_recommended") return null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Alert title="You could save on a higher plan" variant="warning">
|
|
22
|
+
<Text>{fit.text}</Text>
|
|
23
|
+
</Alert>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default PlanFitBanner;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Flex, Text, Alert } from "@hubspot/ui-extensions";
|
|
3
|
+
import { resolveTrialBanner } from "../lib/homeBlocks";
|
|
4
|
+
|
|
5
|
+
// The Home trial banner for credit apps — Andrew's 2026-10 copy, in all three
|
|
6
|
+
// states (active countdown / ended-with-credits-left / out of credits), with
|
|
7
|
+
// the "Need help?" footer line. State selection + copy live in
|
|
8
|
+
// ../lib/homeBlocks (unit-tested); this is pure rendering.
|
|
9
|
+
//
|
|
10
|
+
// Renders nothing for paid plans, non-credits modes, pre-install, or a
|
|
11
|
+
// backend that carries no grant clock (resolveTrialBanner returns null).
|
|
12
|
+
//
|
|
13
|
+
// Props:
|
|
14
|
+
// state — the /v1/home payload (reads entitlement + app.name).
|
|
15
|
+
// appName — display name override; defaults to state.app.name.
|
|
16
|
+
export function TrialBanner({ state, appName }) {
|
|
17
|
+
const banner = resolveTrialBanner(state?.entitlement, {
|
|
18
|
+
appName: appName ?? state?.app?.name,
|
|
19
|
+
});
|
|
20
|
+
if (!banner) return null;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Alert title={banner.title} variant={banner.variant}>
|
|
24
|
+
<Flex direction="column" gap="extra-small">
|
|
25
|
+
<Text>{banner.body}</Text>
|
|
26
|
+
<Text variant="microcopy">{banner.footer}</Text>
|
|
27
|
+
</Flex>
|
|
28
|
+
</Alert>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default TrialBanner;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Reserved slot for the later "most used / not in use" Home section
|
|
2
|
+
// (separate workstream — do NOT build it here). AppHome renders this directly
|
|
3
|
+
// below PeriodComparison in the post-checklist cockpit; it intentionally
|
|
4
|
+
// renders nothing until that section lands, so the composition boundary is
|
|
5
|
+
// already in place.
|
|
6
|
+
export function UsageHighlights({ state }) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default UsageHighlights;
|
package/src/index.js
CHANGED
|
@@ -11,14 +11,20 @@ export { HealthHero } from "./home/HealthHero";
|
|
|
11
11
|
export { OnboardingChecklist } from "./home/OnboardingChecklist";
|
|
12
12
|
export { SyncPulse } from "./home/SyncPulse";
|
|
13
13
|
export { FeatureNudges } from "./home/FeatureNudges";
|
|
14
|
+
export { FeatureUsage } from "./home/FeatureUsage";
|
|
14
15
|
export { CreditMeter } from "./home/CreditMeter";
|
|
15
16
|
export { HelpfulLinks } from "./home/HelpfulLinks";
|
|
17
|
+
export { TrialBanner } from "./home/TrialBanner";
|
|
18
|
+
export { PlanFitBanner } from "./home/PlanFitBanner";
|
|
19
|
+
export { PeriodComparison } from "./home/PeriodComparison";
|
|
20
|
+
export { UsageHighlights } from "./home/UsageHighlights";
|
|
16
21
|
|
|
17
22
|
// Billing.
|
|
18
23
|
export { BillingTab } from "./billing/BillingTab";
|
|
19
24
|
export { PlanGrid } from "./billing/PlanGrid";
|
|
20
25
|
export { BillingBanner } from "./billing/BillingBanner";
|
|
21
26
|
export { SubscriptionGate } from "./billing/SubscriptionGate";
|
|
27
|
+
export { AutoProvisionToggle } from "./billing/AutoProvisionToggle";
|
|
22
28
|
|
|
23
29
|
// Alerts — the shared customer alerts table (level filter, sort, dismiss).
|
|
24
30
|
export { AlertsTab } from "./alerts/AlertsTab";
|
|
@@ -44,12 +50,29 @@ export {
|
|
|
44
50
|
STATUS_VARIANT,
|
|
45
51
|
} from "./lib/format";
|
|
46
52
|
export { useStrictModeEffect } from "./lib/useStrictModeEffect";
|
|
53
|
+
export { resolveFeatureUsage, featureUsageOf } from "./lib/featureUsage";
|
|
47
54
|
export {
|
|
48
55
|
hasTopUpBank,
|
|
49
56
|
resolveCreditMeter,
|
|
50
57
|
resolveBillingBanner,
|
|
51
58
|
parseLimitInput,
|
|
52
59
|
} from "./lib/creditDisplay";
|
|
60
|
+
export {
|
|
61
|
+
TRIAL_BANNER_FOOTER,
|
|
62
|
+
resolveTrialBanner,
|
|
63
|
+
resolvePlanFit,
|
|
64
|
+
resolvePeriodComparison,
|
|
65
|
+
} from "./lib/homeBlocks";
|
|
66
|
+
export {
|
|
67
|
+
AUTO_PROVISION_TITLE,
|
|
68
|
+
AUTO_PROVISION_CONFIRM_YES,
|
|
69
|
+
AUTO_PROVISION_CONFIRM_NO,
|
|
70
|
+
AUTO_PROVISION_IDLE,
|
|
71
|
+
autoProvisionSubtext,
|
|
72
|
+
autoProvisionConfirmBody,
|
|
73
|
+
resolveAutoProvision,
|
|
74
|
+
autoProvisionReducer,
|
|
75
|
+
} from "./lib/autoProvision";
|
|
53
76
|
|
|
54
77
|
// HubSpot URL builders (app.hubspot.com record/property/list links).
|
|
55
78
|
export {
|
|
@@ -74,6 +97,7 @@ export {
|
|
|
74
97
|
getTopUps,
|
|
75
98
|
getInvoices,
|
|
76
99
|
setBillingLimit,
|
|
100
|
+
setAutoProvision,
|
|
77
101
|
getSettings,
|
|
78
102
|
updateSettings,
|
|
79
103
|
makeSettingsApi,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// "Add Additional Credits Automatically" — the auto-provision toggle's copy
|
|
2
|
+
// (verbatim from Andrew's 2026-10 feedback, app name templated) plus the tiny
|
|
3
|
+
// state machine behind its ON→OFF confirmation flow. Framework-free so the
|
|
4
|
+
// flow is unit-testable; AutoProvisionToggle (../billing) stays thin.
|
|
5
|
+
//
|
|
6
|
+
// state.auto_provision = { enabled } is additive on the /v1/home payload and
|
|
7
|
+
// REPLACES the old billing-limit concept: enabled (the DEFAULT — also when
|
|
8
|
+
// the block is absent) buys another pack when monthly credits run out;
|
|
9
|
+
// disabled means no purchase and actions start failing the entitlement gate.
|
|
10
|
+
|
|
11
|
+
export const AUTO_PROVISION_TITLE = "Add Additional Credits Automatically";
|
|
12
|
+
|
|
13
|
+
export function autoProvisionSubtext(appName) {
|
|
14
|
+
return `If monthly credits are used, this will automatically purchase another pack of credits so use of ${appName} is not impacted.`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function autoProvisionConfirmBody(appName) {
|
|
18
|
+
return `Are you sure you want to disable automatic credit provisioning? If monthly credits are used, ${appName} will no longer function and workflow actions will fail.`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const AUTO_PROVISION_CONFIRM_YES = "Yes, disable this feature.";
|
|
22
|
+
export const AUTO_PROVISION_CONFIRM_NO =
|
|
23
|
+
"No, keep automatic credit provisioning enabled.";
|
|
24
|
+
|
|
25
|
+
// Read the account flag with its contract default: enabled UNLESS the block
|
|
26
|
+
// explicitly says false.
|
|
27
|
+
export function resolveAutoProvision(autoProvision) {
|
|
28
|
+
return { enabled: autoProvision?.enabled !== false };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The toggle's interaction state. `enabled` itself lives in the component
|
|
32
|
+
// (seeded from the payload, updated on save); this machine owns everything
|
|
33
|
+
// around it: the ON→OFF confirmation and the in-flight POST.
|
|
34
|
+
// idle — nothing happening
|
|
35
|
+
// confirming — the user flipped ON→OFF; the confirmation panel is up and
|
|
36
|
+
// the toggle stays checked until they decide
|
|
37
|
+
// saving — the auto-provision POST is in flight
|
|
38
|
+
export const AUTO_PROVISION_IDLE = {
|
|
39
|
+
phase: "idle",
|
|
40
|
+
notice: null, // null | { variant: "success"|"error", text }
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Events:
|
|
44
|
+
// { type: "request", enabled } — the user flipped the toggle to `enabled`.
|
|
45
|
+
// OFF requests park in `confirming` (the dangerous direction); ON
|
|
46
|
+
// requests save immediately (no confirmation to turn protection ON).
|
|
47
|
+
// { type: "confirm" } — "Yes, disable this feature."
|
|
48
|
+
// { type: "cancel" } — "No, keep automatic credit provisioning enabled."
|
|
49
|
+
// { type: "saved" } — the POST succeeded
|
|
50
|
+
// { type: "failed", message } — the POST failed
|
|
51
|
+
export function autoProvisionReducer(state, action) {
|
|
52
|
+
switch (action.type) {
|
|
53
|
+
case "request":
|
|
54
|
+
if (state.phase === "saving") return state;
|
|
55
|
+
return action.enabled === false
|
|
56
|
+
? { phase: "confirming", notice: null }
|
|
57
|
+
: { phase: "saving", notice: null };
|
|
58
|
+
case "confirm":
|
|
59
|
+
if (state.phase !== "confirming") return state;
|
|
60
|
+
return { phase: "saving", notice: null };
|
|
61
|
+
case "cancel":
|
|
62
|
+
if (state.phase !== "confirming") return state;
|
|
63
|
+
return { phase: "idle", notice: null };
|
|
64
|
+
case "saved":
|
|
65
|
+
return {
|
|
66
|
+
phase: "idle",
|
|
67
|
+
notice: { variant: "success", text: "Saved." },
|
|
68
|
+
};
|
|
69
|
+
case "failed":
|
|
70
|
+
return {
|
|
71
|
+
phase: "idle",
|
|
72
|
+
notice: {
|
|
73
|
+
variant: "error",
|
|
74
|
+
text: action.message ?? "Couldn't update automatic credit provisioning.",
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
case "dismiss-notice":
|
|
78
|
+
return { ...state, notice: null };
|
|
79
|
+
default:
|
|
80
|
+
return state;
|
|
81
|
+
}
|
|
82
|
+
}
|