mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
add popups for free limit hit and spend cap hit (#6623)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user