From 7e493226c402642997e1029741d1ddd8a3d9b444 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:34:59 +0100 Subject: [PATCH] add popups for free limit hit and spend cap hit (#6623) --- .../public/locales/en-GB/translation.toml | 21 +- frontend/editor/src/saas/App.tsx | 2 + .../saas/components/UsageLimitModalHost.tsx | 39 ++++ .../onboarding/slides/UsageSnapshotSlide.tsx | 2 +- .../shared/FreeLimitReachedModal.tsx | 213 ++++++++++++++++++ .../shared/SpendCapReachedModal.tsx | 211 +++++++++++++++++ .../shared/config/configSections/PaygFree.tsx | 108 +-------- .../config/configSections/usageMeters.tsx | 204 +++++++++++++++++ .../src/saas/components/usageLimitModals.ts | 25 ++ 9 files changed, 718 insertions(+), 107 deletions(-) create mode 100644 frontend/editor/src/saas/components/UsageLimitModalHost.tsx create mode 100644 frontend/editor/src/saas/components/shared/FreeLimitReachedModal.tsx create mode 100644 frontend/editor/src/saas/components/shared/SpendCapReachedModal.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/usageMeters.tsx create mode 100644 frontend/editor/src/saas/components/usageLimitModals.ts diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 050104088..c4889e0b9 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index d1eab6c69..418903544 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -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() { + ); } diff --git a/frontend/editor/src/saas/components/UsageLimitModalHost.tsx b/frontend/editor/src/saas/components/UsageLimitModalHost.tsx new file mode 100644 index 000000000..c6e2a2887 --- /dev/null +++ b/frontend/editor/src/saas/components/UsageLimitModalHost.tsx @@ -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 && setFreeOpen(false)} />} + {spendOpen && ( + setSpendOpen(false)} /> + )} + + ); +} diff --git a/frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx b/frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx index 4c032fbfd..d34d1c086 100644 --- a/frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx +++ b/frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx @@ -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"; diff --git a/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.tsx b/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.tsx new file mode 100644 index 000000000..7db30887e --- /dev/null +++ b/frontend/editor/src/saas/components/shared/FreeLimitReachedModal.tsx @@ -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 ( + + +
+ +
+
+ +
+
+
+ +
+ +
+ {t("plan.freeLimit.title", "Woah, {{total}} PDFs Processed!", { + total: snap.billableUsed.toLocaleString(), + })} +
+ +
+
+ {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.", + )} +
+
+ + + +
+ +
+ + + +
+
+
+
+
+
+ ); +} diff --git a/frontend/editor/src/saas/components/shared/SpendCapReachedModal.tsx b/frontend/editor/src/saas/components/shared/SpendCapReachedModal.tsx new file mode 100644 index 000000000..c124b9bae --- /dev/null +++ b/frontend/editor/src/saas/components/shared/SpendCapReachedModal.tsx @@ -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 ( + + +
+ +
+
+ +
+
+
+ +
+ +
+ {t("plan.spendCap.title", "You're on a Roll!")} +
+ +
+
+ {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.", + )} +
+
+ + + +
+ +
+ + + +
+
+
+
+
+
+ ); +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx index ced4f4c2b..330bffa71 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx @@ -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 ( -
-
-
- - {snap.billableUsed.toLocaleString()} - - - {t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", { - limit: snap.billableLimit.toLocaleString(), - })} - -
- - - {stateLabel} - -
- -
-
-
- -
- - {t("payg.free.hero.metaCategories", "Automation · AI · API requests")} - - - - {t("payg.free.hero.neverResets", "One-time — never resets")} - -
-
- ); -} - // ─── Processor plan card (two-column: pitch + benefits | meter + CTA) ────── interface ProcessorCardProps { diff --git a/frontend/editor/src/saas/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/saas/components/shared/config/configSections/usageMeters.tsx new file mode 100644 index 000000000..b28f785ee --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/usageMeters.tsx @@ -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 ( +
+
+
+ + {snap.billableUsed.toLocaleString()} + + + {t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", { + limit: snap.billableLimit.toLocaleString(), + })} + +
+ + + {stateLabel} + +
+ +
+
+
+ +
+ + {t("payg.free.hero.metaCategories", "Automation · AI · API requests")} + + + {t("payg.free.hero.neverResets", "One-time, never resets")} +
+
+ ); +} + +// ─── 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 ( +
+
+
+ + {symbol} + {snap.spent.toLocaleString()} + + + {t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", { + amount: `${symbol}${snap.cap.toLocaleString()}`, + })} + +
+ + + {stateLabel} + +
+ +
+
+
+ +
+ + {t( + "payg.spendCapMeter.metaCategories", + "Automation · AI · API spend", + )} + + + + {t("payg.spendCapMeter.resets", "Resets each billing period")} + +
+
+ ); +} diff --git a/frontend/editor/src/saas/components/usageLimitModals.ts b/frontend/editor/src/saas/components/usageLimitModals.ts new file mode 100644 index 000000000..bb5fc59fe --- /dev/null +++ b/frontend/editor/src/saas/components/usageLimitModals.ts @@ -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)); +}