mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +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"
|
automation = "automations"
|
||||||
default = "this feature"
|
default = "this feature"
|
||||||
|
|
||||||
|
[payg.spendCapMeter]
|
||||||
|
capSuffix = "/ {{amount}} cap"
|
||||||
|
metaCategories = "Automation · AI · API spend"
|
||||||
|
resets = "Resets each billing period"
|
||||||
|
|
||||||
[payg.state]
|
[payg.state]
|
||||||
degraded = "Cap reached"
|
degraded = "Cap reached"
|
||||||
full = "Healthy"
|
full = "Healthy"
|
||||||
@@ -5772,6 +5777,12 @@ highlight3 = "Community support"
|
|||||||
included = "Included"
|
included = "Included"
|
||||||
name = "Free"
|
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]
|
[plan.highlights]
|
||||||
advancedIntegrations = "Advanced integrations"
|
advancedIntegrations = "Advanced integrations"
|
||||||
allBasicFeatures = "All basic features"
|
allBasicFeatures = "All basic features"
|
||||||
@@ -5803,6 +5814,12 @@ highlight2 = "Advanced PDF tools"
|
|||||||
highlight3 = "No watermarks"
|
highlight3 = "No watermarks"
|
||||||
name = "Pro"
|
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]
|
[plan.static]
|
||||||
activateLicense = "Activate Your License"
|
activateLicense = "Activate Your License"
|
||||||
contactToUpgrade = "Contact us to upgrade or customize your plan"
|
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."
|
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||||
deleteConfirmTitle = "Delete {{label}} policy?"
|
deleteConfirmTitle = "Delete {{label}} policy?"
|
||||||
|
|
||||||
[policies]
|
|
||||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
|
||||||
deleteConfirmTitle = "Delete policy?"
|
|
||||||
|
|
||||||
[printFile]
|
[printFile]
|
||||||
title = "Print File"
|
title = "Print File"
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import OAuthConsent from "@app/routes/OAuthConsent";
|
|||||||
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
||||||
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
|
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
|
||||||
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
|
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
|
||||||
|
import UsageLimitModalHost from "@app/components/UsageLimitModalHost";
|
||||||
|
|
||||||
// Import global styles
|
// Import global styles
|
||||||
import "@app/styles/tailwind.css";
|
import "@app/styles/tailwind.css";
|
||||||
@@ -45,6 +46,7 @@ function NonAuthBootstraps() {
|
|||||||
<OnboardingBootstrap />
|
<OnboardingBootstrap />
|
||||||
<TrialExpiredBootstrap />
|
<TrialExpiredBootstrap />
|
||||||
<SignupRequiredBootstrap />
|
<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 {
|
import {
|
||||||
FreeMeterPanel,
|
FreeMeterPanel,
|
||||||
useFreeSnapshot,
|
useFreeSnapshot,
|
||||||
} from "@app/components/shared/config/configSections/PaygFree";
|
} from "@app/components/shared/config/configSections/usageMeters";
|
||||||
import i18n from "@app/i18n";
|
import i18n from "@app/i18n";
|
||||||
import styles from "@app/components/onboarding/slides/SaasOnboardingSlides.module.css";
|
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
|
* - {@link PaygFreeMember} — read-only; the CTA is replaced with an
|
||||||
* ask-the-owner note.
|
* ask-the-owner note.
|
||||||
*/
|
*/
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Stack } from "@mantine/core";
|
import { Stack } from "@mantine/core";
|
||||||
import BoltIcon from "@mui/icons-material/BoltRounded";
|
import BoltIcon from "@mui/icons-material/BoltRounded";
|
||||||
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
|
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
|
||||||
@@ -40,55 +40,11 @@ import "./PaygFree.css";
|
|||||||
import UpgradeModal from "./UpgradeModal";
|
import UpgradeModal from "./UpgradeModal";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { DocHelp } from "./Payg";
|
import { DocHelp } from "./Payg";
|
||||||
|
import {
|
||||||
// ─── Shared free-tier snapshot ────────────────────────────
|
FreeMeterPanel,
|
||||||
|
useFreeSnapshot,
|
||||||
export interface FreeSnapshot {
|
type FreeSnapshot,
|
||||||
/** One-time free documents used so far (grant − remaining). */
|
} from "@app/components/shared/config/configSections/usageMeters";
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Editor plan card (always-free tools only) ────────────────────────────
|
// ─── 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) ──────
|
// ─── Processor plan card (two-column: pitch + benefits | meter + CTA) ──────
|
||||||
|
|
||||||
interface ProcessorCardProps {
|
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