add popups for free limit hit and spend cap hit (#6623)

This commit is contained in:
EthanHealy01
2026-06-11 20:34:59 +01:00
committed by GitHub
parent d48017a5b5
commit 7e493226c4
9 changed files with 718 additions and 107 deletions
@@ -5317,6 +5317,11 @@ api = "this tool"
automation = "automations"
default = "this feature"
[payg.spendCapMeter]
capSuffix = "/ {{amount}} cap"
metaCategories = "Automation · AI · API spend"
resets = "Resets each billing period"
[payg.state]
degraded = "Cap reached"
full = "Healthy"
@@ -5772,6 +5777,12 @@ highlight3 = "Community support"
included = "Included"
name = "Free"
[plan.freeLimit]
cta = "View Processor Plan"
dismiss = "Maybe Later"
message = "That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day."
title = "Woah, {{total}} PDFs Processed!"
[plan.highlights]
advancedIntegrations = "Advanced integrations"
allBasicFeatures = "All basic features"
@@ -5803,6 +5814,12 @@ highlight2 = "Advanced PDF tools"
highlight3 = "No watermarks"
name = "Pro"
[plan.spendCap]
cta = "View Spending Limit"
dismiss = "Not Now"
message = "You've made the most of this month's cap. That's a load of automation, AI and API work! Bump it up whenever you like to keep going."
title = "You're on a Roll!"
[plan.static]
activateLicense = "Activate Your License"
contactToUpgrade = "Contact us to upgrade or customize your plan"
@@ -5843,10 +5860,6 @@ subscribeToPro = "Subscribe to Pro"
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
deleteConfirmTitle = "Delete {{label}} policy?"
[policies]
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
deleteConfirmTitle = "Delete policy?"
[printFile]
title = "Print File"
+2
View File
@@ -16,6 +16,7 @@ import OAuthConsent from "@app/routes/OAuthConsent";
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
import UsageLimitModalHost from "@app/components/UsageLimitModalHost";
// Import global styles
import "@app/styles/tailwind.css";
@@ -45,6 +46,7 @@ function NonAuthBootstraps() {
<OnboardingBootstrap />
<TrialExpiredBootstrap />
<SignupRequiredBootstrap />
<UsageLimitModalHost />
</>
);
}
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { FreeLimitReachedModal } from "@app/components/shared/FreeLimitReachedModal";
import { SpendCapReachedModal } from "@app/components/shared/SpendCapReachedModal";
import {
FREE_LIMIT_MODAL_EVENT,
SPEND_CAP_MODAL_EVENT,
} from "@app/components/usageLimitModals";
/**
* Always-mounted host for the usage-limit warning modals. Mount once (in
* App.tsx); it renders nothing until openFreeLimitModal()/openSpendCapModal()
* (see usageLimitModals.ts) fire their bridge events. Each modal is mounted
* only while open, so it reads the wallet (and animates in) on open rather
* than on app load.
*/
export default function UsageLimitModalHost() {
const [freeOpen, setFreeOpen] = useState(false);
const [spendOpen, setSpendOpen] = useState(false);
useEffect(() => {
const onFree = () => setFreeOpen(true);
const onSpend = () => setSpendOpen(true);
window.addEventListener(FREE_LIMIT_MODAL_EVENT, onFree);
window.addEventListener(SPEND_CAP_MODAL_EVENT, onSpend);
return () => {
window.removeEventListener(FREE_LIMIT_MODAL_EVENT, onFree);
window.removeEventListener(SPEND_CAP_MODAL_EVENT, onSpend);
};
}, []);
return (
<>
{freeOpen && <FreeLimitReachedModal onClose={() => setFreeOpen(false)} />}
{spendOpen && (
<SpendCapReachedModal onClose={() => setSpendOpen(false)} />
)}
</>
);
}
@@ -5,7 +5,7 @@ import { createLightSlideBackground } from "@app/components/onboarding/slides/un
import {
FreeMeterPanel,
useFreeSnapshot,
} from "@app/components/shared/config/configSections/PaygFree";
} from "@app/components/shared/config/configSections/usageMeters";
import i18n from "@app/i18n";
import styles from "@app/components/onboarding/slides/SaasOnboardingSlides.module.css";
@@ -0,0 +1,213 @@
import { useMemo } from "react";
import { Modal, Stack, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import CelebrationIcon from "@mui/icons-material/CelebrationOutlined";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
import { navigateToSettings } from "@app/utils/settingsNavigation";
import {
FreeMeterPanel,
freeSnapshotFromWallet,
} from "@app/components/shared/config/configSections/usageMeters";
import { useWallet } from "@app/hooks/useWallet";
interface FreeLimitReachedModalProps {
onClose: () => void;
}
function readColor(varName: string, fallback: string): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim() || fallback
);
}
export function FreeLimitReachedModal({ onClose }: FreeLimitReachedModalProps) {
const { t } = useTranslation();
const { wallet, loading } = useWallet();
// Resolve theme colours once; reading the CSS vars on every render would
// force a style recalc.
const gradientStops = useMemo<[string, string]>(
() => [
readColor("--color-primary-500", "#3b82f6"),
readColor("--color-primary-800", "#1e40af"),
],
[],
);
// Hold the modal back until the wallet resolves so the meter never flashes
// placeholder numbers before the real ones land.
if (loading || !wallet) return null;
const snap = freeSnapshotFromWallet(wallet);
const circles = [
{
position: "bottom-left" as const,
size: 270,
color: "rgba(255, 255, 255, 0.25)",
opacity: 0.9,
amplitude: 24,
duration: 4.5,
offsetX: 18,
offsetY: 14,
},
{
position: "top-right" as const,
size: 300,
color: "rgba(255, 255, 255, 0.2)",
opacity: 0.9,
amplitude: 28,
duration: 4.5,
delay: 0.5,
offsetX: 24,
offsetY: 18,
},
];
const handleUpgrade = () => {
onClose();
navigateToSettings("plan");
};
return (
<Modal
opened
onClose={onClose}
withCloseButton={false}
centered
size="lg"
radius="lg"
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: {
overflow: "hidden",
border: "none",
background: "var(--bg-surface)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
},
}}
>
<Stack
gap={0}
className={styles.modalContent}
style={{
height: "100%",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
}}
>
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={gradientStops}
circles={circles}
isActive
slideKey="free-limit-reached"
/>
<div className={styles.heroLogo}>
<div className={styles.heroLogoCircle}>
<CelebrationIcon sx={{ fontSize: 64, color: "#000000" }} />
</div>
</div>
</div>
<div
className={styles.modalBody}
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
WebkitOverflowScrolling: "touch",
}}
>
<Stack gap={16}>
<div className={`${styles.title} ${styles.titleText}`}>
{t("plan.freeLimit.title", "Woah, {{total}} PDFs Processed!", {
total: snap.billableUsed.toLocaleString(),
})}
</div>
<div className={styles.bodyText}>
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{t(
"plan.freeLimit.message",
"That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day.",
)}
</div>
</div>
<FreeMeterPanel snap={snap} />
<div className={styles.buttonContainer}>
<style>{`
@media (max-width: 30rem) {
.free-limit-button-container {
justify-content: center !important;
}
.free-limit-modal-button {
flex: 1 1 100% !important;
}
}
.free-limit-modal-button-primary:hover {
background: linear-gradient(135deg, var(--color-primary-600), var(--color-primary-900)) !important;
}
`}</style>
<div
className="free-limit-button-container"
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.75rem",
justifyContent: "space-between",
}}
>
<Button
onClick={onClose}
variant="default"
size="sm"
className="free-limit-modal-button"
style={{
fontSize: "0.8125rem",
padding: "0.5rem 1rem",
height: "auto",
minWidth: "8.125rem",
flex: "0 1 auto",
border: "0",
}}
>
{t("plan.freeLimit.dismiss", "Maybe Later")}
</Button>
<Button
onClick={handleUpgrade}
size="md"
className="free-limit-modal-button free-limit-modal-button-primary"
style={{
background:
"linear-gradient(135deg, var(--color-primary-500), var(--color-primary-800))",
color: "#FFFFFF",
fontSize: "0.9375rem",
fontWeight: 600,
padding: "0.75rem 1.5rem",
height: "auto",
border: "none",
minWidth: "10.625rem",
flex: "0 1 auto",
}}
>
{t("plan.freeLimit.cta", "View Processor Plan")}
</Button>
</div>
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -0,0 +1,211 @@
import { useMemo } from "react";
import { Modal, Stack, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import TrendingUpIcon from "@mui/icons-material/TrendingUpOutlined";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
import { navigateToSettings } from "@app/utils/settingsNavigation";
import {
SpendCapMeterPanel,
spendCapSnapshotFromWallet,
} from "@app/components/shared/config/configSections/usageMeters";
import { useWallet } from "@app/hooks/useWallet";
interface SpendCapReachedModalProps {
onClose: () => void;
}
function readColor(varName: string, fallback: string): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim() || fallback
);
}
export function SpendCapReachedModal({ onClose }: SpendCapReachedModalProps) {
const { t } = useTranslation();
const { wallet, loading } = useWallet();
// Resolve theme colours once; reading the CSS vars on every render would
// force a style recalc.
const gradientStops = useMemo<[string, string]>(
() => [
readColor("--color-green-500", "#22c55e"),
readColor("--color-green-700", "#15803d"),
],
[],
);
// Hold the modal back until the wallet resolves so the meter never flashes
// placeholder numbers before the real ones land.
if (loading || !wallet) return null;
const snap = spendCapSnapshotFromWallet(wallet);
const circles = [
{
position: "bottom-left" as const,
size: 270,
color: "rgba(255, 255, 255, 0.25)",
opacity: 0.9,
amplitude: 24,
duration: 4.5,
offsetX: 18,
offsetY: 14,
},
{
position: "top-right" as const,
size: 300,
color: "rgba(255, 255, 255, 0.2)",
opacity: 0.9,
amplitude: 28,
duration: 4.5,
delay: 0.5,
offsetX: 24,
offsetY: 18,
},
];
const handleRaiseCap = () => {
onClose();
navigateToSettings("plan");
};
return (
<Modal
opened
onClose={onClose}
withCloseButton={false}
centered
size="lg"
radius="lg"
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: {
overflow: "hidden",
border: "none",
background: "var(--bg-surface)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
},
}}
>
<Stack
gap={0}
className={styles.modalContent}
style={{
height: "100%",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
}}
>
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={gradientStops}
circles={circles}
isActive
slideKey="spend-cap-reached"
/>
<div className={styles.heroLogo}>
<div className={styles.heroLogoCircle}>
<TrendingUpIcon sx={{ fontSize: 64, color: "#000000" }} />
</div>
</div>
</div>
<div
className={styles.modalBody}
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
WebkitOverflowScrolling: "touch",
}}
>
<Stack gap={16}>
<div className={`${styles.title} ${styles.titleText}`}>
{t("plan.spendCap.title", "You're on a Roll!")}
</div>
<div className={styles.bodyText}>
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{t(
"plan.spendCap.message",
"You've made the most of this month's cap. That's a load of automation, AI and API work! Bump it up whenever you like to keep going.",
)}
</div>
</div>
<SpendCapMeterPanel snap={snap} />
<div className={styles.buttonContainer}>
<style>{`
@media (max-width: 30rem) {
.spend-cap-button-container {
justify-content: center !important;
}
.spend-cap-modal-button {
flex: 1 1 100% !important;
}
}
.spend-cap-modal-button-primary:hover {
background: linear-gradient(135deg, var(--color-green-600), var(--color-green-800)) !important;
}
`}</style>
<div
className="spend-cap-button-container"
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.75rem",
justifyContent: "space-between",
}}
>
<Button
onClick={onClose}
variant="default"
size="sm"
className="spend-cap-modal-button"
style={{
fontSize: "0.8125rem",
padding: "0.5rem 1rem",
height: "auto",
minWidth: "8.125rem",
flex: "0 1 auto",
border: "0",
}}
>
{t("plan.spendCap.dismiss", "Not Now")}
</Button>
<Button
onClick={handleRaiseCap}
size="md"
className="spend-cap-modal-button spend-cap-modal-button-primary"
style={{
background:
"linear-gradient(135deg, var(--color-green-500), var(--color-green-700))",
color: "#FFFFFF",
fontSize: "0.9375rem",
fontWeight: 600,
padding: "0.75rem 1.5rem",
height: "auto",
border: "none",
minWidth: "10.625rem",
flex: "0 1 auto",
}}
>
{t("plan.spendCap.cta", "View Spending Limit")}
</Button>
</div>
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -23,7 +23,7 @@
* - {@link PaygFreeMember} — read-only; the CTA is replaced with an
* ask-the-owner note.
*/
import React, { useMemo, useState } from "react";
import React, { useState } from "react";
import { Stack } from "@mantine/core";
import BoltIcon from "@mui/icons-material/BoltRounded";
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
@@ -40,55 +40,11 @@ import "./PaygFree.css";
import UpgradeModal from "./UpgradeModal";
// eslint-disable-next-line no-restricted-imports
import { DocHelp } from "./Payg";
// ─── Shared free-tier snapshot ────────────────────────────
export interface FreeSnapshot {
/** One-time free documents used so far (grant remaining). */
billableUsed: number;
/**
* The team's one-time free grant size in documents. Real value from the
* wallet endpoint (pricing_policy.free_tier_units); 500 below is only the
* pre-load placeholder for the first paint.
*/
billableLimit: number;
}
/**
* Read free-tier snapshot from the real {@link useWallet} hook. Falls back to
* a zeroed view if the wallet hasn't loaded yet — this only happens briefly on
* first paint; once the snapshot arrives the component re-renders with real
* numbers. Earlier versions returned a mock "62 of 500" sentinel which leaked
* into the rendered UI and made the page look like nothing was wired up.
*/
export function useFreeSnapshot(): FreeSnapshot {
const { wallet } = useWallet();
return useMemo(() => {
if (wallet) {
return {
// Used = grant remaining, derived straight from the one-time grant so
// the free view never depends on the per-state meaning of billableUsed.
billableUsed: Math.max(0, wallet.freeAllowance - wallet.freeRemaining),
// The free view's ceiling IS the one-time grant size.
billableLimit: wallet.freeAllowance,
};
}
return { billableUsed: 0, billableLimit: 500 };
}, [wallet]);
}
type MeterState = "FULL" | "WARNED" | "DEGRADED";
/** Warn/degrade band for the one-time grant meter (mirrors the BE thresholds). */
function meterState(
used: number,
limit: number,
): { state: MeterState; pct: number } {
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100;
const state: MeterState =
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
return { state, pct };
}
import {
FreeMeterPanel,
useFreeSnapshot,
type FreeSnapshot,
} from "@app/components/shared/config/configSections/usageMeters";
// ─── Editor plan card (always-free tools only) ────────────────────────────
@@ -136,58 +92,6 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
);
}
// ─── Compact one-time free meter (right column of the Processor card) ──────
export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
const { t } = useTranslation();
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
const stateLabel =
state === "DEGRADED"
? t("payg.free.state.limitReached", "Limit reached")
: state === "WARNED"
? t("payg.free.state.approachingLimit", "Approaching limit")
: t("payg.free.state.plentyLeft", "Plenty left");
return (
<div className="paygf-meter" data-state={state}>
<div className="paygf-meter__top">
<div className="paygf-meter__figure">
<span className="paygf-meter__num">
{snap.billableUsed.toLocaleString()}
</span>
<span className="paygf-meter__cap">
{t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
limit: snap.billableLimit.toLocaleString(),
})}
</span>
</div>
<span className="payg-status" data-state={state}>
<span className="payg-status__dot" />
{stateLabel}
</span>
</div>
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={state}
style={{ width: `${pct}%` }}
/>
</div>
<div className="paygf-meter__meta">
<span>
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
</span>
<span className="payg-hero__meta-dot"></span>
<span>
{t("payg.free.hero.neverResets", "One-time — never resets")}
</span>
</div>
</div>
);
}
// ─── Processor plan card (two-column: pitch + benefits | meter + CTA) ──────
interface ProcessorCardProps {
@@ -0,0 +1,204 @@
/**
* Compact usage meters shared by the Plan section and the usage-limit warning
* modals. Kept in their own module (rather than inside Payg/PaygFree) so the
* modals can render a meter without pulling in the upgrade-checkout subtree
* (UpgradeModal, useWallet, etc.). Only depends on i18n + the co-located CSS.
*/
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useWallet, type Wallet } from "@app/hooks/useWallet";
import "@app/components/shared/config/configSections/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.css";
export type MeterState = "FULL" | "WARNED" | "DEGRADED";
/** Warn/degrade band for a usage meter (mirrors the BE thresholds). */
export function meterState(
used: number,
limit: number,
): { state: MeterState; pct: number } {
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100;
const state: MeterState =
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
return { state, pct };
}
/** Currency symbol for compact inline use; falls back to the ISO code. */
function currencySymbol(currency: string | null): string {
switch ((currency ?? "").toLowerCase()) {
case "usd":
return "$";
case "eur":
return "€";
case "gbp":
return "£";
default:
return currency ? currency.toUpperCase() + " " : "$";
}
}
// ─── One-time free grant meter ──────────────────────────────────────────────
export interface FreeSnapshot {
/** One-time free documents used so far (grant remaining). */
billableUsed: number;
/** The team's one-time free grant size in documents. */
billableLimit: number;
}
/**
* Derive the free-grant snapshot from a wallet. Null (not yet loaded) yields a
* zeroed view over the default 500 grant, the brief first-paint placeholder.
*/
export function freeSnapshotFromWallet(wallet: Wallet | null): FreeSnapshot {
if (!wallet) return { billableUsed: 0, billableLimit: 500 };
return {
billableUsed: Math.max(0, wallet.freeAllowance - wallet.freeRemaining),
billableLimit: wallet.freeAllowance,
};
}
/**
* Read the free-grant snapshot from the live wallet. Falls back to a zeroed
* view over the default grant until the wallet loads.
*/
export function useFreeSnapshot(): FreeSnapshot {
const { wallet } = useWallet();
return useMemo(() => freeSnapshotFromWallet(wallet), [wallet]);
}
export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
const { t } = useTranslation();
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
const stateLabel =
state === "DEGRADED"
? t("payg.free.state.limitReached", "Limit reached")
: state === "WARNED"
? t("payg.free.state.approachingLimit", "Approaching limit")
: t("payg.free.state.plentyLeft", "Plenty left");
return (
<div className="paygf-meter" data-state={state}>
<div className="paygf-meter__top">
<div className="paygf-meter__figure">
<span className="paygf-meter__num">
{snap.billableUsed.toLocaleString()}
</span>
<span className="paygf-meter__cap">
{t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
limit: snap.billableLimit.toLocaleString(),
})}
</span>
</div>
<span className="payg-status" data-state={state}>
<span className="payg-status__dot" />
{stateLabel}
</span>
</div>
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={state}
style={{ width: `${pct}%` }}
/>
</div>
<div className="paygf-meter__meta">
<span>
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
</span>
<span className="payg-hero__meta-dot"></span>
<span>{t("payg.free.hero.neverResets", "One-time, never resets")}</span>
</div>
</div>
);
}
// ─── Monthly spend-cap meter ────────────────────────────────────────────────
export interface SpendCapSnapshot {
/** Money spent so far this billing period, in major currency units. */
spent: number;
/** The configured monthly spend cap, in major currency units. */
cap: number;
/** ISO currency code of {@link spent}/{@link cap}; null falls back to "$". */
currency: string | null;
}
/**
* Derive the spend-vs-cap snapshot from a wallet. {@code estimatedBillMinor} is
* this period's charges in minor units; {@code capUsd} is the cap in major
* units. Null wallet (or no cap set) yields a zeroed view.
*/
export function spendCapSnapshotFromWallet(
wallet: Wallet | null,
): SpendCapSnapshot {
if (!wallet) return { spent: 0, cap: 0, currency: null };
return {
spent:
wallet.estimatedBillMinor != null ? wallet.estimatedBillMinor / 100 : 0,
cap: wallet.capUsd ?? 0,
currency: wallet.currency,
};
}
/**
* Sibling of {@link FreeMeterPanel} for the money cap rather than the one-time
* free grant. Shares the same bar/status styling and the cap-state labels
* ({@code payg.state.*}) used by the Plan hero, so it reads as the same meter.
*/
export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
const { t } = useTranslation();
const { state, pct } = meterState(snap.spent, snap.cap);
const stateLabel =
state === "DEGRADED"
? t("payg.state.degraded", "Cap reached")
: state === "WARNED"
? t("payg.state.warned", "Approaching cap")
: t("payg.state.full", "Healthy");
const symbol = currencySymbol(snap.currency);
return (
<div className="paygf-meter" data-state={state}>
<div className="paygf-meter__top">
<div className="paygf-meter__figure">
<span className="paygf-meter__num">
{symbol}
{snap.spent.toLocaleString()}
</span>
<span className="paygf-meter__cap">
{t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", {
amount: `${symbol}${snap.cap.toLocaleString()}`,
})}
</span>
</div>
<span className="payg-status" data-state={state}>
<span className="payg-status__dot" />
{stateLabel}
</span>
</div>
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={state}
style={{ width: `${pct}%` }}
/>
</div>
<div className="paygf-meter__meta">
<span>
{t(
"payg.spendCapMeter.metaCategories",
"Automation · AI · API spend",
)}
</span>
<span className="payg-hero__meta-dot"></span>
<span>
{t("payg.spendCapMeter.resets", "Resets each billing period")}
</span>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
/**
* Imperative API for the usage-limit warning modals.
*
* Call these from anywhere (React or not) to pop a modal. They bridge to the
* always-mounted {@link ./UsageLimitModalHost} host via a window event. No
* arguments and no context plumbing: the modal reads the live wallet itself to
* fill in the usage figures.
*
* import { openFreeLimitModal } from "@app/components/usageLimitModals";
* openFreeLimitModal();
*/
// Internal bridge events, not part of the public API. Use the helpers below.
export const FREE_LIMIT_MODAL_EVENT = "stirling:open-free-limit-modal";
export const SPEND_CAP_MODAL_EVENT = "stirling:open-spend-cap-modal";
/** Open the "free limit reached" modal. Figures come from the live wallet. */
export function openFreeLimitModal(): void {
window.dispatchEvent(new Event(FREE_LIMIT_MODAL_EVENT));
}
/** Open the "spend cap reached" modal. Figures come from the live wallet. */
export function openSpendCapModal(): void {
window.dispatchEvent(new Event(SPEND_CAP_MODAL_EVENT));
}