Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c4401476 | ||
|
|
a24bffdb8e | ||
|
|
9f3b3b174d | ||
|
|
1de5177f96 | ||
|
|
c3a3c95b7c | ||
|
|
e6754ede32 | ||
|
|
2b11e35530 | ||
|
|
e4d8832c27 | ||
|
|
d3ffc6ebeb | ||
|
|
b46f65ef8c | ||
|
|
ac33e950b5 | ||
|
|
c655517ca3 | ||
|
|
e99dfb81ae | ||
|
|
2faa38c011 | ||
|
|
a15cf5f90d | ||
|
|
e8fd67c004 | ||
|
|
b3cae8c0e3 | ||
|
|
81b7edc84c |
@@ -3132,6 +3132,10 @@ integration = "Integration Configuration"
|
||||
security = "Security Configuration"
|
||||
system = "System Configuration"
|
||||
|
||||
[config.payg]
|
||||
label = "Billing & usage"
|
||||
section = "Pay-as-you-go"
|
||||
|
||||
[connectionMode.status]
|
||||
localOffline = "Offline mode running"
|
||||
localOnline = "Offline mode running"
|
||||
@@ -5568,6 +5572,230 @@ bullet3 = "Invalid expressions are ignored"
|
||||
text = "Keep these guidelines in mind:"
|
||||
title = "Tips"
|
||||
|
||||
[payg]
|
||||
subtitle = "Pay-as-you-go — you only pay for what you process. Billing period {{start}} – {{end}}."
|
||||
|
||||
[payg.activity]
|
||||
subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here."
|
||||
title = "Recent billable activity"
|
||||
units = "units"
|
||||
viewAll = "View all"
|
||||
|
||||
[payg.cap]
|
||||
amount = "Cap amount"
|
||||
currency = "Currency"
|
||||
degradeAt = "Limit spend at"
|
||||
degradeAtDesc = "Set below 100% to leave yourself headroom."
|
||||
perMonth = "/ month"
|
||||
preview = "{{money}} ≈ {{units}} documents per month"
|
||||
previewNote = "At current pricing. Final translation happens server-side on save."
|
||||
previewShort = "≈ {{units}} documents per month at current pricing."
|
||||
save = "Update cap"
|
||||
subtitle = "Set your maximum spend. We convert this to documents using the current price tier."
|
||||
title = "Monthly spending cap"
|
||||
warnAt = "Warn me at"
|
||||
warnAtDesc = "Notify when usage crosses this threshold."
|
||||
|
||||
[payg.checkout]
|
||||
connecting = "Connecting to Stripe…"
|
||||
errorTitle = "Stripe error"
|
||||
|
||||
[payg.checkout.error]
|
||||
startFailed = "Couldn't start checkout session"
|
||||
|
||||
[payg.checkout.mock]
|
||||
backend = "Backend is in mock mode — no real Stripe session was created."
|
||||
continue = "Continue with mock subscription"
|
||||
noKey = "VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured."
|
||||
title = "Stripe Embedded Checkout (mock mode)"
|
||||
|
||||
[payg.confirm]
|
||||
body = "Your team can now run automation, AI, and API operations beyond the 500/month free allowance."
|
||||
capValue = "{{symbol}}{{amount}} / month"
|
||||
noCap = "No cap"
|
||||
note = "You can change your cap, cancel, or open the Stripe customer portal any time from this page."
|
||||
summaryLabel = "Monthly ceiling"
|
||||
title = "Welcome to the Processor plan"
|
||||
|
||||
[payg.error]
|
||||
body = "We couldn't reach the billing service. Refresh the page to try again."
|
||||
title = "Couldn't load your plan"
|
||||
|
||||
[payg.exhausted]
|
||||
body = "You've used your free 500 operations this month. Upgrade to Processor to keep going."
|
||||
cta = "Go to billing"
|
||||
title = "You've hit your free monthly limit"
|
||||
|
||||
[payg.free.cta]
|
||||
benefit1Body = "chain tools, schedule runs, batch process"
|
||||
benefit1Title = "Automation pipelines"
|
||||
benefit2Body = "summarise, classify, redact, AI-OCR"
|
||||
benefit2Title = "AI tools"
|
||||
benefit3Body = "call any Stirling endpoint programmatically"
|
||||
benefit3Title = "API access"
|
||||
button = "Turn on Processor →"
|
||||
reassurance = "No minimum · Set a $0 cap to test · Cancel anytime"
|
||||
subtitle = "Unlock more than 500 automation, AI, and API operations per month. Set a monthly ceiling — you stay in control."
|
||||
title = "Turn on the Processor plan"
|
||||
|
||||
[payg.free.explainer]
|
||||
alwaysFreeBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, no matter where you trigger them from."
|
||||
alwaysFreeForYouBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, never counted."
|
||||
alwaysFreeForYouLabel = "Always free for you"
|
||||
alwaysFreeLabel = "Always free"
|
||||
countsBody = "Automation pipelines (chained tools, scheduled runs), AI tools (summaries, classification, AI-OCR), and API calls (programmatic access). Above 500 you'll need Processor."
|
||||
countsLabel = "Counts toward 500/month"
|
||||
sharedBody = "Automation pipelines, AI tools, and API calls share a single 500/month allowance across the whole team. If it's full, ask your owner to enable Processor."
|
||||
sharedLabel = "Shared with the team"
|
||||
|
||||
[payg.free.header]
|
||||
subtitle = "Editor plan — manual tools are always free. Pay only for automation, AI & API. Billing period {{period}}."
|
||||
|
||||
[payg.free.hero]
|
||||
capSuffix = "/ {{limit}} free operations"
|
||||
eyebrow = "This billing period"
|
||||
metaCategories = "Automation · AI · API requests"
|
||||
resetsIn = "Resets in {{days}} days"
|
||||
resetsTomorrow = "Resets tomorrow"
|
||||
|
||||
[payg.free.member]
|
||||
body = "Your team owner can enable the Processor plan and set a monthly ceiling. Until then, manual tools are free for you to use as much as you like — automation, AI, and API access are limited to the team's 500/month free allowance."
|
||||
title = "Want more than 500 automation, AI, or API operations a month?"
|
||||
|
||||
[payg.free.state]
|
||||
approachingLimit = "Approaching limit"
|
||||
limitReached = "Limit reached"
|
||||
plentyLeft = "Plenty left"
|
||||
|
||||
[payg.gates]
|
||||
ai = "AI tools (AI Create, suggestions, AI-OCR)"
|
||||
automation = "Automations & pipelines"
|
||||
client = "Browser-only tools (viewer, page editor, file management)"
|
||||
offsite = "Server tools (compress, OCR, convert, watermark…)"
|
||||
pauses = "pauses at cap"
|
||||
subtitle = "Your everyday tools keep working. Only AI and automation pause until the cap resets or is raised."
|
||||
title = "What happens when the cap is reached"
|
||||
|
||||
[payg.member]
|
||||
askLeader = "Only your team owner can change the cap."
|
||||
contactLeader = "Ask team owner to raise cap"
|
||||
editCap = "Edit"
|
||||
noCap = "· no sub-cap"
|
||||
setCap = "Set cap"
|
||||
units = "units"
|
||||
|
||||
[payg.role]
|
||||
leader = "Team owner"
|
||||
member = "Member"
|
||||
|
||||
[payg.signupRequired]
|
||||
body = "Stirling PDF gives every signed-up account 500 free operations per month — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
|
||||
cancel = "Not now"
|
||||
cta = "Sign up free"
|
||||
subtext = "Creating an account is free and takes a few seconds. No credit card required."
|
||||
title = "Sign up to use {{category}}"
|
||||
|
||||
[payg.signupRequired.category]
|
||||
ai = "AI features"
|
||||
api = "this tool"
|
||||
automation = "automations"
|
||||
default = "this feature"
|
||||
|
||||
[payg.state]
|
||||
degraded = "Cap reached"
|
||||
full = "Healthy"
|
||||
warned = "Approaching cap"
|
||||
|
||||
[payg.stripe]
|
||||
open = "Open billing portal"
|
||||
subtitle = "Receipts, invoices, payment method, billing currency."
|
||||
title = "Manage billing in Stripe"
|
||||
|
||||
[payg.stripe.toast.unavailable]
|
||||
body = "Billing portal isn't available right now. Try again in a moment."
|
||||
title = "Billing portal unavailable"
|
||||
|
||||
[payg.subcaps]
|
||||
subtitle = "Optional: cap individual teammates so one person can't drain the team budget."
|
||||
title = "Per-member sub-caps"
|
||||
|
||||
[payg.subcaps.editor]
|
||||
label = "Sub-cap for {{name}}"
|
||||
remove = "Remove cap"
|
||||
|
||||
[payg.subcaps.toast.clamped]
|
||||
body = "Clamped to team cap of {{units}} documents."
|
||||
title = "Sub-cap clamped"
|
||||
|
||||
[payg.subcaps.toast.error]
|
||||
body = "Please try again in a moment."
|
||||
title = "Couldn't update sub-cap"
|
||||
|
||||
[payg.subcaps.toast.saved]
|
||||
title = "Sub-cap updated"
|
||||
|
||||
[payg.upgrade]
|
||||
closeAria = "Close"
|
||||
|
||||
[payg.upgrade.button]
|
||||
back = "← Back"
|
||||
cancel = "Cancel"
|
||||
continue = "Continue →"
|
||||
finish = "Finish"
|
||||
|
||||
[payg.upgrade.cap]
|
||||
customPlaceholder = "Or enter your own amount ($0 keeps it free)"
|
||||
help = "We'll never charge above this. Your first 500 automation / AI / API operations every month are free. Set $0 if you want to keep everything free while testing."
|
||||
noCapHint = "You can still cancel anytime from the customer portal."
|
||||
noCapLabel = "No cap — I'll manage spend manually"
|
||||
perMonthSuffix = "/ month"
|
||||
presetsAria = "Monthly cap preset"
|
||||
title = "Set your monthly spend ceiling"
|
||||
|
||||
[payg.upgrade.checkout]
|
||||
capLabel = "Monthly ceiling:"
|
||||
capValue = "{{symbol}}{{amount}} / month"
|
||||
edit = "Edit"
|
||||
help = "Stripe handles your card details. Stirling never sees them."
|
||||
loading = "Loading checkout…"
|
||||
noCap = "No cap"
|
||||
title = "Add your payment method"
|
||||
|
||||
[payg.upgrade.footer]
|
||||
capHint = "You can change your cap any time later."
|
||||
checkoutHint = "Card details handled by Stripe — never touched by Stirling."
|
||||
confirmHint = "Your wallet will refresh automatically in a moment."
|
||||
|
||||
[payg.upgrade.help]
|
||||
aiBody = "summarise, classify, redact, AI-OCR"
|
||||
aiTitle = "AI tools"
|
||||
apiBody = "programmatic access to any Stirling endpoint"
|
||||
apiTitle = "API calls"
|
||||
automationBody = "chained tools or scheduled runs that don't need clicks"
|
||||
automationTitle = "Automation pipelines"
|
||||
footnote = "Manual tools — viewing, editing, merging, splitting, watermarking, compressing, manual OCR — are always free, even past 500. The distinction is the type of work, not where you click."
|
||||
title = "What we count toward billing"
|
||||
|
||||
[payg.upgrade.promise]
|
||||
body = "You only pay for automation pipelines, AI tools, and API calls — the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge."
|
||||
highlight = "Manual tools stay free, always."
|
||||
|
||||
[payg.upgrade.steps]
|
||||
cap = "Set monthly ceiling"
|
||||
payment = "Add payment method"
|
||||
|
||||
[payg.upgrade.title]
|
||||
confirm = "You're subscribed"
|
||||
default = "Upgrade to Processor plan"
|
||||
|
||||
[payg.usage]
|
||||
credit = "{{amount}} account credit"
|
||||
docs = "documents"
|
||||
resetsIn = "Resets in {{days}} days"
|
||||
resetsTomorrow = "Resets tomorrow"
|
||||
spent = "{{spend}} of {{cap}} used"
|
||||
thisPeriod = "This billing period"
|
||||
|
||||
[payment]
|
||||
autoClose = "This window will close automatically..."
|
||||
billingPeriod = "Billing Period"
|
||||
|
||||
@@ -30,6 +30,7 @@ export const VALID_NAV_KEYS = [
|
||||
"adminEndpoints",
|
||||
"adminStorageSharing",
|
||||
"help",
|
||||
"payg",
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface AppConfig {
|
||||
enableDesktopInstallSlide?: boolean;
|
||||
premiumEnabled?: boolean;
|
||||
premiumKey?: string;
|
||||
paygEnabled?: boolean;
|
||||
termsAndConditions?: string;
|
||||
privacyPolicy?: string;
|
||||
cookiePolicy?: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ import AuthCallback from "@app/routes/AuthCallback";
|
||||
import ResetPassword from "@app/routes/ResetPassword";
|
||||
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
||||
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
|
||||
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
@@ -36,6 +37,7 @@ export default function App() {
|
||||
<AppLayout>
|
||||
<OnboardingBootstrap />
|
||||
<TrialExpiredBootstrap />
|
||||
<SignupRequiredBootstrap />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Modal, Stack, Button, Text } from "@mantine/core";
|
||||
import PersonAddIcon from "@mui/icons-material/PersonAdd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
import type { PaygSignupRequiredDetail } from "@app/services/paygErrorInterceptor";
|
||||
|
||||
/**
|
||||
* Bootstrap that listens for {@code payg:signupRequired} (dispatched by
|
||||
* the {@code apiClient} response interceptor when an anonymous user hits
|
||||
* a billable endpoint and the server returns {@code 401 SIGNUP_REQUIRED})
|
||||
* and opens a Mantine modal explaining the free 500-op/month allowance
|
||||
* with a "Sign up free" CTA.
|
||||
*
|
||||
* <h2>Why an event bus instead of direct render</h2>
|
||||
* The {@code apiClient} module is created at app boot, outside the React
|
||||
* tree, and can't import JSX. We bridge with a {@code CustomEvent}: the
|
||||
* interceptor dispatches, this bootstrap (mounted near the app root)
|
||||
* listens and renders. Same pattern as {@code TrialExpiredBootstrap},
|
||||
* just driven by a request-side trigger rather than auth state.
|
||||
*
|
||||
* <h2>De-duping</h2>
|
||||
* If the user fires multiple billable requests in quick succession (e.g.
|
||||
* clicking a tool button twice), only one modal opens — the listener
|
||||
* ignores the event when the modal is already visible. The modal closes
|
||||
* on backdrop click or Escape; we don't gate it on a localStorage flag
|
||||
* because this is a deterministic "you need an account" UI, not a one-
|
||||
* time onboarding nudge.
|
||||
*/
|
||||
export default function SignupRequiredBootstrap() {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<PaygSignupRequiredDetail>).detail;
|
||||
// De-dupe: if the modal is already open, the existing copy wins.
|
||||
// The user is already being prompted; piling another open call on
|
||||
// top would just flicker the same content.
|
||||
setOpened((wasOpen) => {
|
||||
if (!wasOpen) {
|
||||
setCategory(detail?.category ?? null);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
window.addEventListener("payg:signupRequired", handler as EventListener);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"payg:signupRequired",
|
||||
handler as EventListener,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Map the server's gate categories to user-facing nouns. The server
|
||||
// returns the FeatureGate name (AI, AUTOMATION, API); the user has
|
||||
// no idea what those are in raw form, so we pretty-print here. The
|
||||
// fallback "this feature" keeps the modal sensible if the BE adds
|
||||
// a category we don't know about.
|
||||
const categoryNoun = useMemo(() => {
|
||||
switch ((category ?? "").toUpperCase()) {
|
||||
case "AI":
|
||||
return t("payg.signupRequired.category.ai", "AI features");
|
||||
case "AUTOMATION":
|
||||
return t("payg.signupRequired.category.automation", "automations");
|
||||
case "API":
|
||||
return t("payg.signupRequired.category.api", "this tool");
|
||||
default:
|
||||
return t("payg.signupRequired.category.default", "this feature");
|
||||
}
|
||||
}, [category, t]);
|
||||
|
||||
const handleSignUp = () => {
|
||||
window.location.href = withBasePath("/signup");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
withCloseButton
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
title={
|
||||
<Text fw={700} size="lg">
|
||||
{t("payg.signupRequired.title", "Sign up to use {{category}}", {
|
||||
category: categoryNoun,
|
||||
})}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t(
|
||||
"payg.signupRequired.body",
|
||||
"Stirling PDF gives every signed-up account 500 free operations per month — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing.",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"payg.signupRequired.subtext",
|
||||
"Creating an account is free and takes a few seconds. No credit card required.",
|
||||
)}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Button variant="default" onClick={() => setOpened(false)}>
|
||||
{t("payg.signupRequired.cancel", "Not now")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<PersonAddIcon style={{ fontSize: 16 }} />}
|
||||
onClick={handleSignUp}
|
||||
>
|
||||
{t("payg.signupRequired.cta", "Sign up free")}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -106,7 +106,9 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
|
||||
const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
|
||||
|
||||
// Left navigation structure and icons
|
||||
// Left navigation structure and icons. The Plan tab now internally branches
|
||||
// free vs subscribed × leader vs member via useWallet(), so the modal no
|
||||
// longer plumbs paygEnabled / isLeader through to the nav builder.
|
||||
const configNavSections = useMemo(
|
||||
() =>
|
||||
createSaasConfigNavSections(Overview, openLogoutConfirm, {
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
/* Pay-as-you-go settings screen — visual polish.
|
||||
Uses the app's semantic theme tokens (theme.css) so it tracks light/dark. */
|
||||
|
||||
.payg {
|
||||
--payg-accent: #0a8bff;
|
||||
--payg-accent-soft: rgba(10, 139, 255, 0.1);
|
||||
--payg-radius: 14px;
|
||||
--payg-gap: 20px;
|
||||
/* Cards sit ON the modal content bg; in light that's white-on-white so we
|
||||
lean on border + shadow. Tokens are overridden per scheme below. */
|
||||
--payg-card-bg: var(--bg-surface);
|
||||
--payg-card-border: var(--border-default);
|
||||
--payg-inset-bg: var(--bg-raised);
|
||||
--payg-divider: var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Dark mode: the modal content bg is #2a2f36 and so is --bg-surface, so plain
|
||||
cards vanish. Lift cards a shade above the modal and strengthen borders. */
|
||||
[data-mantine-color-scheme="dark"] .payg {
|
||||
--payg-card-bg: #313842;
|
||||
--payg-card-border: #3d444e;
|
||||
--payg-inset-bg: #272c33;
|
||||
--payg-accent-soft: rgba(10, 139, 255, 0.16);
|
||||
--payg-divider: #3d444e;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||
.payg-header__title {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.payg-header__subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.payg-role-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.payg-role-pill[data-leader="true"] {
|
||||
background: var(--payg-accent-soft);
|
||||
color: var(--payg-accent);
|
||||
border: 1px solid rgba(10, 139, 255, 0.25);
|
||||
}
|
||||
.payg-role-pill[data-leader="false"] {
|
||||
background: var(--bg-muted);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* ── Generic card ───────────────────────────────────────────────────── */
|
||||
.payg-card {
|
||||
background: var(--payg-card-bg);
|
||||
border: 1px solid var(--payg-card-border);
|
||||
border-radius: var(--payg-radius);
|
||||
padding: 22px 24px;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.payg-card__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.payg-card__subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Hero usage panel ───────────────────────────────────────────────── */
|
||||
.payg-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--payg-radius);
|
||||
padding: 24px 26px;
|
||||
border: 1px solid var(--payg-card-border);
|
||||
background: var(--payg-card-bg);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.payg-hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.payg-hero[data-state="FULL"]::before {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(10, 139, 255, 0.12) 0%,
|
||||
rgba(10, 139, 255, 0) 55%
|
||||
);
|
||||
}
|
||||
.payg-hero[data-state="WARNED"]::before {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(234, 179, 8, 0.16) 0%,
|
||||
rgba(234, 179, 8, 0) 55%
|
||||
);
|
||||
}
|
||||
.payg-hero[data-state="DEGRADED"]::before {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(239, 68, 68, 0.16) 0%,
|
||||
rgba(239, 68, 68, 0) 55%
|
||||
);
|
||||
}
|
||||
.payg-hero__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.payg-hero__eyebrow {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.payg-hero__figure {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.payg-hero__spend {
|
||||
font-size: 2.35rem;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.payg-hero__cap {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.payg-hero__meta {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 18px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.payg-hero__meta-dot {
|
||||
color: var(--border-strong);
|
||||
}
|
||||
.payg-hero__credit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-green-700);
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .payg-hero__credit {
|
||||
color: #4ade80;
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
|
||||
/* Make Mantine default-variant buttons read clearly on the lifted cards in
|
||||
both themes (their stock border can wash out against --payg-card-bg). */
|
||||
.payg .mantine-Button-root[data-variant="default"] {
|
||||
border-color: var(--payg-card-border);
|
||||
}
|
||||
|
||||
/* Dark mode: the stock default button is a flat slab that disappears into the
|
||||
card. Lift it with a soft top-down gradient + brighter border, and warm the
|
||||
hover so the buttons feel tactile against --payg-card-bg (#313842). Driven
|
||||
through Mantine's --button-* vars. The same rule covers disabled buttons
|
||||
(Cancel/Update cap before the form is dirty) so they read identically to the
|
||||
always-enabled ones — Mantine only fades them via opacity. */
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.payg
|
||||
.mantine-Button-root[data-variant="default"] {
|
||||
--button-bg: linear-gradient(180deg, #3d4651 0%, #353d48 100%);
|
||||
--button-hover: linear-gradient(180deg, #475160 0%, #3d4654 100%);
|
||||
--button-bd: 1px solid #4b5563;
|
||||
--button-color: var(--mantine-color-white);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.04) inset,
|
||||
0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
/* Mantine recolours disabled buttons to a dim grey via a hard-coded color/bg
|
||||
on the disabled selector, overriding --button-color. Re-assert white + the
|
||||
gradient so disabled buttons match the rest. */
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.payg
|
||||
.mantine-Button-root[data-variant="default"]:disabled,
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.payg
|
||||
.mantine-Button-root[data-variant="default"][data-disabled] {
|
||||
background: var(--button-bg);
|
||||
color: var(--mantine-color-white);
|
||||
}
|
||||
|
||||
/* Status chip */
|
||||
.payg-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 13px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.payg-status__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.payg-status[data-state="FULL"] {
|
||||
background: var(--color-green-100);
|
||||
color: var(--color-green-700);
|
||||
}
|
||||
.payg-status[data-state="FULL"] .payg-status__dot {
|
||||
background: var(--color-green-500);
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
.payg-status[data-state="WARNED"] {
|
||||
background: var(--color-yellow-100);
|
||||
color: var(--color-yellow-700);
|
||||
}
|
||||
.payg-status[data-state="WARNED"] .payg-status__dot {
|
||||
background: var(--color-yellow-500);
|
||||
box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2);
|
||||
}
|
||||
.payg-status[data-state="DEGRADED"] {
|
||||
background: var(--color-red-100);
|
||||
color: var(--color-red-700);
|
||||
}
|
||||
.payg-status[data-state="DEGRADED"] .payg-status__dot {
|
||||
background: var(--color-red-500);
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Segmented usage bar */
|
||||
.payg-bar {
|
||||
margin-top: 18px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-muted);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.payg-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.payg-bar__fill[data-state="FULL"] {
|
||||
background: linear-gradient(90deg, #0a8bff, #38bdf8);
|
||||
}
|
||||
.payg-bar__fill[data-state="WARNED"] {
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
}
|
||||
.payg-bar__fill[data-state="DEGRADED"] {
|
||||
background: linear-gradient(90deg, #dc2626, #f87171);
|
||||
}
|
||||
|
||||
/* ── Cap preview strip ──────────────────────────────────────────────── */
|
||||
.payg-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 13px 16px;
|
||||
border-radius: 10px;
|
||||
background: var(--payg-accent-soft);
|
||||
border: 1px solid rgba(10, 139, 255, 0.2);
|
||||
}
|
||||
.payg-preview__icon {
|
||||
color: var(--payg-accent);
|
||||
display: flex;
|
||||
}
|
||||
.payg-preview__main {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 550;
|
||||
}
|
||||
.payg-preview__note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ── Gates grid ─────────────────────────────────────────────────────── */
|
||||
.payg-gates {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.payg-gates {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.payg-gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 13px 15px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid var(--payg-card-border);
|
||||
background: var(--payg-inset-bg);
|
||||
}
|
||||
.payg-gate[data-enabled="false"] {
|
||||
border-style: dashed;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.payg-gate__chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.payg-gate[data-enabled="true"] .payg-gate__chip {
|
||||
background: rgba(34, 197, 94, 0.16);
|
||||
color: #22c55e;
|
||||
}
|
||||
.payg-gate[data-enabled="false"] .payg-gate__chip {
|
||||
background: rgba(239, 68, 68, 0.16);
|
||||
color: #f87171;
|
||||
}
|
||||
.payg-gate__label {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
.payg-gate__tag {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.payg-gate__tag[data-variant="on"] {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
.payg-gate__tag[data-variant="pause"] {
|
||||
color: #dc2626;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .payg-gate__tag[data-variant="pause"] {
|
||||
color: #f87171;
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
|
||||
/* ── Member rows ────────────────────────────────────────────────────── */
|
||||
.payg-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 4px;
|
||||
border-bottom: 1px solid var(--payg-divider);
|
||||
}
|
||||
.payg-member:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.payg-member__avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.payg-member__name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 550;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.payg-member__email {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.payg-member__usage {
|
||||
text-align: right;
|
||||
min-width: 120px;
|
||||
}
|
||||
.payg-member__usage-num {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.payg-member__minibar {
|
||||
height: 5px;
|
||||
width: 90px;
|
||||
border-radius: 999px;
|
||||
background: var(--payg-inset-bg);
|
||||
overflow: hidden;
|
||||
margin-top: 5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.payg-member__minibar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--payg-accent);
|
||||
}
|
||||
|
||||
/* ── Activity feed ──────────────────────────────────────────────────── */
|
||||
.payg-activity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 11px 4px;
|
||||
border-bottom: 1px solid var(--payg-divider);
|
||||
}
|
||||
.payg-activity-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.payg-activity__dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.payg-activity__dot[data-kind="ai"] {
|
||||
background: var(--category-color-formatting); /* purple */
|
||||
}
|
||||
.payg-activity__dot[data-kind="automation"] {
|
||||
background: var(--category-color-automation); /* pink */
|
||||
}
|
||||
.payg-activity__label {
|
||||
font-size: 0.8438rem;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
.payg-activity__ts {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.payg-activity__kind {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.payg-activity__units {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 58px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Stripe CTA card ────────────────────────────────────────────────── */
|
||||
.payg-stripe {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
border-radius: var(--payg-radius);
|
||||
border: 1px solid rgba(99, 91, 255, 0.28);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(99, 91, 255, 0.1) 0%,
|
||||
rgba(99, 91, 255, 0.02) 100%
|
||||
);
|
||||
}
|
||||
.payg-stripe__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.payg-stripe__subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
/* Styles for the free-tier Plan views (PaygFreeLeader + PaygFreeMember).
|
||||
Extends Payg.css — the hero + bar + status pill reuse those classes so the
|
||||
visual reads as a continuation of the Processor dashboard. New classes here
|
||||
are prefixed `paygf-`. */
|
||||
|
||||
.payg-hero__head-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* ── Leader CTA card ──────────────────────────────────────────────────── */
|
||||
|
||||
.paygf-cta {
|
||||
margin-top: var(--payg-gap);
|
||||
padding: 22px 24px;
|
||||
border-radius: var(--payg-radius);
|
||||
background: var(--payg-card-bg);
|
||||
/* Gradient border using padding-box / border-box trick so the inner bg
|
||||
stays solid + the outline gets the brand gradient. */
|
||||
border: 1.5px solid transparent;
|
||||
background:
|
||||
linear-gradient(var(--payg-card-bg), var(--payg-card-bg)) padding-box,
|
||||
linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%) border-box;
|
||||
box-shadow: 0 10px 28px -18px rgba(10, 139, 255, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.paygf-cta__heading-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.paygf-cta__icon {
|
||||
flex-shrink: 0;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%);
|
||||
color: white !important;
|
||||
font-size: 1.7rem !important;
|
||||
}
|
||||
.paygf-cta__heading-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.paygf-cta__title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.paygf-cta__subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.paygf-cta__benefits {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px 16px;
|
||||
}
|
||||
.paygf-cta__benefits li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.paygf-cta__check {
|
||||
flex-shrink: 0;
|
||||
color: var(--payg-accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Footer row: button on the left, reassurance text on the right.
|
||||
Right-side text wraps to second line below button on narrow viewports. */
|
||||
.paygf-cta__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--payg-divider);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.paygf-cta__button {
|
||||
padding: 13px 22px;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.paygf-cta__button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 22px -6px rgba(10, 139, 255, 0.55);
|
||||
}
|
||||
.paygf-cta__reassurance {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Member ask-the-owner note ───────────────────────────────────────── */
|
||||
|
||||
.paygf-member-note {
|
||||
margin-top: var(--payg-gap);
|
||||
padding: 22px 24px;
|
||||
border-radius: var(--payg-radius);
|
||||
background: var(--payg-inset-bg);
|
||||
border: 1px solid var(--payg-card-border);
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.paygf-member-note__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
padding: 8px;
|
||||
background: var(--payg-card-bg);
|
||||
border-radius: 10px;
|
||||
font-size: 1.6rem !important;
|
||||
}
|
||||
.paygf-member-note__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.005em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.paygf-member-note__body {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Explainer (free vs metered) — shared by leader + member ──────────── */
|
||||
|
||||
.paygf-explainer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-top: var(--payg-gap);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.paygf-explainer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.paygf-explainer__col {
|
||||
padding: 18px 20px;
|
||||
border-radius: 12px;
|
||||
background: var(--payg-card-bg);
|
||||
border: 1px solid var(--payg-card-border);
|
||||
}
|
||||
.paygf-explainer__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.paygf-explainer__icon {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
.paygf-explainer__icon--free {
|
||||
color: #10b981;
|
||||
}
|
||||
.paygf-explainer__icon--paid {
|
||||
color: var(--payg-accent);
|
||||
}
|
||||
.paygf-explainer__text {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Free-tier Plan views (leader + member) shown before the team subscribes to
|
||||
* Processor. Mirrors the visual language of {@code Payg.tsx} so the upgrade
|
||||
* feels like enabling a switch, not visiting a new product.
|
||||
*
|
||||
* <p><b>Free tier model (set 2026-06):</b> users only pay for
|
||||
* <em>automation</em>, <em>AI</em>, and <em>API</em> operations. Manual tools
|
||||
* — viewing, editing, signing, merging, splitting, conversion, manual OCR,
|
||||
* watermarks, compression — are unmetered, no matter where they're triggered
|
||||
* from. The distinction is the <em>type of work</em> (manual tool vs
|
||||
* automation / AI / API), not where the click happens, because automation and
|
||||
* AI also have UI surfaces. The 500/month free allowance applies <em>only</em>
|
||||
* to the three billable categories. Backend design doc + pricing schema will
|
||||
* catch up to this in a follow-up; this is the FE source of truth in the
|
||||
* meantime.
|
||||
*
|
||||
* <p>Two variants:
|
||||
* - {@link PaygFreeLeader} — visible to the team owner; includes the "Turn
|
||||
* on Processor" CTA that opens the upgrade modal.
|
||||
* - {@link PaygFreeMember} — read-only; surfaces the team's free-tier
|
||||
* consumption and explains that the owner can enable Processor.
|
||||
*/
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Group, Stack } from "@mantine/core";
|
||||
import BoltIcon from "@mui/icons-material/BoltRounded";
|
||||
import CheckIcon from "@mui/icons-material/CheckRounded";
|
||||
import LockIcon from "@mui/icons-material/LockOutlined";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRenderCount } from "@app/hooks/useRenderCount";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./Payg.css";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./PaygFree.css";
|
||||
import UpgradeModal from "./UpgradeModal";
|
||||
|
||||
// ─── Shared free-tier snapshot (mock for now) ────────────────────────────
|
||||
|
||||
interface FreeSnapshot {
|
||||
/** ISO yyyy-mm-dd. */
|
||||
billingPeriodStart: string;
|
||||
billingPeriodEnd: string;
|
||||
/** Automation + AI + API operations used this cycle. */
|
||||
billableUsed: number;
|
||||
/** Free-tier ceiling. Hard-coded 500 for V1; will come from pricing_policy. */
|
||||
billableLimit: number;
|
||||
}
|
||||
|
||||
function useFreeMock(): FreeSnapshot {
|
||||
return useMemo(() => {
|
||||
const now = new Date();
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const isoDay = (d: Date) => d.toISOString().slice(0, 10);
|
||||
return {
|
||||
billingPeriodStart: isoDay(periodStart),
|
||||
billingPeriodEnd: isoDay(periodEnd),
|
||||
billableUsed: 62,
|
||||
billableLimit: 500,
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function formatPeriod(start: string, end: string): string {
|
||||
const s = new Date(start);
|
||||
const e = new Date(end);
|
||||
const fmt = (d: Date) =>
|
||||
d.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
|
||||
return `${fmt(s)} – ${fmt(e)}`;
|
||||
}
|
||||
|
||||
function daysUntil(iso: string): number {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.ceil((new Date(iso).getTime() - Date.now()) / 86_400_000),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hero usage panel (shared by leader + member) ─────────────────────────
|
||||
|
||||
interface FreeHeroProps {
|
||||
snap: FreeSnapshot;
|
||||
}
|
||||
|
||||
function FreeHero({ snap }: FreeHeroProps) {
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.min(100, (snap.billableUsed / snap.billableLimit) * 100);
|
||||
const daysLeft = daysUntil(snap.billingPeriodEnd);
|
||||
const state =
|
||||
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
|
||||
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="payg-hero" data-state={state}>
|
||||
<div className="payg-hero__inner">
|
||||
<div className="payg-hero__head-row">
|
||||
<div>
|
||||
<div className="payg-hero__eyebrow">
|
||||
{t("payg.free.hero.eyebrow", "This billing period")}
|
||||
</div>
|
||||
<div className="payg-hero__figure">
|
||||
<span className="payg-hero__spend">
|
||||
{snap.billableUsed.toLocaleString()}
|
||||
</span>
|
||||
<span className="payg-hero__cap">
|
||||
{t(
|
||||
"payg.free.hero.capSuffix",
|
||||
"/ {{limit}} free operations",
|
||||
{ limit: snap.billableLimit.toLocaleString() },
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="payg-status" data-state={state}>
|
||||
<span className="payg-status__dot" />
|
||||
{stateLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="payg-bar">
|
||||
<div
|
||||
className="payg-bar__fill"
|
||||
data-state={state}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="payg-hero__meta">
|
||||
<span>
|
||||
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
|
||||
</span>
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>
|
||||
{daysLeft === 1
|
||||
? t("payg.free.hero.resetsTomorrow", "Resets tomorrow")
|
||||
: t("payg.free.hero.resetsIn", "Resets in {{days}} days", {
|
||||
days: daysLeft,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section header (shared) ─────────────────────────────────────────────
|
||||
// Mirrors the Mantine Group + payg-header__subtitle + role pill pattern in
|
||||
// Payg.tsx so the free view reads as a continuation of the subscribed view.
|
||||
|
||||
interface SectionHeaderProps {
|
||||
snap: FreeSnapshot;
|
||||
/** Pill text on the right. */
|
||||
pill: string;
|
||||
/** LEADER pill colour treatment. */
|
||||
leader?: boolean;
|
||||
}
|
||||
|
||||
function SectionHeader({ snap, pill, leader }: SectionHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<div className="payg-header__subtitle">
|
||||
{t(
|
||||
"payg.free.header.subtitle",
|
||||
"Editor plan — manual tools are always free. Pay only for automation, AI & API. Billing period {{period}}.",
|
||||
{
|
||||
period: formatPeriod(snap.billingPeriodStart, snap.billingPeriodEnd),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<span className="payg-role-pill" data-leader={leader ? "true" : "false"}>
|
||||
{pill}
|
||||
</span>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Free LEADER: hero + upgrade CTA + what's free explainer ─────────────
|
||||
|
||||
export interface PaygFreeLeaderProps {
|
||||
/**
|
||||
* Called when the user finishes the {@link UpgradeModal} checkout flow.
|
||||
* Plumbed up to {@code PlanSection} so the page can flip to the subscribed
|
||||
* view immediately. When undefined we fall back to a demo {@code alert} so
|
||||
* the dev preview route still works in isolation.
|
||||
*/
|
||||
onUpgraded?: (result: { capUsd: number | null }) => void;
|
||||
}
|
||||
|
||||
function PaygFreeLeaderInner({ onUpgraded }: PaygFreeLeaderProps = {}) {
|
||||
useRenderCount("PaygFreeLeader");
|
||||
const { t } = useTranslation();
|
||||
const snap = useFreeMock();
|
||||
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="payg">
|
||||
<Stack gap="lg">
|
||||
<SectionHeader
|
||||
snap={snap}
|
||||
pill={t("payg.role.leader", "Team owner")}
|
||||
leader
|
||||
/>
|
||||
|
||||
<FreeHero snap={snap} />
|
||||
|
||||
<div className="paygf-cta">
|
||||
<div className="paygf-cta__heading-row">
|
||||
<BoltIcon className="paygf-cta__icon" />
|
||||
<div className="paygf-cta__heading-text">
|
||||
<h3 className="paygf-cta__title">
|
||||
{t(
|
||||
"payg.free.cta.title",
|
||||
"Turn on the Processor plan",
|
||||
)}
|
||||
</h3>
|
||||
<p className="paygf-cta__subtitle">
|
||||
{t(
|
||||
"payg.free.cta.subtitle",
|
||||
"Unlock more than 500 automation, AI, and API operations per month. Set a monthly ceiling — you stay in control.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="paygf-cta__benefits">
|
||||
<li>
|
||||
<CheckIcon className="paygf-cta__check" fontSize="small" />
|
||||
<span>
|
||||
<strong>
|
||||
{t(
|
||||
"payg.free.cta.benefit1Title",
|
||||
"Automation pipelines",
|
||||
)}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.free.cta.benefit1Body",
|
||||
"chain tools, schedule runs, batch process",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<CheckIcon className="paygf-cta__check" fontSize="small" />
|
||||
<span>
|
||||
<strong>
|
||||
{t("payg.free.cta.benefit2Title", "AI tools")}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.free.cta.benefit2Body",
|
||||
"summarise, classify, redact, AI-OCR",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<CheckIcon className="paygf-cta__check" fontSize="small" />
|
||||
<span>
|
||||
<strong>
|
||||
{t("payg.free.cta.benefit3Title", "API access")}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.free.cta.benefit3Body",
|
||||
"call any Stirling endpoint programmatically",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="paygf-cta__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="paygf-cta__button"
|
||||
onClick={() => setUpgradeOpen(true)}
|
||||
data-testid="turn-on-processor"
|
||||
>
|
||||
{t("payg.free.cta.button", "Turn on Processor →")}
|
||||
</button>
|
||||
<span className="paygf-cta__reassurance">
|
||||
{t(
|
||||
"payg.free.cta.reassurance",
|
||||
"No minimum · Set a $0 cap to test · Cancel anytime",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="paygf-explainer">
|
||||
<div className="paygf-explainer__col">
|
||||
<div className="paygf-explainer__label">
|
||||
<CheckIcon
|
||||
className="paygf-explainer__icon paygf-explainer__icon--free"
|
||||
fontSize="small"
|
||||
/>
|
||||
{t("payg.free.explainer.alwaysFreeLabel", "Always free")}
|
||||
</div>
|
||||
<p className="paygf-explainer__text">
|
||||
{t(
|
||||
"payg.free.explainer.alwaysFreeBody",
|
||||
"Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, no matter where you trigger them from.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="paygf-explainer__col">
|
||||
<div className="paygf-explainer__label">
|
||||
<BoltIcon
|
||||
className="paygf-explainer__icon paygf-explainer__icon--paid"
|
||||
fontSize="small"
|
||||
/>
|
||||
{t(
|
||||
"payg.free.explainer.countsLabel",
|
||||
"Counts toward 500/month",
|
||||
)}
|
||||
</div>
|
||||
<p className="paygf-explainer__text">
|
||||
{t(
|
||||
"payg.free.explainer.countsBody",
|
||||
"Automation pipelines (chained tools, scheduled runs), AI tools (summaries, classification, AI-OCR), and API calls (programmatic access). Above 500 you'll need Processor.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<UpgradeModal
|
||||
open={upgradeOpen}
|
||||
onClose={() => setUpgradeOpen(false)}
|
||||
onComplete={({ capUsd }) => {
|
||||
setUpgradeOpen(false);
|
||||
if (onUpgraded) {
|
||||
onUpgraded({ capUsd });
|
||||
} else {
|
||||
// Standalone fallback (dev preview route renders without a parent
|
||||
// handler). Real flow always passes onUpgraded via PlanSection.
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(
|
||||
`Demo: subscription complete. Cap = ${
|
||||
capUsd === null ? "no cap" : `$${capUsd}/mo`
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Free MEMBER: hero + ask-owner note (no CTA) ─────────────────────────
|
||||
|
||||
function PaygFreeMemberInner() {
|
||||
useRenderCount("PaygFreeMember");
|
||||
const { t } = useTranslation();
|
||||
const snap = useFreeMock();
|
||||
|
||||
return (
|
||||
<div className="payg">
|
||||
<Stack gap="lg">
|
||||
<SectionHeader snap={snap} pill={t("payg.role.member", "Member")} />
|
||||
|
||||
<FreeHero snap={snap} />
|
||||
|
||||
<div className="paygf-member-note">
|
||||
<LockIcon className="paygf-member-note__icon" />
|
||||
<div>
|
||||
<h3 className="paygf-member-note__title">
|
||||
{t(
|
||||
"payg.free.member.title",
|
||||
"Want more than 500 automation, AI, or API operations a month?",
|
||||
)}
|
||||
</h3>
|
||||
<p className="paygf-member-note__body">
|
||||
{t(
|
||||
"payg.free.member.body",
|
||||
"Your team owner can enable the Processor plan and set a monthly ceiling. Until then, manual tools are free for you to use as much as you like — automation, AI, and API access are limited to the team's 500/month free allowance.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="paygf-explainer">
|
||||
<div className="paygf-explainer__col">
|
||||
<div className="paygf-explainer__label">
|
||||
<CheckIcon
|
||||
className="paygf-explainer__icon paygf-explainer__icon--free"
|
||||
fontSize="small"
|
||||
/>
|
||||
{t(
|
||||
"payg.free.explainer.alwaysFreeForYouLabel",
|
||||
"Always free for you",
|
||||
)}
|
||||
</div>
|
||||
<p className="paygf-explainer__text">
|
||||
{t(
|
||||
"payg.free.explainer.alwaysFreeForYouBody",
|
||||
"Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, never counted.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="paygf-explainer__col">
|
||||
<div className="paygf-explainer__label">
|
||||
<BoltIcon
|
||||
className="paygf-explainer__icon paygf-explainer__icon--paid"
|
||||
fontSize="small"
|
||||
/>
|
||||
{t(
|
||||
"payg.free.explainer.sharedLabel",
|
||||
"Shared with the team",
|
||||
)}
|
||||
</div>
|
||||
<p className="paygf-explainer__text">
|
||||
{t(
|
||||
"payg.free.explainer.sharedBody",
|
||||
"Automation pipelines, AI tools, and API calls share a single 500/month allowance across the whole team. If it's full, ask your owner to enable Processor.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// React.memo so Plan re-rendering on loading/error toggles doesn't cascade
|
||||
// down to these leaves. Plan passes a stable onUpgraded callback (hoisted in
|
||||
// Plan.tsx) so the prop identity stays stable across wallet refetches.
|
||||
export const PaygFreeLeader = React.memo(PaygFreeLeaderInner);
|
||||
export const PaygFreeMember = React.memo(PaygFreeMemberInner);
|
||||
@@ -1,234 +1,99 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Divider, Loader, Alert, Select, Group, Text } from "@mantine/core";
|
||||
import { usePlans, PlanTier } from "@app/hooks/usePlans";
|
||||
import StripeCheckout, {
|
||||
PurchaseType,
|
||||
CreditsPack,
|
||||
PlanID,
|
||||
} from "@app/components/shared/StripeCheckoutSaas";
|
||||
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
|
||||
import ApiPackagesSection from "@app/components/shared/config/configSections/plan/ApiPackagesSection";
|
||||
import ActivePlanSection from "@app/components/shared/config/configSections/plan/ActivePlanSection";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
/**
|
||||
* SaaS "Plan" page — the single entry point for billing, plan state, and
|
||||
* usage. Branches on the team's wallet state and the viewer's role:
|
||||
*
|
||||
* - free + leader → {@link PaygFreeLeader} (upgrade CTA + manual-tools
|
||||
* framing)
|
||||
* - free + member → {@link PaygFreeMember} (ask-the-owner note)
|
||||
* - subscribed + leader → {@link PaygLeader} (full dashboard, editable cap)
|
||||
* - subscribed + member → {@link PaygMember} (member dashboard)
|
||||
*
|
||||
* <p>The hook handles loading + error states locally so the four view
|
||||
* components stay focused on rendering the data they own. {@code Plan} is
|
||||
* intentionally tiny (under 60 lines) so future "Plan-level" affordances —
|
||||
* a top-level error toast, a subscription confirmation card, etc — have
|
||||
* obvious places to land.
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Alert, Center, Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWallet } from "@app/hooks/useWallet";
|
||||
import { useRenderCount } from "@app/hooks/useRenderCount";
|
||||
import {
|
||||
PaygLeader,
|
||||
PaygMember,
|
||||
} from "@app/components/shared/config/configSections/Payg";
|
||||
import {
|
||||
PaygFreeLeader,
|
||||
PaygFreeMember,
|
||||
} from "@app/components/shared/config/configSections/PaygFree";
|
||||
|
||||
const Plan: React.FC = () => {
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
|
||||
const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package
|
||||
const [purchaseType, setPurchaseType] =
|
||||
useState<PurchaseType>("subscription");
|
||||
const [selectedCreditsPack, setSelectedCreditsPack] =
|
||||
useState<CreditsPack>(null);
|
||||
const [currency, setCurrency] = useState<string>("gbp");
|
||||
const { trialStatus } = useAuth();
|
||||
const { data, loading, error, updateCurrentPlan } = usePlans(currency);
|
||||
useRenderCount("Plan");
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
wallet,
|
||||
loading,
|
||||
error,
|
||||
markSubscribed,
|
||||
updateCap,
|
||||
updateSubCap,
|
||||
openPortal,
|
||||
} = useWallet();
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: "cny", label: "Chinese yuan (CNY, ¥)" },
|
||||
{ value: "usd", label: "US dollar (USD, $)" },
|
||||
{ value: "inr", label: "Indian rupee (INR, ₹)" },
|
||||
{ value: "brl", label: "Brazilian real (BRL, R$)" },
|
||||
{ value: "eur", label: "Euro (EUR, €)" },
|
||||
{ value: "idr", label: "Indonesian rupiah (IDR, Rp)" },
|
||||
{ value: "gbp", label: "British pound (GBP, £)" },
|
||||
];
|
||||
|
||||
const handleUpgradeClick = useCallback(
|
||||
(plan: PlanTier) => {
|
||||
if (!data) return;
|
||||
|
||||
if (plan.isContactOnly) {
|
||||
// Open contact form or redirect to contact page
|
||||
window.open(
|
||||
"mailto:contact@stirlingpdf.com?subject=Enterprise Plan Inquiry",
|
||||
"_blank",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.id !== data.currentPlan.id) {
|
||||
setSelectedPlan(plan);
|
||||
setPurchaseType("subscription");
|
||||
setSelectedCreditsPack(null);
|
||||
setCheckoutOpen(true);
|
||||
}
|
||||
// Stable callback so PaygFreeLeader's React.memo doesn't see a new prop
|
||||
// identity on every Plan render (e.g. loading flips false→true→false on
|
||||
// a refetch). Closing over the stable markSubscribed from useWallet
|
||||
// means we don't need to add wallet state to deps.
|
||||
const onUpgraded = useCallback(
|
||||
({ capUsd }: { capUsd: number | null }) => {
|
||||
// Bridges the modal's local success → backend mock → refetch loop.
|
||||
// Real Stripe flow: the customer.subscription.created webhook is
|
||||
// what flips status; we still call markSubscribed locally so the
|
||||
// optimistic refetch hits immediately.
|
||||
void markSubscribed(capUsd);
|
||||
},
|
||||
[data],
|
||||
[markSubscribed],
|
||||
);
|
||||
|
||||
const handleCreditPurchaseClick = useCallback(
|
||||
(creditsPack: CreditsPack) => {
|
||||
if (!data) return;
|
||||
|
||||
setSelectedCreditsPack(creditsPack);
|
||||
setPurchaseType("credits");
|
||||
setSelectedPlan(null);
|
||||
setCheckoutOpen(true);
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
const handlePaymentSuccess = useCallback(
|
||||
(sessionId: string) => {
|
||||
console.log("Payment successful, session:", sessionId);
|
||||
|
||||
// Update local state immediately - no page reload needed
|
||||
if (selectedPlan && purchaseType === "subscription") {
|
||||
updateCurrentPlan(selectedPlan.id);
|
||||
}
|
||||
|
||||
// Close modal after brief delay to show success message
|
||||
setTimeout(() => {
|
||||
setCheckoutOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setSelectedCreditsPack(null);
|
||||
}, 2000);
|
||||
},
|
||||
[selectedPlan, purchaseType, updateCurrentPlan],
|
||||
);
|
||||
|
||||
const handlePaymentError = useCallback((error: string) => {
|
||||
console.error("Payment error:", error);
|
||||
// Error is already displayed in the StripeCheckout component
|
||||
}, []);
|
||||
|
||||
const handleCheckoutClose = useCallback(() => {
|
||||
setCheckoutOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setSelectedCreditsPack(null);
|
||||
}, []);
|
||||
|
||||
const handleAddPaymentClick = useCallback(() => {
|
||||
if (!data) return;
|
||||
|
||||
// Find Pro plan from available plans
|
||||
const proPlan = Array.from(data.plans.values()).find(
|
||||
(plan) => plan.id === "pro",
|
||||
);
|
||||
|
||||
if (proPlan) {
|
||||
setSelectedPlan(proPlan);
|
||||
setPurchaseType("subscription");
|
||||
setSelectedCreditsPack(null);
|
||||
setCheckoutOpen(true);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Check URL parameters for action=add-payment
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("action") === "add-payment") {
|
||||
handleAddPaymentClick();
|
||||
// Clean up URL
|
||||
params.delete("action");
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
}, [data, handleAddPaymentClick]);
|
||||
|
||||
// Early returns after all hooks are called
|
||||
if (loading) {
|
||||
if (loading && !wallet) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
<Center mih={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || !wallet) {
|
||||
return (
|
||||
<Alert color="red" title="Error loading plans">
|
||||
{error}
|
||||
<Alert color="red" title={t("payg.error.title", "Couldn't load your plan")}>
|
||||
{error ??
|
||||
t(
|
||||
"payg.error.body",
|
||||
"We couldn't reach the billing service. Refresh the page to try again.",
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Alert color="yellow" title="No data available">
|
||||
Plans data is not available at the moment.
|
||||
</Alert>
|
||||
if (wallet.status === "subscribed") {
|
||||
return wallet.role === "leader" ? (
|
||||
<PaygLeader
|
||||
onSaveCap={updateCap}
|
||||
members={wallet.members}
|
||||
onSaveSubCap={updateSubCap}
|
||||
onOpenPortal={openPortal}
|
||||
/>
|
||||
) : (
|
||||
<PaygMember />
|
||||
);
|
||||
}
|
||||
|
||||
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } =
|
||||
data;
|
||||
const plansArray = Array.from(plans.values());
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
|
||||
{/* Currency Selector */}
|
||||
<div>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text size="lg" fw={600}>
|
||||
Currency
|
||||
</Text>
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={(value) => setCurrency(value || "gbp")}
|
||||
data={currencyOptions}
|
||||
searchable
|
||||
clearable={true}
|
||||
w={300}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<ActivePlanSection
|
||||
currentPlan={currentPlan}
|
||||
_activeSince={activeSince}
|
||||
_nextBillingDate={nextBillingDate}
|
||||
trialStatus={trialStatus ?? undefined}
|
||||
onAddPaymentClick={handleAddPaymentClick}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plansArray}
|
||||
currentPlan={currentPlan}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ApiPackagesSection
|
||||
apiPackages={apiPackages}
|
||||
selectedCredits={selectedCredits}
|
||||
onSelectedCreditsChange={setSelectedCredits}
|
||||
onCreditPurchaseClick={handleCreditPurchaseClick}
|
||||
/>
|
||||
|
||||
{/* Stripe Checkout Modal */}
|
||||
<StripeCheckout
|
||||
opened={
|
||||
checkoutOpen &&
|
||||
(selectedPlan !== null || selectedCreditsPack !== null)
|
||||
}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType={purchaseType}
|
||||
planId={
|
||||
purchaseType === "subscription" ? (selectedPlan?.id as PlanID) : null
|
||||
}
|
||||
creditsPack={purchaseType === "credits" ? selectedCreditsPack : null}
|
||||
planName={
|
||||
purchaseType === "subscription"
|
||||
? selectedPlan?.name || ""
|
||||
: data?.apiPackages.find((pkg) => pkg.id === selectedCreditsPack)
|
||||
?.name || ""
|
||||
}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
isTrialConversion={
|
||||
trialStatus?.isTrialing && purchaseType === "subscription"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
// Free tier — only the leader sees the upgrade CTA.
|
||||
if (wallet.role === "leader") {
|
||||
return <PaygFreeLeader onUpgraded={onUpgraded} />;
|
||||
}
|
||||
return <PaygFreeMember />;
|
||||
};
|
||||
|
||||
export default Plan;
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Stripe Embedded Checkout panel — lives in its own module so it can be
|
||||
* imported lazily. {@code @stripe/stripe-js} pulls a fairly chunky third-party
|
||||
* SDK; we don't want it in the main bundle for users who never open the
|
||||
* UpgradeModal, let alone reach step 2.
|
||||
*
|
||||
* <h2>Lazy-load pattern</h2>
|
||||
*
|
||||
* <pre>
|
||||
* // In UpgradeModal.tsx — only when the user advances to step 2:
|
||||
* const StripeCheckoutPanel = React.lazy(
|
||||
* () => import("./StripeCheckoutPanel"),
|
||||
* );
|
||||
* </pre>
|
||||
*
|
||||
* The {@code loadStripe()} call inside this module deferred-imports the SDK
|
||||
* itself, so the chunk graph is:
|
||||
*
|
||||
* <pre>
|
||||
* StripeCheckoutPanel.chunk.js
|
||||
* └─ @stripe/react-stripe-js (pulled in by ESM static import here)
|
||||
* └─ @stripe/stripe-js (pulled in by loadStripe inside fetchStripe)
|
||||
* </pre>
|
||||
*
|
||||
* Both chunks are eligible for Vite tree-shaking + lazy load; nothing in the
|
||||
* main bundle references either package.
|
||||
*
|
||||
* <h2>Architecture</h2>
|
||||
*
|
||||
* Stripe-touching code lives in Supabase edge functions, not the Java
|
||||
* backend. This panel invokes {@code create-payg-team-subscription}
|
||||
* directly via {@code supabase.functions.invoke()} — same pattern {@link
|
||||
* usePlans} already uses for {@code stripe-price-lookup}. The auth JWT is
|
||||
* attached automatically by the Supabase client.
|
||||
*
|
||||
* <p>The edge function (SaaS PR #300) is the canonical place Stripe Checkout
|
||||
* Sessions get created — it uses the Stripe Sync Engine tables, has dedicated
|
||||
* unit tests, and shares Stripe SDK / secret-key plumbing with the metering +
|
||||
* webhook edge functions. Routing through Java would have meant a useless
|
||||
* proxy hop + a second Stripe SDK to maintain.
|
||||
*
|
||||
* <h2>Behaviour</h2>
|
||||
*
|
||||
* <ol>
|
||||
* <li>On mount: calls {@code supabase.functions.invoke("create-payg-team-subscription", {capUsd, noCap})}
|
||||
* to obtain a {@code client_secret}.
|
||||
* <li>If no {@code VITE_STRIPE_PUBLISHABLE_KEY} is configured OR the edge
|
||||
* function isn't deployed yet (errors out / returns a {@code cs_mock_}
|
||||
* sentinel), render a clearly-labelled placeholder + "Continue with
|
||||
* mock" button so the post-completion path stays testable.
|
||||
* <li>Otherwise render the real {@code <EmbeddedCheckoutProvider>} +
|
||||
* {@code <EmbeddedCheckout>} iframe.
|
||||
* </ol>
|
||||
*
|
||||
* The parent {@link UpgradeModal} passes {@code onComplete} which fires when
|
||||
* either the real Stripe checkout emits its complete event OR the user
|
||||
* presses "Continue with mock" in unconfigured environments.
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
|
||||
// Eager static imports here are OK because this whole module is itself lazy-
|
||||
// imported by the modal. They land in the same lazy chunk.
|
||||
import {
|
||||
EmbeddedCheckout,
|
||||
EmbeddedCheckoutProvider,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import type { Stripe } from "@stripe/stripe-js";
|
||||
|
||||
export interface StripeCheckoutPanelProps {
|
||||
/** Cap in USD; null means no cap. */
|
||||
capUsd: number | null;
|
||||
/** Called when Stripe (or the mock continue button) signals completion. */
|
||||
onComplete: () => void;
|
||||
/** Called when the call to /api/v1/payg/checkout fails. */
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape from the {@code create-payg-team-subscription} Supabase edge
|
||||
* function. Mirrors what the function returns in SaaS PR #300.
|
||||
*/
|
||||
interface CheckoutResponse {
|
||||
/** Stripe Checkout Session client_secret. */
|
||||
client_secret: string;
|
||||
/**
|
||||
* Optional sentinel: edge functions in non-prod environments may return a
|
||||
* stubbed secret prefixed {@code cs_mock_} so the FE knows to render the
|
||||
* placeholder rather than try to mount a real iframe with a bad secret.
|
||||
*/
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
// Singleton Stripe promise — created on first use and reused for the lifetime
|
||||
// of the tab. {@code loadStripe} is dynamically imported so the actual SDK
|
||||
// chunk is only pulled when this code path runs.
|
||||
let stripePromise: Promise<Stripe | null> | null = null;
|
||||
function getStripe(publishableKey: string): Promise<Stripe | null> {
|
||||
if (stripePromise === null) {
|
||||
stripePromise = import("@stripe/stripe-js").then((mod) =>
|
||||
mod.loadStripe(publishableKey),
|
||||
);
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
|
||||
capUsd,
|
||||
onComplete,
|
||||
onError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [isMock, setIsMock] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Strict-Mode-safe single-flight: React 18 dev mounts effects twice; we use
|
||||
// a ref-guarded fetch so we don't burn two Stripe Checkout Sessions per
|
||||
// mount in dev. (Cap changes are handled via key= in the parent, so each
|
||||
// distinct cap gets a fresh mount — fetchedRef only protects within a
|
||||
// single logical mount.)
|
||||
const fetchedRef = useRef<boolean>(false);
|
||||
|
||||
// Stable ref for the error callback so we don't have to include it in the
|
||||
// effect deps — keeps the single-flight semantics intact for callers that
|
||||
// pass an inline onError.
|
||||
const onErrorRef = useRef<typeof onError>(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
// Stash t() in a ref so the effect — which is single-flight via fetchedRef
|
||||
// and deliberately not re-running on translation changes — can read the
|
||||
// current translator without forcing t into its deps.
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const publishableKey =
|
||||
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
|
||||
// Dev preview route has no backend — skip the API call and go straight
|
||||
// to the mock placeholder so the design + completion path stay testable.
|
||||
// Both checks required so a production tenant with a /dev/ URL prefix
|
||||
// can't accidentally trigger the placeholder.
|
||||
const devPreview =
|
||||
import.meta.env.DEV &&
|
||||
typeof window !== "undefined" &&
|
||||
window.location.pathname.startsWith("/dev/");
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedRef.current) return;
|
||||
fetchedRef.current = true;
|
||||
|
||||
if (devPreview) {
|
||||
setClientSecret("cs_mock_devpreview");
|
||||
setIsMock(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
async function createSession() {
|
||||
try {
|
||||
const noCap = capUsd === null;
|
||||
// Direct Supabase edge function invocation — same pattern as usePlans
|
||||
// for stripe-price-lookup. The user's JWT is attached automatically;
|
||||
// edge fn uses it to derive the team id + stripe_customer_id.
|
||||
const { data, error: invokeError } =
|
||||
await supabase.functions.invoke<CheckoutResponse>(
|
||||
"create-payg-team-subscription",
|
||||
{
|
||||
body: {
|
||||
capUsd: capUsd ?? 0,
|
||||
noCap,
|
||||
returnUrl: window.location.href,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (invokeError) {
|
||||
throw invokeError;
|
||||
}
|
||||
if (!data?.client_secret) {
|
||||
throw new Error("Edge function returned no client_secret");
|
||||
}
|
||||
setClientSecret(data.client_secret);
|
||||
setIsMock(Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"));
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: tRef.current(
|
||||
"payg.checkout.error.startFailed",
|
||||
"Couldn't start checkout session",
|
||||
);
|
||||
if (!cancelled) {
|
||||
setError(msg);
|
||||
onErrorRef.current?.(msg);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
void createSession();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [capUsd, devPreview]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="upm-stripe-mount" data-state="loading">
|
||||
<div className="upm-stripe-mount__title">
|
||||
{t("payg.checkout.connecting", "Connecting to Stripe…")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="upm-stripe-mount" data-state="error">
|
||||
<div className="upm-stripe-mount__title">
|
||||
{t("payg.checkout.errorTitle", "Stripe error")}
|
||||
</div>
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mock mode OR no publishable key configured → friendly placeholder.
|
||||
const showMockPlaceholder = isMock || publishableKey.length === 0;
|
||||
|
||||
if (showMockPlaceholder) {
|
||||
return (
|
||||
<div className="upm-stripe-mount" data-state="mock">
|
||||
<div className="upm-stripe-mount__title">
|
||||
{t(
|
||||
"payg.checkout.mock.title",
|
||||
"Stripe Embedded Checkout (mock mode)",
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{publishableKey.length === 0
|
||||
? t(
|
||||
"payg.checkout.mock.noKey",
|
||||
"VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured.",
|
||||
)
|
||||
: t(
|
||||
"payg.checkout.mock.backend",
|
||||
"Backend is in mock mode — no real Stripe session was created.",
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="primary"
|
||||
onClick={onComplete}
|
||||
>
|
||||
{t(
|
||||
"payg.checkout.mock.continue",
|
||||
"Continue with mock subscription",
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!clientSecret) return null;
|
||||
|
||||
return (
|
||||
<div className="upm-stripe-mount" data-state="live">
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={getStripe(publishableKey)}
|
||||
options={{ clientSecret, onComplete }}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StripeCheckoutPanel;
|
||||
@@ -0,0 +1,476 @@
|
||||
/* Upgrade-to-Processor modal: two steps inside one frame.
|
||||
Step 1: pick a monthly cap.
|
||||
Step 2: Stripe Embedded Checkout (or placeholder when no test key set).
|
||||
The frame doesn't change between steps — only the panel slides — so the
|
||||
user feels like they're filling out one form, not navigating pages. */
|
||||
|
||||
.upm {
|
||||
--upm-accent: #0a8bff;
|
||||
--upm-accent-2: #6c5ce7;
|
||||
--upm-accent-soft: rgba(10, 139, 255, 0.08);
|
||||
--upm-success: #10b981;
|
||||
--upm-radius: 18px;
|
||||
--upm-card-bg: var(--bg-surface);
|
||||
--upm-border: var(--border-default);
|
||||
--upm-divider: var(--border-subtle);
|
||||
--upm-text: var(--text-primary);
|
||||
--upm-muted: var(--text-muted);
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .upm {
|
||||
--upm-card-bg: #313842;
|
||||
--upm-border: #3d444e;
|
||||
--upm-divider: #3d444e;
|
||||
--upm-accent-soft: rgba(10, 139, 255, 0.14);
|
||||
}
|
||||
|
||||
/* Backdrop locks the page and centres the modal. */
|
||||
.upm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
animation: upm-fade-in 180ms ease;
|
||||
}
|
||||
|
||||
@keyframes upm-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.upm-frame {
|
||||
background: var(--upm-card-bg);
|
||||
border: 1px solid var(--upm-border);
|
||||
border-radius: var(--upm-radius);
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.4);
|
||||
animation: upm-pop-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes upm-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Header: title + close + step dots ────────────────────────────────── */
|
||||
.upm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px 14px;
|
||||
border-bottom: 1px solid var(--upm-divider);
|
||||
}
|
||||
.upm-header__title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--upm-text);
|
||||
margin: 0;
|
||||
}
|
||||
.upm-header__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--upm-muted);
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.upm-header__close:hover {
|
||||
background: var(--upm-divider);
|
||||
color: var(--upm-text);
|
||||
}
|
||||
|
||||
.upm-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 22px 14px;
|
||||
background: var(--upm-accent-soft);
|
||||
border-bottom: 1px solid var(--upm-divider);
|
||||
}
|
||||
.upm-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--upm-muted);
|
||||
transition: color 200ms ease;
|
||||
}
|
||||
.upm-step__dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--upm-card-bg);
|
||||
border: 1.5px solid var(--upm-divider);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
|
||||
}
|
||||
.upm-step[data-state="active"] {
|
||||
color: var(--upm-text);
|
||||
}
|
||||
.upm-step[data-state="active"] .upm-step__dot {
|
||||
background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2));
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
.upm-step[data-state="done"] {
|
||||
color: var(--upm-success);
|
||||
}
|
||||
.upm-step[data-state="done"] .upm-step__dot {
|
||||
background: var(--upm-success);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
.upm-step__connector {
|
||||
flex: 1 1 auto;
|
||||
height: 1.5px;
|
||||
background: var(--upm-divider);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* ── Body: slides between steps without remounting ───────────────────── */
|
||||
.upm-body {
|
||||
padding: 22px;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Hero promise reinforcement — only shown on step 1 */
|
||||
.upm-promise {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: var(--upm-accent-soft);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 18px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: var(--upm-text);
|
||||
}
|
||||
.upm-promise__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--upm-accent);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.upm-promise__highlight {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.upm-section-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--upm-text);
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.upm-section-help {
|
||||
font-size: 0.825rem;
|
||||
color: var(--upm-muted);
|
||||
margin: 0 0 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Cap input + quick presets */
|
||||
.upm-cap-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.upm-cap-preset {
|
||||
padding: 12px 6px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid var(--upm-divider);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--upm-text);
|
||||
transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.upm-cap-preset:hover {
|
||||
border-color: var(--upm-accent);
|
||||
}
|
||||
.upm-cap-preset[data-selected="true"] {
|
||||
background: var(--upm-accent-soft);
|
||||
border-color: var(--upm-accent);
|
||||
color: var(--upm-accent);
|
||||
}
|
||||
|
||||
.upm-cap-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1.5px solid var(--upm-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--upm-card-bg);
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
.upm-cap-input-row:focus-within {
|
||||
border-color: var(--upm-accent);
|
||||
}
|
||||
.upm-cap-input-row__currency {
|
||||
font-weight: 700;
|
||||
color: var(--upm-muted);
|
||||
}
|
||||
.upm-cap-input-row__input {
|
||||
flex: 1 1 auto;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--upm-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.upm-cap-input-row__period {
|
||||
font-size: 0.85rem;
|
||||
color: var(--upm-muted);
|
||||
}
|
||||
|
||||
.upm-no-cap-toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed var(--upm-divider);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.upm-no-cap-toggle input {
|
||||
margin-top: 3px;
|
||||
accent-color: var(--upm-accent);
|
||||
}
|
||||
.upm-no-cap-toggle__text {
|
||||
font-size: 0.825rem;
|
||||
color: var(--upm-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.upm-no-cap-toggle__hint {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--upm-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* What counts as "processed" — quiet helper text */
|
||||
.upm-help-card {
|
||||
background: var(--upm-divider);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-top: 14px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: var(--upm-muted);
|
||||
}
|
||||
.upm-help-card__title {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
color: var(--upm-text);
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Step 2 (Stripe iframe placeholder for now) */
|
||||
.upm-cap-confirmation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--upm-accent-soft);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.825rem;
|
||||
}
|
||||
.upm-cap-confirmation__label {
|
||||
color: var(--upm-muted);
|
||||
}
|
||||
.upm-cap-confirmation__value {
|
||||
font-weight: 700;
|
||||
color: var(--upm-text);
|
||||
}
|
||||
.upm-cap-confirmation__edit {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--upm-accent);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.upm-cap-confirmation__edit:hover {
|
||||
background: rgba(10, 139, 255, 0.12);
|
||||
}
|
||||
|
||||
.upm-stripe-mount {
|
||||
min-height: 220px;
|
||||
border: 1.5px dashed var(--upm-divider);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
color: var(--upm-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.upm-stripe-mount__title {
|
||||
font-weight: 700;
|
||||
color: var(--upm-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.upm-stripe-mount__code {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
background: var(--upm-card-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--upm-divider);
|
||||
color: var(--upm-text);
|
||||
}
|
||||
|
||||
/* ── Footer / nav actions ─────────────────────────────────────────────── */
|
||||
.upm-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 22px;
|
||||
border-top: 1px solid var(--upm-divider);
|
||||
background: var(--upm-card-bg);
|
||||
}
|
||||
.upm-footer__hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--upm-muted);
|
||||
}
|
||||
.upm-footer__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.upm-btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid transparent;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.upm-btn[data-variant="ghost"] {
|
||||
background: transparent;
|
||||
border-color: var(--upm-divider);
|
||||
color: var(--upm-text);
|
||||
}
|
||||
.upm-btn[data-variant="ghost"]:hover {
|
||||
border-color: var(--upm-accent);
|
||||
color: var(--upm-accent);
|
||||
}
|
||||
.upm-btn[data-variant="primary"] {
|
||||
background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2));
|
||||
color: white;
|
||||
}
|
||||
.upm-btn[data-variant="primary"]:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px -6px rgba(10, 139, 255, 0.5);
|
||||
}
|
||||
.upm-btn[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* ── Step 3: confirmation ─────────────────────────────────────────────── */
|
||||
|
||||
.upm-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 12px 8px 4px;
|
||||
gap: 12px;
|
||||
}
|
||||
.upm-confirm__icon {
|
||||
font-size: 64px !important;
|
||||
color: #10b981;
|
||||
filter: drop-shadow(0 6px 18px rgba(16, 185, 129, 0.35));
|
||||
}
|
||||
.upm-confirm__title {
|
||||
margin: 6px 0 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--upm-text);
|
||||
}
|
||||
.upm-confirm__body {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--upm-text-muted);
|
||||
max-width: 380px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.upm-confirm__summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 14px 18px;
|
||||
background: var(--upm-inset-bg, rgba(0, 0, 0, 0.03));
|
||||
border: 1px solid var(--upm-divider);
|
||||
border-radius: 12px;
|
||||
margin-top: 6px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.upm-confirm__summary > strong {
|
||||
color: var(--upm-text);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.upm-confirm__note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--upm-text-muted);
|
||||
max-width: 380px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* Upgrade-to-Processor modal. Three sequential panels inside one frame:
|
||||
*
|
||||
* Step 1: Cap selection — local state only, no side effects
|
||||
* Step 2: Stripe Checkout — POSTs to /api/v1/payg/checkout, mounts the
|
||||
* Stripe Embedded Checkout iframe (lazy-loaded)
|
||||
* Step 3: Confirmation — brief "Welcome to Processor" beat before
|
||||
* the modal closes and the parent's
|
||||
* {@code onComplete} triggers a wallet refetch
|
||||
*
|
||||
* <p>The Stripe SDK ({@code @stripe/stripe-js} + {@code @stripe/react-stripe-js})
|
||||
* is loaded via {@code React.lazy} on a dedicated module so the chunk only
|
||||
* downloads when the user actually advances to step 2. The main bundle pays
|
||||
* nothing for users who never open the modal.
|
||||
*
|
||||
* <p>Cap state is held locally — nothing reaches the backend until the user
|
||||
* commits in step 2. A user who cancels mid-modal leaves no side effects.
|
||||
*/
|
||||
import React, { Suspense, useState } from "react";
|
||||
import CloseIcon from "@mui/icons-material/CloseRounded";
|
||||
import ShieldIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircleRounded";
|
||||
import { useTranslation } from "react-i18next";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./UpgradeModal.css";
|
||||
|
||||
// Lazy-loaded so the @stripe/stripe-js bundle only downloads when the user
|
||||
// reaches step 2. See StripeCheckoutPanel.tsx for the full pattern + the
|
||||
// chunk-graph reasoning.
|
||||
const StripeCheckoutPanel = React.lazy(() => import("./StripeCheckoutPanel"));
|
||||
|
||||
interface UpgradeModalProps {
|
||||
open: boolean;
|
||||
/** Called when the user closes the modal without completing checkout. */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Called after the user confirms cap + completes Stripe checkout. The parent
|
||||
* is expected to refresh the wallet snapshot which will then show the
|
||||
* subscribed state.
|
||||
*/
|
||||
onComplete: (result: { capUsd: number | null }) => void;
|
||||
/** ISO 4217 currency code for the cap input. Default USD. */
|
||||
currency?: "USD" | "EUR" | "GBP";
|
||||
}
|
||||
|
||||
type Step = "cap" | "checkout" | "confirm";
|
||||
|
||||
const CAP_PRESETS_USD = [10, 25, 50, 100] as const;
|
||||
|
||||
function currencySymbol(c: UpgradeModalProps["currency"]): string {
|
||||
switch (c) {
|
||||
case "EUR":
|
||||
return "€";
|
||||
case "GBP":
|
||||
return "£";
|
||||
default:
|
||||
return "$";
|
||||
}
|
||||
}
|
||||
|
||||
export default function UpgradeModal({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
currency = "USD",
|
||||
}: UpgradeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>("cap");
|
||||
const [capUsd, setCapUsd] = useState<number>(25);
|
||||
const [noCap, setNoCap] = useState<boolean>(false);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveCap = noCap ? null : capUsd;
|
||||
|
||||
const goToCheckout = () => setStep("checkout");
|
||||
const goBackToCap = () => setStep("cap");
|
||||
const goToConfirm = () => setStep("confirm");
|
||||
|
||||
// Modal close → reset internal step so reopening starts at step 1.
|
||||
const closeAndReset = () => {
|
||||
setStep("cap");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="upm" role="dialog" aria-modal="true">
|
||||
<div className="upm-backdrop" onClick={closeAndReset}>
|
||||
<div className="upm-frame" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header — title + close. Title stays constant; the step indicator
|
||||
below tells the user where they are. */}
|
||||
<header className="upm-header">
|
||||
<h2 className="upm-header__title">
|
||||
{step === "confirm"
|
||||
? t("payg.upgrade.title.confirm", "You're subscribed")
|
||||
: t("payg.upgrade.title.default", "Upgrade to Processor plan")}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-header__close"
|
||||
aria-label={t("payg.upgrade.closeAria", "Close")}
|
||||
onClick={closeAndReset}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Step indicator. Hidden on the confirmation panel since the
|
||||
modal is winding down at that point. */}
|
||||
{step !== "confirm" && (
|
||||
<div className="upm-steps">
|
||||
<div
|
||||
className="upm-step"
|
||||
data-state={step === "cap" ? "active" : "done"}
|
||||
>
|
||||
<span className="upm-step__dot">1</span>
|
||||
<span>
|
||||
{t("payg.upgrade.steps.cap", "Set monthly ceiling")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="upm-step__connector" />
|
||||
<div
|
||||
className="upm-step"
|
||||
data-state={step === "checkout" ? "active" : "idle"}
|
||||
>
|
||||
<span className="upm-step__dot">2</span>
|
||||
<span>
|
||||
{t(
|
||||
"payg.upgrade.steps.payment",
|
||||
"Add payment method",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="upm-body">
|
||||
{step === "cap" && (
|
||||
<CapStep
|
||||
capUsd={capUsd}
|
||||
setCapUsd={setCapUsd}
|
||||
noCap={noCap}
|
||||
setNoCap={setNoCap}
|
||||
currency={currency}
|
||||
/>
|
||||
)}
|
||||
{step === "checkout" && (
|
||||
// Keyed on the cap value so editing the cap → returning to
|
||||
// step 2 unmounts + remounts the panel, triggering a fresh
|
||||
// POST /checkout for the new cap. Without the key, the
|
||||
// StripeCheckoutPanel's fetchedRef short-circuits and the
|
||||
// session keeps the stale cap.
|
||||
<CheckoutStep
|
||||
key={`co:${effectiveCap ?? "nocap"}`}
|
||||
effectiveCap={effectiveCap}
|
||||
currency={currency}
|
||||
onEditCap={goBackToCap}
|
||||
onComplete={goToConfirm}
|
||||
/>
|
||||
)}
|
||||
{step === "confirm" && (
|
||||
<ConfirmationStep
|
||||
effectiveCap={effectiveCap}
|
||||
currency={currency}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="upm-footer">
|
||||
<span className="upm-footer__hint">
|
||||
{step === "cap" &&
|
||||
t(
|
||||
"payg.upgrade.footer.capHint",
|
||||
"You can change your cap any time later.",
|
||||
)}
|
||||
{step === "checkout" &&
|
||||
t(
|
||||
"payg.upgrade.footer.checkoutHint",
|
||||
"Card details handled by Stripe — never touched by Stirling.",
|
||||
)}
|
||||
{step === "confirm" &&
|
||||
t(
|
||||
"payg.upgrade.footer.confirmHint",
|
||||
"Your wallet will refresh automatically in a moment.",
|
||||
)}
|
||||
</span>
|
||||
<div className="upm-footer__actions">
|
||||
{step === "checkout" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="ghost"
|
||||
onClick={goBackToCap}
|
||||
>
|
||||
{t("payg.upgrade.button.back", "← Back")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{step === "cap" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="ghost"
|
||||
onClick={closeAndReset}
|
||||
>
|
||||
{t("payg.upgrade.button.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="primary"
|
||||
onClick={goToCheckout}
|
||||
>
|
||||
{t("payg.upgrade.button.continue", "Continue →")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{step === "confirm" && (
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="primary"
|
||||
onClick={() => {
|
||||
setStep("cap");
|
||||
onComplete({ capUsd: effectiveCap });
|
||||
}}
|
||||
>
|
||||
{t("payg.upgrade.button.finish", "Finish")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 1: cap selection ──────────────────────────────────────────────
|
||||
|
||||
interface CapStepProps {
|
||||
capUsd: number;
|
||||
setCapUsd: (v: number) => void;
|
||||
noCap: boolean;
|
||||
setNoCap: (v: boolean) => void;
|
||||
currency: UpgradeModalProps["currency"];
|
||||
}
|
||||
|
||||
function CapStep({ capUsd, setCapUsd, noCap, setNoCap, currency }: CapStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const sym = currencySymbol(currency);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="upm-promise">
|
||||
<ShieldIcon className="upm-promise__icon" fontSize="small" />
|
||||
<div>
|
||||
<span className="upm-promise__highlight">
|
||||
{t(
|
||||
"payg.upgrade.promise.highlight",
|
||||
"Manual tools stay free, always.",
|
||||
)}
|
||||
</span>{" "}
|
||||
{t(
|
||||
"payg.upgrade.promise.body",
|
||||
"You only pay for automation pipelines, AI tools, and API calls — the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="upm-section-title">
|
||||
{t(
|
||||
"payg.upgrade.cap.title",
|
||||
"Set your monthly spend ceiling",
|
||||
)}
|
||||
</h3>
|
||||
<p className="upm-section-help">
|
||||
{t(
|
||||
"payg.upgrade.cap.help",
|
||||
"We'll never charge above this. Your first 500 automation / AI / API operations every month are free. Set $0 if you want to keep everything free while testing.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="upm-cap-presets"
|
||||
role="radiogroup"
|
||||
aria-label={t(
|
||||
"payg.upgrade.cap.presetsAria",
|
||||
"Monthly cap preset",
|
||||
)}
|
||||
>
|
||||
{CAP_PRESETS_USD.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={!noCap && capUsd === preset}
|
||||
className="upm-cap-preset"
|
||||
data-selected={!noCap && capUsd === preset}
|
||||
onClick={() => {
|
||||
setNoCap(false);
|
||||
setCapUsd(preset);
|
||||
}}
|
||||
>
|
||||
{sym}
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="upm-cap-input-row" htmlFor="upm-cap-amount">
|
||||
<span className="upm-cap-input-row__currency">{sym}</span>
|
||||
<input
|
||||
id="upm-cap-amount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={10000}
|
||||
value={noCap ? "" : capUsd}
|
||||
disabled={noCap}
|
||||
placeholder={t(
|
||||
"payg.upgrade.cap.customPlaceholder",
|
||||
"Or enter your own amount ($0 keeps it free)",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isNaN(v) && v >= 0) {
|
||||
setCapUsd(v);
|
||||
setNoCap(false);
|
||||
}
|
||||
}}
|
||||
className="upm-cap-input-row__input"
|
||||
/>
|
||||
<span className="upm-cap-input-row__period">
|
||||
{t("payg.upgrade.cap.perMonthSuffix", "/ month")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="upm-no-cap-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noCap}
|
||||
onChange={(e) => setNoCap(e.target.checked)}
|
||||
/>
|
||||
<span className="upm-no-cap-toggle__text">
|
||||
{t(
|
||||
"payg.upgrade.cap.noCapLabel",
|
||||
"No cap — I'll manage spend manually",
|
||||
)}
|
||||
<span className="upm-no-cap-toggle__hint">
|
||||
{t(
|
||||
"payg.upgrade.cap.noCapHint",
|
||||
"You can still cancel anytime from the customer portal.",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="upm-help-card">
|
||||
<span className="upm-help-card__title">
|
||||
{t(
|
||||
"payg.upgrade.help.title",
|
||||
"What we count toward billing",
|
||||
)}
|
||||
</span>
|
||||
<ul style={{ margin: "4px 0 0", paddingLeft: 18, lineHeight: 1.55 }}>
|
||||
<li>
|
||||
<strong>
|
||||
{t(
|
||||
"payg.upgrade.help.automationTitle",
|
||||
"Automation pipelines",
|
||||
)}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.upgrade.help.automationBody",
|
||||
"chained tools or scheduled runs that don't need clicks",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
{t("payg.upgrade.help.aiTitle", "AI tools")}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.upgrade.help.aiBody",
|
||||
"summarise, classify, redact, AI-OCR",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
{t("payg.upgrade.help.apiTitle", "API calls")}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.upgrade.help.apiBody",
|
||||
"programmatic access to any Stirling endpoint",
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<div style={{ marginTop: 8, fontStyle: "italic" }}>
|
||||
{t(
|
||||
"payg.upgrade.help.footnote",
|
||||
"Manual tools — viewing, editing, merging, splitting, signing, watermarking, compressing, manual OCR — are always free, even past 500. The distinction is the type of work, not where you click.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 2: Stripe Embedded Checkout (lazy-loaded) ────────────────────
|
||||
|
||||
interface CheckoutStepProps {
|
||||
effectiveCap: number | null;
|
||||
currency: UpgradeModalProps["currency"];
|
||||
onEditCap: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
function CheckoutStep({
|
||||
effectiveCap,
|
||||
currency,
|
||||
onEditCap,
|
||||
onComplete,
|
||||
}: CheckoutStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const sym = currencySymbol(currency);
|
||||
return (
|
||||
<>
|
||||
<div className="upm-cap-confirmation">
|
||||
<span className="upm-cap-confirmation__label">
|
||||
{t("payg.upgrade.checkout.capLabel", "Monthly ceiling:")}
|
||||
</span>
|
||||
<span className="upm-cap-confirmation__value">
|
||||
{effectiveCap === null
|
||||
? t("payg.upgrade.checkout.noCap", "No cap")
|
||||
: t("payg.upgrade.checkout.capValue", "{{symbol}}{{amount}} / month", {
|
||||
symbol: sym,
|
||||
amount: effectiveCap,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-cap-confirmation__edit"
|
||||
onClick={onEditCap}
|
||||
>
|
||||
{t("payg.upgrade.checkout.edit", "Edit")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="upm-section-title">
|
||||
{t(
|
||||
"payg.upgrade.checkout.title",
|
||||
"Add your payment method",
|
||||
)}
|
||||
</h3>
|
||||
<p className="upm-section-help">
|
||||
{t(
|
||||
"payg.upgrade.checkout.help",
|
||||
"Stripe handles your card details. Stirling never sees them.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="upm-stripe-mount" data-state="loading">
|
||||
<div className="upm-stripe-mount__title">
|
||||
{t("payg.upgrade.checkout.loading", "Loading checkout…")}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StripeCheckoutPanel
|
||||
capUsd={effectiveCap}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 3: confirmation ──────────────────────────────────────────────
|
||||
|
||||
interface ConfirmationStepProps {
|
||||
effectiveCap: number | null;
|
||||
currency: UpgradeModalProps["currency"];
|
||||
}
|
||||
|
||||
function ConfirmationStep({ effectiveCap, currency }: ConfirmationStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const sym = currencySymbol(currency);
|
||||
return (
|
||||
<div className="upm-confirm">
|
||||
<CheckCircleIcon className="upm-confirm__icon" />
|
||||
<h3 className="upm-confirm__title">
|
||||
{t(
|
||||
"payg.confirm.title",
|
||||
"Welcome to the Processor plan",
|
||||
)}
|
||||
</h3>
|
||||
<p className="upm-confirm__body">
|
||||
{t(
|
||||
"payg.confirm.body",
|
||||
"Your team can now run automation, AI, and API operations beyond the 500/month free allowance.",
|
||||
)}
|
||||
</p>
|
||||
<div className="upm-confirm__summary">
|
||||
<span>{t("payg.confirm.summaryLabel", "Monthly ceiling")}</span>
|
||||
<strong>
|
||||
{effectiveCap === null
|
||||
? t("payg.confirm.noCap", "No cap")
|
||||
: t("payg.confirm.capValue", "{{symbol}}{{amount}} / month", {
|
||||
symbol: sym,
|
||||
amount: effectiveCap,
|
||||
})}
|
||||
</strong>
|
||||
</div>
|
||||
<p className="upm-confirm__note">
|
||||
{t(
|
||||
"payg.confirm.note",
|
||||
"You can change your cap, cancel, or open the Stripe customer portal any time from this page.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, Text, Group, Flex, Alert, Button, Badge } from "@mantine/core";
|
||||
import AccessTimeIcon from "@mui/icons-material/AccessTime";
|
||||
import CreditCardIcon from "@mui/icons-material/CreditCard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/hooks/usePlans";
|
||||
import { ManageBillingButton } from "@app/components/shared/ManageBillingButton";
|
||||
|
||||
interface TrialStatus {
|
||||
isTrialing: boolean;
|
||||
trialEnd: string;
|
||||
daysRemaining: number;
|
||||
hasPaymentMethod: boolean;
|
||||
hasScheduledSub: boolean;
|
||||
}
|
||||
|
||||
interface ActivePlanSectionProps {
|
||||
currentPlan: PlanTier;
|
||||
_activeSince?: string;
|
||||
_nextBillingDate?: string;
|
||||
trialStatus?: TrialStatus;
|
||||
onAddPaymentClick?: () => void;
|
||||
}
|
||||
|
||||
const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
currentPlan,
|
||||
_activeSince,
|
||||
_nextBillingDate,
|
||||
trialStatus,
|
||||
onAddPaymentClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center">
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.activePlan.title", "Active Plan")}
|
||||
</h3>
|
||||
<ManageBillingButton
|
||||
returnUrl={`${window.location.origin}/account`}
|
||||
trialStatus={trialStatus}
|
||||
/>
|
||||
</Flex>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.activePlan.subtitle", "Your current subscription details")}
|
||||
</p>
|
||||
|
||||
{/* Trial Status Alert */}
|
||||
{trialStatus?.isTrialing && (
|
||||
<Alert
|
||||
color="blue"
|
||||
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
|
||||
mt="md"
|
||||
mb="md"
|
||||
title={t("plan.trial.title", "Free Trial Active")}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t("plan.trial.daysRemaining", "Your trial ends in {{days}} days", {
|
||||
days: trialStatus.daysRemaining,
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("plan.trial.endDate", "Expires: {{date}}", {
|
||||
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
|
||||
})}
|
||||
</Text>
|
||||
{trialStatus.hasScheduledSub ? (
|
||||
<Text size="xs" c="green" fw={500} mt="sm">
|
||||
✓{" "}
|
||||
{t(
|
||||
"plan.trial.subscriptionScheduled",
|
||||
"Subscription scheduled - starts {{date}}",
|
||||
{
|
||||
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
onAddPaymentClick && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
mt="sm"
|
||||
onClick={onAddPaymentClick}
|
||||
leftSection={<CreditCardIcon sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text size="lg" fw={600}>
|
||||
{currentPlan.name}
|
||||
</Text>
|
||||
{trialStatus?.isTrialing && (
|
||||
<Badge color="blue" variant="light">
|
||||
{t("plan.trial.badge", "Trial")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{/* {activeSince && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.activeSince', 'Active since {{date}}', { date: activeSince })}
|
||||
</Text>
|
||||
)} */}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Text size="xl" fw={700}>
|
||||
{currentPlan.currency}
|
||||
{currentPlan.price}/month
|
||||
</Text>
|
||||
{/* {nextBillingDate && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.nextBilling', 'Next billing: {{date}}', { date: nextBillingDate })}
|
||||
</Text>
|
||||
)} */}
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivePlanSection;
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Text, Stack, Flex, Slider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CreditsPack } from "@app/components/shared/StripeCheckoutSaas";
|
||||
|
||||
interface ApiPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
credits: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ApiPackagesSectionProps {
|
||||
apiPackages: ApiPackage[];
|
||||
selectedCredits: number;
|
||||
onSelectedCreditsChange: (value: number) => void;
|
||||
onCreditPurchaseClick: (creditsPack: CreditsPack) => void;
|
||||
}
|
||||
|
||||
const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
apiPackages,
|
||||
selectedCredits,
|
||||
onSelectedCreditsChange,
|
||||
onCreditPurchaseClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.apiPackages.title", "API Credit Packages")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"plan.apiPackages.subtitle",
|
||||
"Purchase API credits for your applications",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Card padding="xl" radius="md" className="mb-4">
|
||||
<Stack gap="xl">
|
||||
{/* Credits Selection */}
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t("plan.selectCredits", "Select Credit Amount")}
|
||||
</Text>
|
||||
|
||||
<div className="px-4">
|
||||
<Slider
|
||||
value={selectedCredits}
|
||||
onChange={onSelectedCreditsChange}
|
||||
onChangeEnd={(value) =>
|
||||
onSelectedCreditsChange(Math.round(value))
|
||||
}
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.01}
|
||||
marks={[
|
||||
{ value: 0, label: "100" },
|
||||
{ value: 1, label: "500" },
|
||||
{ value: 2, label: "1K" },
|
||||
{ value: 3, label: "5K" },
|
||||
]}
|
||||
size="lg"
|
||||
className="mb-6"
|
||||
label={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Package Display */}
|
||||
<Flex gap={"xl"} justify="space-between" align="center">
|
||||
<div>
|
||||
<Text size="xl" fw={700}>
|
||||
{apiPackages[
|
||||
Math.round(selectedCredits)
|
||||
].credits.toLocaleString()}{" "}
|
||||
Credits
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{apiPackages[Math.round(selectedCredits)].description}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<Text size="xl" fw={700}>
|
||||
{apiPackages[Math.round(selectedCredits)].currency}
|
||||
{apiPackages[Math.round(selectedCredits)].price}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("plan.totalCost", "Total Cost")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() =>
|
||||
onCreditPurchaseClick(
|
||||
apiPackages[Math.round(selectedCredits)].id as CreditsPack,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("plan.purchase", "Purchase")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiPackagesSection;
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Card, Badge, Text, Collapse } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/hooks/usePlans";
|
||||
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
|
||||
|
||||
interface AvailablePlansSectionProps {
|
||||
plans: PlanTier[];
|
||||
currentPlan?: PlanTier;
|
||||
currentLicenseInfo?: unknown;
|
||||
onUpgradeClick: (plan: PlanTier) => void;
|
||||
onManageClick?: (plan: PlanTier) => void;
|
||||
currency?: string;
|
||||
onCurrencyChange?: (currency: string) => void;
|
||||
currencyOptions?: Array<{ value: string; label: string }>;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
plans,
|
||||
currentPlan,
|
||||
onUpgradeClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
const isUserProOrAbove =
|
||||
currentPlan?.id === "pro" || currentPlan?.id === "enterprise";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.availablePlans.title", "Available Plans")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"plan.availablePlans.subtitle",
|
||||
"Choose the plan that fits your needs",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex h-[20rem] mb-4 "
|
||||
style={{ gap: "1rem", overflowX: "auto" }}
|
||||
>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
isCurrentPlan={plan.id === currentPlan?.id}
|
||||
isUserProOrAbove={isUserProOrAbove}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setShowComparison(!showComparison)}
|
||||
>
|
||||
{showComparison
|
||||
? t("plan.hideComparison", "Hide Feature Comparison")
|
||||
: t("plan.showComparison", "Compare All Features")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Collapse in={showComparison}>
|
||||
<Card padding="lg" radius="md" withBorder className="mt-4">
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t("plan.featureComparison", "Feature Comparison")}
|
||||
</Text>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">
|
||||
{t("plan.feature.title", "Feature")}
|
||||
</th>
|
||||
{plans.map((plan) => (
|
||||
<th
|
||||
key={plan.id}
|
||||
className="text-center p-2 min-w-24 relative"
|
||||
>
|
||||
{plan.name}
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="filled"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0rem",
|
||||
right: "-2rem",
|
||||
fontSize: "0.5rem",
|
||||
fontWeight: "500",
|
||||
height: "1rem",
|
||||
padding: "0 0.1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans[0].features.map((_, featureIndex) => (
|
||||
<tr key={featureIndex} className="border-b">
|
||||
<td className="p-2">
|
||||
{plans[0].features[featureIndex].name}
|
||||
</td>
|
||||
{plans.map((plan) => (
|
||||
<td key={plan.id} className="text-center p-2">
|
||||
{plan.features[featureIndex].included ? (
|
||||
<Text c="green" fw={600}>
|
||||
✓
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="gray">-</Text>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailablePlansSection;
|
||||
@@ -1,113 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/hooks/usePlans";
|
||||
|
||||
interface PlanCardProps {
|
||||
plan?: PlanTier;
|
||||
planGroup?: { monthly?: PlanTier; yearly?: PlanTier }; // For proprietary PlanTierGroup compatibility
|
||||
isCurrentPlan?: boolean;
|
||||
isCurrentTier?: boolean;
|
||||
isDowngrade?: boolean;
|
||||
isUserProOrAbove?: boolean;
|
||||
currentLicenseInfo?: unknown;
|
||||
currentTier?: string | null; // Accept null for proprietary compatibility
|
||||
onUpgradeClick?: (plan: PlanTier) => void;
|
||||
onManageClick?: (plan: PlanTier) => void;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({
|
||||
plan: propPlan,
|
||||
planGroup,
|
||||
isCurrentPlan,
|
||||
isCurrentTier: _isCurrentTier,
|
||||
isDowngrade: _isDowngrade,
|
||||
isUserProOrAbove,
|
||||
currentLicenseInfo: _currentLicenseInfo,
|
||||
currentTier: _currentTier,
|
||||
onUpgradeClick,
|
||||
onManageClick: _onManageClick,
|
||||
loginEnabled: _loginEnabled,
|
||||
}) => {
|
||||
// Use plan from props, or extract from planGroup if proprietary is using it
|
||||
const plan = propPlan || planGroup?.monthly || planGroup?.yearly;
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!plan) return null; // Safety check
|
||||
|
||||
const shouldHideUpgrade = plan.id === "free" && isUserProOrAbove;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
padding="lg"
|
||||
radius="sm"
|
||||
withBorder
|
||||
className="h-full w-[33%] relative"
|
||||
>
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
|
||||
>
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="md" className="h-full">
|
||||
<div>
|
||||
<Text size="lg" fw={600}>
|
||||
{plan.name}
|
||||
</Text>
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text size="2xl" fw={700}>
|
||||
{plan.isContactOnly
|
||||
? t("plan.customPricing", "Custom")
|
||||
: `${plan.currency}${plan.price}`}
|
||||
</Text>
|
||||
{!plan.isContactOnly && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{plan.period}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs">
|
||||
{plan.highlights.map((highlight: string, index: number) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div className="flex-grow" />
|
||||
|
||||
{!shouldHideUpgrade && (
|
||||
<Button
|
||||
variant={
|
||||
isCurrentPlan
|
||||
? "filled"
|
||||
: plan.isContactOnly
|
||||
? "outline"
|
||||
: "filled"
|
||||
}
|
||||
disabled={isCurrentPlan}
|
||||
fullWidth
|
||||
onClick={() => onUpgradeClick?.(plan)}
|
||||
>
|
||||
{isCurrentPlan
|
||||
? t("plan.current", "Current Plan")
|
||||
: plan.isContactOnly
|
||||
? t("plan.contact", "Get in Touch")
|
||||
: t("plan.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
@@ -111,7 +111,11 @@ function appendBillingSection(
|
||||
export function createSaasConfigNavSections(
|
||||
Overview: OverviewComponent,
|
||||
onLogoutClick: () => void,
|
||||
{ isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
|
||||
{
|
||||
isDev = false,
|
||||
isAnonymous = false,
|
||||
t,
|
||||
}: CreateSaasConfigNavSectionsOptions,
|
||||
): ConfigNavSection[] {
|
||||
const baseSections = createCoreConfigNavSections(false, false, false);
|
||||
|
||||
@@ -153,6 +157,9 @@ export function createSaasConfigNavSections(
|
||||
sections = appendDeveloperSection(sections);
|
||||
|
||||
if (!isAnonymous) {
|
||||
// The Plan tab is now the single billing surface — it internally branches
|
||||
// free vs subscribed × leader vs member via useWallet(). The old separate
|
||||
// "Pay-as-you-go" tab and paygEnabled / isLeader options were removed.
|
||||
sections = appendBillingSection(sections, t);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Dev-only render counter. Logs every render with a stable label so we can
|
||||
* eyeball excessive re-renders during PAYG Plan-page work. Counts to
|
||||
* {@code window.__renderCounts[label]} so a Playwright eval can assert
|
||||
* "Plan rendered ≤ 3 times after a wallet refetch."
|
||||
*
|
||||
* <p>In production builds {@code import.meta.env.DEV} is constant-folded to
|
||||
* {@code false}, so the module-level constant {@code IS_DEV} below allows
|
||||
* Vite's dead-code-elimination to drop the whole hook body — no extra refs,
|
||||
* no extra renders, no window pollution.
|
||||
*/
|
||||
import { useRef } from "react";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__renderCounts?: Record<string, number>;
|
||||
}
|
||||
}
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function useRenderCount(label: string): number {
|
||||
// useRef must run unconditionally to satisfy rules-of-hooks. In production
|
||||
// the rest of the body is dead-code-eliminated, leaving just this single
|
||||
// ref allocation.
|
||||
const count = useRef(0);
|
||||
if (!IS_DEV) return 0;
|
||||
count.current += 1;
|
||||
if (typeof window !== "undefined") {
|
||||
if (!window.__renderCounts) window.__renderCounts = {};
|
||||
window.__renderCounts[label] = count.current;
|
||||
}
|
||||
return count.current;
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* Hook backing the PAYG Plan page. Wraps {@code GET /api/v1/payg/wallet}
|
||||
* (served by {@code PaygWalletController} once Wave 1 BE lands; until then
|
||||
* the dev preview route synthesises a wallet from localStorage) and exposes
|
||||
* mutations for marking-subscribed and updating-the-cap.
|
||||
*
|
||||
* <h2>Render efficiency</h2>
|
||||
*
|
||||
* The hook is designed so {@code Plan}, {@code PaygFreeLeader/Member}, and
|
||||
* {@code PaygLeader/Member} re-render only on actual data change:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link Wallet} snapshot is stored as a plain object — but every
|
||||
* successful fetch deep-compares with the previous snapshot and reuses
|
||||
* the prior reference if the payload is unchanged. Consumers that hold
|
||||
* a stable {@code wallet} reference get stable child memoisation.
|
||||
* <li>The returned {@link UseWalletResult} keeps stable callback identities
|
||||
* via {@code useCallback}. {@code Plan} can pass {@code markSubscribed}
|
||||
* to {@code UpgradeModal} without forcing a remount.
|
||||
* <li>{@code refetch / markSubscribed / updateCap} bump an internal counter
|
||||
* that the {@code useEffect} watches — no global state plumbing.
|
||||
* <li>A monotonic {@code requestId} ref drops stale responses so a slow
|
||||
* refetch from tick=N can't overwrite a faster one from tick=N+1
|
||||
* (out-of-order resolution would otherwise show old data).
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Mutation semantics</h2>
|
||||
*
|
||||
* Both {@code markSubscribed} and {@code updateCap} resolve only after the
|
||||
* post-mutation wallet refetch completes. So callers like the cap-editor
|
||||
* "Update cap" button that gate a {@code loading} state on the returned
|
||||
* promise see the UI flip exactly once the new state is visible — no
|
||||
* intermediate flash of the old value.
|
||||
*
|
||||
* <h2>Dev preview fallback</h2>
|
||||
*
|
||||
* When the hook is rendered outside the saas app (e.g. on {@code
|
||||
* /dev/payg-preview} during local design work) the {@code AppConfigContext}
|
||||
* provider is not mounted and no backend is available. The hook detects that
|
||||
* via {@code import.meta.env.DEV} + the {@code /dev/} path and falls back to
|
||||
* a synthesised snapshot whose subscription state is read from
|
||||
* {@code localStorage} (key {@code stirling.payg.devSubscription}). Both
|
||||
* conditions are required so a production tenant whose URL happens to start
|
||||
* with {@code /dev/} can't trigger the fallback.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
// ─── Public types ───────────────────────────────────────────────────────
|
||||
|
||||
export type WalletStatus = "free" | "subscribed";
|
||||
export type WalletRole = "leader" | "member";
|
||||
|
||||
/**
|
||||
* A single team member's billing-relevant info — name + email for the avatar
|
||||
* row, {@code spendUnits} for the mini-bar, and {@code capUnits} for the
|
||||
* optional per-member sub-cap. Mirrors a row of the backend's {@code members}
|
||||
* array on {@code WalletSnapshot} (the {@code wallet_category_summary} view
|
||||
* joined with {@code team_memberships}).
|
||||
*/
|
||||
export interface WalletMember {
|
||||
/** Supabase user id of the member. */
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
/** Per-member sub-cap, or {@code null} for "no sub-cap". */
|
||||
capUnits: number | null;
|
||||
/** Member's current-period billable spend. */
|
||||
spendUnits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-category breakdown of current-period spend in billable units. The
|
||||
* categories mirror the {@code FeatureGate} buckets the backend tracks:
|
||||
* server-side tool calls ({@code api}), AI-backed tools ({@code ai}), and
|
||||
* pipeline / automation runs ({@code automation}). Numbers sum to {@code
|
||||
* billableUsed} (modulo rounding in mock data).
|
||||
*/
|
||||
export interface WalletCategoryBreakdown {
|
||||
api: number;
|
||||
ai: number;
|
||||
automation: number;
|
||||
}
|
||||
|
||||
/** Mirror of the backend's {@code WalletSnapshot} record (the JSON returned from {@code GET /api/v1/payg/wallet}). */
|
||||
export interface Wallet {
|
||||
status: WalletStatus;
|
||||
role: WalletRole;
|
||||
/** ISO yyyy-mm-dd. */
|
||||
billingPeriodStart: string;
|
||||
billingPeriodEnd: string;
|
||||
/** Automation + AI + API operations used this cycle. */
|
||||
billableUsed: number;
|
||||
/** Free-tier ceiling. 500 in V1. */
|
||||
billableLimit: number;
|
||||
/** USD cap when subscribed; null when noCap or status=='free'. */
|
||||
capUsd: number | null;
|
||||
/** Only meaningful when status=='subscribed'. */
|
||||
noCap: boolean;
|
||||
/** Stripe subscription id when subscribed; null when free. */
|
||||
stripeSubscriptionId: string | null;
|
||||
/** Current-period spend in billable units. */
|
||||
spendUnitsThisPeriod: number;
|
||||
/** Per-category spend breakdown (api / ai / automation). */
|
||||
categoryBreakdown: WalletCategoryBreakdown;
|
||||
/**
|
||||
* Team members, populated for the leader view; empty for members or
|
||||
* single-seat tenants. Leader-vs-member is still resolved via {@link
|
||||
* Wallet#role} — this field just carries the per-member rows the leader's
|
||||
* sub-cap table needs.
|
||||
*/
|
||||
members: WalletMember[];
|
||||
/**
|
||||
* Recent billable-activity rows. V1 returns {@code []} from the backend;
|
||||
* the field exists so the Plan page can render an empty state without
|
||||
* branching on undefined. Each entry is a {@code Record<string, unknown>}
|
||||
* because the activity-row shape is not yet finalised — when the meter-
|
||||
* event surface lands, this widens to a real interface.
|
||||
*/
|
||||
recent: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a per-member sub-cap update. The backend may clamp the requested
|
||||
* value down to the team-level cap (so a member can't be granted more than
|
||||
* the team has to spend) — {@code clamped} surfaces that so the UI can show
|
||||
* a "Clamped to team cap of $X" toast.
|
||||
*/
|
||||
export interface SubCapUpdateResult {
|
||||
/** The cap units that actually landed on the row (post-clamp). */
|
||||
effective: number;
|
||||
/** {@code true} when the server reduced the request to fit the team cap. */
|
||||
clamped: boolean;
|
||||
}
|
||||
|
||||
export interface UseWalletResult {
|
||||
wallet: Wallet | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** Force a refetch — e.g. after Stripe redirects back into the app. */
|
||||
refetch: () => Promise<void>;
|
||||
/**
|
||||
* Dev-only side-channel that simulates the Stripe webhook flipping the
|
||||
* team to subscribed. Used by {@code UpgradeModal} when the backend is
|
||||
* running the mock checkout — the real flow waits for the webhook
|
||||
* instead and the next {@code refetch} picks up the change. Resolves
|
||||
* once the post-mutation refetch completes.
|
||||
*/
|
||||
markSubscribed: (capUsd: number | null) => Promise<void>;
|
||||
/**
|
||||
* Update the team's monthly cap. {@code null} means "no cap". Resolves
|
||||
* once the post-mutation refetch completes so a save-button
|
||||
* {@code loading} state can be safely cleared on resolution.
|
||||
*/
|
||||
updateCap: (capUsd: number | null) => Promise<void>;
|
||||
/**
|
||||
* Update a single member's per-seat sub-cap. {@code capUnits === null}
|
||||
* removes the sub-cap (member shares the team budget). Returns the
|
||||
* effective value the server actually stored — clamping happens server-
|
||||
* side because only the server knows the current team cap, and we'd
|
||||
* race the leader's own cap edit if we tried to clamp in the FE.
|
||||
* Refetches the wallet on success so {@code members[]} reflects the
|
||||
* new value.
|
||||
*/
|
||||
updateSubCap: (
|
||||
userId: string,
|
||||
capUnits: number | null,
|
||||
) => Promise<SubCapUpdateResult>;
|
||||
/**
|
||||
* Mint a Stripe Customer Portal session and navigate to it. Calls
|
||||
* {@code POST /api/v1/payg/portal-session} (which proxies to a Supabase
|
||||
* edge function) and assigns {@code window.location} to the returned URL
|
||||
* — same-tab redirect, matching the sibling {@code ManageBillingButton}.
|
||||
* We do not use {@code window.open(...,"_blank")} after an {@code await}
|
||||
* because browsers treat it as non-user-gesture and silently popup-block
|
||||
* it; the portal is a full-page experience anyway and Stripe redirects
|
||||
* back to {@code returnUrl} on close. Throws on backend error so the
|
||||
* caller can show a friendly toast — notably 503
|
||||
* {@code PORTAL_NOT_CONFIGURED} (Supabase unavailable in local dev) and
|
||||
* 404 (team not yet subscribed). The HTTP request passes
|
||||
* {@code suppressErrorToast: true} so the global response interceptor
|
||||
* doesn't show a generic toast on top of the caller's friendly one.
|
||||
*/
|
||||
openPortal: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Implementation ─────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = "stirling.payg.devSubscription";
|
||||
|
||||
/**
|
||||
* Stable reference reuse — if the new payload deep-equals the previous one,
|
||||
* return the previous object so React's reference check short-circuits child
|
||||
* renders. Walks the top-level scalars first (cheapest), then the nested
|
||||
* {@code categoryBreakdown} object, then the {@code members} array. The
|
||||
* {@code recent} array is identity-compared only — Wave 1 always returns
|
||||
* {@code []} so a reference-stability check is sufficient; we'll deepen
|
||||
* this once the activity surface lands.
|
||||
*/
|
||||
function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
|
||||
if (!prev) return next;
|
||||
if (
|
||||
prev.status !== next.status ||
|
||||
prev.role !== next.role ||
|
||||
prev.billingPeriodStart !== next.billingPeriodStart ||
|
||||
prev.billingPeriodEnd !== next.billingPeriodEnd ||
|
||||
prev.billableUsed !== next.billableUsed ||
|
||||
prev.billableLimit !== next.billableLimit ||
|
||||
prev.capUsd !== next.capUsd ||
|
||||
prev.noCap !== next.noCap ||
|
||||
prev.stripeSubscriptionId !== next.stripeSubscriptionId ||
|
||||
prev.spendUnitsThisPeriod !== next.spendUnitsThisPeriod
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
if (
|
||||
prev.categoryBreakdown.api !== next.categoryBreakdown.api ||
|
||||
prev.categoryBreakdown.ai !== next.categoryBreakdown.ai ||
|
||||
prev.categoryBreakdown.automation !== next.categoryBreakdown.automation
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
if (prev.members.length !== next.members.length) {
|
||||
return next;
|
||||
}
|
||||
for (let i = 0; i < prev.members.length; i++) {
|
||||
const a = prev.members[i];
|
||||
const b = next.members[i];
|
||||
if (
|
||||
a.userId !== b.userId ||
|
||||
a.name !== b.name ||
|
||||
a.email !== b.email ||
|
||||
a.capUnits !== b.capUnits ||
|
||||
a.spendUnits !== b.spendUnits
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
if (prev.recent !== next.recent && prev.recent.length !== next.recent.length) {
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesise a wallet snapshot for the dev preview route. Mirrors the same
|
||||
* shape the backend returns. Subscription state comes from localStorage so
|
||||
* the modal's "mark subscribed" action survives a reload.
|
||||
*/
|
||||
function buildDevPreviewWallet(role: WalletRole): Wallet {
|
||||
const subscribed =
|
||||
typeof window !== "undefined" &&
|
||||
(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === "subscribed";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const now = new Date();
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const isoDay = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
return {
|
||||
status: subscribed ? "subscribed" : "free",
|
||||
role,
|
||||
billingPeriodStart: isoDay(periodStart),
|
||||
billingPeriodEnd: isoDay(periodEnd),
|
||||
billableUsed: 62,
|
||||
billableLimit: 500,
|
||||
capUsd: subscribed ? 25 : null,
|
||||
noCap: false,
|
||||
stripeSubscriptionId: subscribed ? "sub_devpreview" : null,
|
||||
spendUnitsThisPeriod: 62,
|
||||
// Wave 1 backend (PR #6574) returns a per-category breakdown so the
|
||||
// hero panel can split AI / automation / API. Use realistic but
|
||||
// tier-distinguishable mock values so the dev preview shows a
|
||||
// different visual when the localStorage flip toggles subscribed.
|
||||
categoryBreakdown: subscribed
|
||||
? { api: 12, ai: 35, automation: 15 }
|
||||
: { api: 5, ai: 40, automation: 17 },
|
||||
// Members are populated in the leader view by the real backend
|
||||
// (joining team_memberships); the dev preview returns an empty
|
||||
// array — Plan.tsx + PaygLeader still resolve role via wallet.role,
|
||||
// so empty members just hides the sub-caps card.
|
||||
members: [],
|
||||
// Activity feed is V1 = [], the backend ships this in Wave 2 once
|
||||
// payg_meter_event_log is read-accessible from the wallet endpoint.
|
||||
recent: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** True when we're rendered outside the real saas app (e.g. dev preview route). */
|
||||
function isDevPreviewContext(): boolean {
|
||||
// Both checks required: production builds drop the path check, so a real
|
||||
// tenant whose URL begins with /dev/ can't accidentally hit the synthesised
|
||||
// fallback.
|
||||
if (!import.meta.env.DEV) return false;
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.location.pathname.startsWith("/dev/");
|
||||
}
|
||||
|
||||
/** Best-effort role read for dev preview — flips per query string ?role=member. */
|
||||
function devPreviewRole(): WalletRole {
|
||||
if (typeof window === "undefined") return "leader";
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get("role") === "member" ? "member" : "leader";
|
||||
}
|
||||
|
||||
export function useWallet(): UseWalletResult {
|
||||
const devPreview = useRef<boolean>(isDevPreviewContext()).current;
|
||||
|
||||
const [wallet, setWallet] = useState<Wallet | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refetchTick, setRefetchTick] = useState(0);
|
||||
|
||||
// Monotonic request id — used to discard stale responses if a faster
|
||||
// refetch lands first. Only the latest issued id is permitted to commit
|
||||
// its result.
|
||||
const latestReqId = useRef(0);
|
||||
|
||||
// Promise tracking the most recent in-flight load. Mutations await this
|
||||
// so their resolution semantics are "the new state is visible," not
|
||||
// "the request fired." Cleared when no load is pending.
|
||||
const inFlight = useRef<Promise<void> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const reqId = ++latestReqId.current;
|
||||
let cancelled = false;
|
||||
|
||||
const promise = (async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (devPreview) {
|
||||
const synth = buildDevPreviewWallet(devPreviewRole());
|
||||
if (cancelled || reqId !== latestReqId.current) return;
|
||||
setWallet((prev) => reuseIfEqual(prev, synth));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiClient.get<Wallet>("/api/v1/payg/wallet");
|
||||
if (cancelled || reqId !== latestReqId.current) return;
|
||||
setWallet((prev) => reuseIfEqual(prev, res.data));
|
||||
} catch (e: unknown) {
|
||||
if (cancelled || reqId !== latestReqId.current) return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[useWallet] fetch failed", e);
|
||||
setError(e instanceof Error ? e.message : "Failed to load wallet");
|
||||
} finally {
|
||||
if (!cancelled && reqId === latestReqId.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
inFlight.current = promise;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Don't clear inFlight here — let it resolve so mutations awaiting it
|
||||
// still see a definitive "load completed" point. The reqId guard
|
||||
// upstream ensures stale results don't commit.
|
||||
};
|
||||
}, [devPreview, refetchTick]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setRefetchTick((t) => t + 1);
|
||||
// Snapshot the next-tick promise so the caller awaits this refetch
|
||||
// specifically — the in-flight ref will be updated to it on the next
|
||||
// effect run, but we can't reference that synchronously, so settle for
|
||||
// a microtask handoff: await the *current* effect to flush, then await
|
||||
// the new in-flight promise.
|
||||
await Promise.resolve();
|
||||
if (inFlight.current) {
|
||||
await inFlight.current;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markSubscribed = useCallback(
|
||||
async (capUsd: number | null) => {
|
||||
if (devPreview) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, "subscribed");
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
await refetch();
|
||||
return;
|
||||
}
|
||||
const noCap = capUsd === null;
|
||||
// The dev side-channel only exists when the BE mock service is
|
||||
// running (FE-branch local dev). Once the real backend (PR #6574)
|
||||
// is in play, /dev/mark-subscribed is removed and the webhook
|
||||
// (customer.subscription.created) is what flips the team to
|
||||
// subscribed. We swallow 404s so the modal's completion path —
|
||||
// which awaits this promise before rendering the confirmation
|
||||
// screen — doesn't error out on a perfectly normal "the real
|
||||
// backend doesn't expose this dev hook" response. A subsequent
|
||||
// refetch picks up the webhook-driven flip whenever it lands.
|
||||
try {
|
||||
await apiClient.post("/api/v1/payg/dev/mark-subscribed", {
|
||||
capUsd: capUsd ?? 0,
|
||||
noCap,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const status =
|
||||
typeof e === "object" && e !== null && "response" in e
|
||||
? (e as { response?: { status?: number } }).response?.status
|
||||
: undefined;
|
||||
if (status === 404) {
|
||||
// Real BE in play — webhook will land the subscription
|
||||
// state; log and continue. Loud-but-harmless so the dev
|
||||
// notices their /dev/mark-subscribed isn't wired up.
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(
|
||||
"[useWallet] /dev/mark-subscribed not available (404) — relying on Stripe webhook to flip subscription state",
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
await refetch();
|
||||
},
|
||||
[devPreview, refetch],
|
||||
);
|
||||
|
||||
const updateCap = useCallback(
|
||||
async (capUsd: number | null) => {
|
||||
const noCap = capUsd === null;
|
||||
if (devPreview) {
|
||||
await refetch();
|
||||
return;
|
||||
}
|
||||
await apiClient.patch("/api/v1/payg/cap", {
|
||||
capUsd: capUsd ?? 0,
|
||||
noCap,
|
||||
});
|
||||
await refetch();
|
||||
},
|
||||
[devPreview, refetch],
|
||||
);
|
||||
|
||||
const updateSubCap = useCallback(
|
||||
async (
|
||||
userId: string,
|
||||
capUnits: number | null,
|
||||
): Promise<SubCapUpdateResult> => {
|
||||
if (devPreview) {
|
||||
// Dev preview has no real members; report a successful no-op so the
|
||||
// toast still fires and the inline editor exits its loading state.
|
||||
await refetch();
|
||||
return { effective: capUnits ?? 0, clamped: false };
|
||||
}
|
||||
// suppressErrorToast: true — the MemberRow caller shows a friendly
|
||||
// toast on failure; without this the global interceptor's generic
|
||||
// toast would stack on top. Matches the established pattern in
|
||||
// saas/services/userManagementService.ts.
|
||||
const res = await apiClient.patch<{
|
||||
success: boolean;
|
||||
capUnits: number;
|
||||
clamped: boolean;
|
||||
}>(
|
||||
`/api/v1/payg/sub-caps/${encodeURIComponent(userId)}`,
|
||||
{ capUnits },
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
await refetch();
|
||||
return { effective: res.data.capUnits, clamped: res.data.clamped };
|
||||
},
|
||||
[devPreview, refetch],
|
||||
);
|
||||
|
||||
const openPortal = useCallback(async () => {
|
||||
if (devPreview) {
|
||||
// No real Stripe in dev preview — navigate to a placeholder so the
|
||||
// click still feels alive. Same-tab to match the real-flow behaviour.
|
||||
window.location.assign("https://billing.stripe.com/p/login/mock");
|
||||
return;
|
||||
}
|
||||
// suppressErrorToast: true — the StripePortalLink caller shows a
|
||||
// friendly toast on failure (notably 404 / 503 PORTAL_NOT_CONFIGURED);
|
||||
// without this the global interceptor's generic toast would stack on
|
||||
// top. Matches saas/services/userManagementService.ts pattern.
|
||||
const res = await apiClient.post<{ url: string }>(
|
||||
"/api/v1/payg/portal-session",
|
||||
{ returnUrl: window.location.href },
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
if (!res.data?.url) {
|
||||
throw new Error("Portal session response missing url");
|
||||
}
|
||||
// Same-tab navigation rather than window.open(...,"_blank"). The
|
||||
// sibling ManageBillingButton uses the same approach: Stripe's
|
||||
// customer portal is a full-page experience and brings the user back
|
||||
// via returnUrl, so a popup buys us nothing — and window.open after
|
||||
// an awaited promise is treated as non-user-gesture and silently
|
||||
// popup-blocked by most browsers.
|
||||
window.location.assign(res.data.url);
|
||||
}, [devPreview]);
|
||||
|
||||
return {
|
||||
wallet,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
markSubscribed,
|
||||
updateCap,
|
||||
updateSubCap,
|
||||
openPortal,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import { supabase } from "@app/auth/supabase";
|
||||
import { handleHttpError } from "@app/services/httpErrorHandler";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
import {
|
||||
classifyPaygError,
|
||||
handlePaygError,
|
||||
} from "@app/services/paygErrorInterceptor";
|
||||
|
||||
// Global credit update callback - will be set by the AuthProvider
|
||||
let globalCreditUpdateCallback: ((credits: number) => void) | null = null;
|
||||
@@ -158,6 +162,32 @@ apiClient.interceptors.response.use(
|
||||
originalRequest.url?.includes(endpoint),
|
||||
);
|
||||
|
||||
// PAYG entitlement errors come from the EntitlementGuard on the server
|
||||
// and have specific sentinels in the response body that we want to
|
||||
// recognise *before* the generic 401/401-refresh logic kicks in:
|
||||
//
|
||||
// 402 FEATURE_DEGRADED — free-tier monthly cap exhausted; show a
|
||||
// toast nudging the user to the Plan tab to upgrade.
|
||||
// 401 SIGNUP_REQUIRED — anonymous user hit a billable endpoint;
|
||||
// show a "Sign up to use [category]" modal instead of redirecting
|
||||
// to /login (which is the default 401 behaviour). The user IS
|
||||
// authenticated as anonymous — refreshing their session wouldn't
|
||||
// unlock the endpoint, only signing up will.
|
||||
//
|
||||
// We classify the error here. If it matches either sentinel, we
|
||||
// surface the appropriate UI and short-circuit the rest of the
|
||||
// response interceptor so:
|
||||
// - 401 SIGNUP_REQUIRED won't trigger the session-refresh / redirect-
|
||||
// to-login dance below.
|
||||
// - The handleHttpError() generic toast at the bottom won't fire.
|
||||
// The error itself is still propagated to the caller so any
|
||||
// component-level catch can react if needed.
|
||||
const paygKind = classifyPaygError(error);
|
||||
if (paygKind !== null) {
|
||||
handlePaygError(paygKind, error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// If we get a 401 and haven't already tried to refresh, and it's not a public endpoint
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the toast layer and openPlanSettings so we can assert what the
|
||||
// handler dispatches without needing a real DOM context for the toast
|
||||
// portal. Mocks are hoisted by vitest so the module under test imports
|
||||
// these in place of the real implementations.
|
||||
vi.mock("@app/components/toast", () => ({
|
||||
alert: vi.fn(),
|
||||
}));
|
||||
vi.mock("@app/utils/appSettings", () => ({
|
||||
openPlanSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
import { alert } from "@app/components/toast";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
import {
|
||||
classifyPaygError,
|
||||
extractSignupCategory,
|
||||
handlePaygError,
|
||||
} from "@app/services/paygErrorInterceptor";
|
||||
|
||||
describe("classifyPaygError", () => {
|
||||
it("returns FEATURE_DEGRADED for 402 + error sentinel", () => {
|
||||
const err = {
|
||||
response: {
|
||||
status: 402,
|
||||
data: {
|
||||
error: "FEATURE_DEGRADED",
|
||||
missingGates: ["AUTOMATION"],
|
||||
state: "DEGRADED",
|
||||
periodEnd: "2026-06-30",
|
||||
capUnits: 500,
|
||||
spendUnits: 500,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(classifyPaygError(err)).toBe("FEATURE_DEGRADED");
|
||||
});
|
||||
|
||||
it("returns SIGNUP_REQUIRED for 401 + error sentinel", () => {
|
||||
const err = {
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: "SIGNUP_REQUIRED", category: "AI" },
|
||||
},
|
||||
};
|
||||
expect(classifyPaygError(err)).toBe("SIGNUP_REQUIRED");
|
||||
});
|
||||
|
||||
it("returns null for plain 401 (session expired path is untouched)", () => {
|
||||
const err = {
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: "Unauthorized" },
|
||||
},
|
||||
};
|
||||
expect(classifyPaygError(err)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for 402 without the FEATURE_DEGRADED sentinel", () => {
|
||||
const err = {
|
||||
response: { status: 402, data: { error: "Payment required" } },
|
||||
};
|
||||
expect(classifyPaygError(err)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when status mismatches sentinel (defence-in-depth)", () => {
|
||||
// 500 + FEATURE_DEGRADED shouldn't match — server may have a bug and
|
||||
// we don't want a generic 500 to silently degrade into an upgrade
|
||||
// prompt. Same for 403 + SIGNUP_REQUIRED.
|
||||
const a = {
|
||||
response: { status: 500, data: { error: "FEATURE_DEGRADED" } },
|
||||
};
|
||||
const b = {
|
||||
response: { status: 403, data: { error: "SIGNUP_REQUIRED" } },
|
||||
};
|
||||
expect(classifyPaygError(a)).toBeNull();
|
||||
expect(classifyPaygError(b)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for malformed errors (null / non-object / no response)", () => {
|
||||
expect(classifyPaygError(null)).toBeNull();
|
||||
expect(classifyPaygError(undefined)).toBeNull();
|
||||
expect(classifyPaygError("oops")).toBeNull();
|
||||
expect(classifyPaygError({})).toBeNull();
|
||||
expect(classifyPaygError({ response: null })).toBeNull();
|
||||
expect(classifyPaygError({ response: { status: 402 } })).toBeNull();
|
||||
expect(
|
||||
classifyPaygError({ response: { status: 402, data: null } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyPaygError({ response: { status: 402, data: "bare-string" } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyPaygError({ response: { status: 402, data: { error: 123 } } }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSignupCategory", () => {
|
||||
it("returns the category string when present", () => {
|
||||
expect(
|
||||
extractSignupCategory({
|
||||
response: { data: { error: "SIGNUP_REQUIRED", category: "AI" } },
|
||||
}),
|
||||
).toBe("AI");
|
||||
});
|
||||
|
||||
it("returns null when missing or wrong type", () => {
|
||||
expect(extractSignupCategory(null)).toBeNull();
|
||||
expect(extractSignupCategory({})).toBeNull();
|
||||
expect(
|
||||
extractSignupCategory({ response: { data: { category: 7 } } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
extractSignupCategory({ response: { data: {} } }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlePaygError", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the persistent upgrade toast on FEATURE_DEGRADED", () => {
|
||||
handlePaygError("FEATURE_DEGRADED", {
|
||||
response: { status: 402, data: { error: "FEATURE_DEGRADED" } },
|
||||
});
|
||||
expect(alert).toHaveBeenCalledTimes(1);
|
||||
const opts = vi.mocked(alert).mock.calls[0][0];
|
||||
expect(opts.alertType).toBe("warning");
|
||||
expect(opts.isPersistentPopup).toBe(true);
|
||||
expect(opts.buttonText).toBe("Go to billing");
|
||||
// Body should reference the 500-op free monthly allowance so the
|
||||
// user understands what they hit.
|
||||
expect(String(opts.body)).toMatch(/500/);
|
||||
});
|
||||
|
||||
it("invoking the toast's buttonCallback opens the Plan settings tab", () => {
|
||||
handlePaygError("FEATURE_DEGRADED", {
|
||||
response: { status: 402, data: { error: "FEATURE_DEGRADED" } },
|
||||
});
|
||||
const opts = vi.mocked(alert).mock.calls[0][0];
|
||||
expect(opts.buttonCallback).toBeDefined();
|
||||
opts.buttonCallback?.();
|
||||
expect(openPlanSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("dispatches payg:signupRequired on SIGNUP_REQUIRED with category in detail", () => {
|
||||
const handler = vi.fn();
|
||||
window.addEventListener("payg:signupRequired", handler);
|
||||
try {
|
||||
handlePaygError("SIGNUP_REQUIRED", {
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: "SIGNUP_REQUIRED", category: "AUTOMATION" },
|
||||
},
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const ev = handler.mock.calls[0][0] as CustomEvent;
|
||||
expect(ev.detail).toEqual({ category: "AUTOMATION" });
|
||||
// No toast for SIGNUP_REQUIRED — the modal carries the message.
|
||||
expect(alert).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
window.removeEventListener("payg:signupRequired", handler);
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches with category=null when the body has no category", () => {
|
||||
const handler = vi.fn();
|
||||
window.addEventListener("payg:signupRequired", handler);
|
||||
try {
|
||||
handlePaygError("SIGNUP_REQUIRED", {
|
||||
response: { status: 401, data: { error: "SIGNUP_REQUIRED" } },
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const ev = handler.mock.calls[0][0] as CustomEvent;
|
||||
expect(ev.detail).toEqual({ category: null });
|
||||
} finally {
|
||||
window.removeEventListener("payg:signupRequired", handler);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Classifies and reacts to PAYG-specific error responses surfaced by the
|
||||
* backend's {@code EntitlementGuard} (Wave 1 BE on PR #6574). Two sentinels
|
||||
* are recognised:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code 402 FEATURE_DEGRADED} — free-tier user has burned through
|
||||
* their 500-op monthly allowance. Surface a toast that nudges them to
|
||||
* the Plan tab so they can upgrade.</li>
|
||||
* <li>{@code 401 SIGNUP_REQUIRED} — anonymous (guest) user hit a billable
|
||||
* endpoint. Open a modal explaining why they need a real account and
|
||||
* where their 500-op free monthly allowance comes in. The body's
|
||||
* {@code category} field ({@code AI}, {@code AUTOMATION}, {@code API})
|
||||
* feeds the modal title so the user understands *which* feature they
|
||||
* just hit. We dispatch a {@code CustomEvent} rather than rendering
|
||||
* directly from this module because the apiClient is created outside
|
||||
* the React tree and can't import JSX; the listener lives on a
|
||||
* bootstrap component mounted near the app root.</li>
|
||||
* </ul>
|
||||
*
|
||||
* The classifier is exported separately from the handler so unit tests can
|
||||
* exercise the parsing logic without touching the toast / event side
|
||||
* effects.
|
||||
*/
|
||||
import { alert } from "@app/components/toast";
|
||||
import i18n from "@app/i18n";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
|
||||
/**
|
||||
* Possible PAYG entitlement sentinels the EntitlementGuard returns.
|
||||
* {@code null} when the error is not a PAYG entitlement response.
|
||||
*/
|
||||
export type PaygErrorKind = "FEATURE_DEGRADED" | "SIGNUP_REQUIRED";
|
||||
|
||||
/**
|
||||
* Detail payload broadcast on {@code payg:signupRequired} when an anonymous
|
||||
* user hits a billable endpoint. The listener (a Bootstrap component near
|
||||
* the app root) opens a modal whose copy is parameterised by
|
||||
* {@link #category}.
|
||||
*/
|
||||
export interface PaygSignupRequiredDetail {
|
||||
/** Category that triggered the gate — {@code AI}, {@code AUTOMATION}, or {@code API}. */
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect an axios-style error and decide whether it's one of the known
|
||||
* PAYG sentinels. Returns the kind, or {@code null} if it isn't.
|
||||
*
|
||||
* The check is intentionally strict (status code AND body.error sentinel)
|
||||
* so we don't hijack incidental 401/402 responses from other endpoints —
|
||||
* notably the existing session-expired 401 flow.
|
||||
*/
|
||||
export function classifyPaygError(error: unknown): PaygErrorKind | null {
|
||||
if (!error || typeof error !== "object") return null;
|
||||
const response = (error as { response?: unknown }).response;
|
||||
if (!response || typeof response !== "object") return null;
|
||||
const status = (response as { status?: unknown }).status;
|
||||
const data = (response as { data?: unknown }).data;
|
||||
if (typeof status !== "number") return null;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const sentinel = (data as { error?: unknown }).error;
|
||||
if (typeof sentinel !== "string") return null;
|
||||
|
||||
if (status === 402 && sentinel === "FEATURE_DEGRADED") {
|
||||
return "FEATURE_DEGRADED";
|
||||
}
|
||||
if (status === 401 && sentinel === "SIGNUP_REQUIRED") {
|
||||
return "SIGNUP_REQUIRED";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract {@code data.category} (a string) from an axios error, or {@code null}. */
|
||||
export function extractSignupCategory(error: unknown): string | null {
|
||||
if (!error || typeof error !== "object") return null;
|
||||
const response = (error as { response?: unknown }).response;
|
||||
if (!response || typeof response !== "object") return null;
|
||||
const data = (response as { data?: unknown }).data;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const category = (data as { category?: unknown }).category;
|
||||
return typeof category === "string" ? category : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface the appropriate UI for a classified PAYG error. Toast for
|
||||
* {@code FEATURE_DEGRADED}, modal-via-CustomEvent for {@code SIGNUP_REQUIRED}.
|
||||
*
|
||||
* Idempotent / safe to call multiple times — the toast layer coalesces
|
||||
* duplicates by (alertType, title, body) and the modal listener already
|
||||
* dedupes by its own opened-state. Suppress-respecting: if the caller
|
||||
* passed {@code suppressErrorToast: true} on the axios config (the
|
||||
* established pattern for component-level error handling), we still fire
|
||||
* the PAYG UI because these are user-facing gates, not transient
|
||||
* error toasts — the suppression flag was for the *generic* error toast,
|
||||
* which we're replacing with something more actionable.
|
||||
*/
|
||||
export function handlePaygError(kind: PaygErrorKind, error: unknown): void {
|
||||
if (kind === "FEATURE_DEGRADED") {
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: i18n.t(
|
||||
"payg.exhausted.title",
|
||||
"You've hit your free monthly limit",
|
||||
),
|
||||
body: i18n.t(
|
||||
"payg.exhausted.body",
|
||||
"You've used your free 500 operations this month. Upgrade to Processor to keep going.",
|
||||
),
|
||||
buttonText: i18n.t("payg.exhausted.cta", "Go to billing"),
|
||||
buttonCallback: () => openPlanSettings(),
|
||||
isPersistentPopup: true,
|
||||
location: "bottom-right",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "SIGNUP_REQUIRED") {
|
||||
const category = extractSignupCategory(error);
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<PaygSignupRequiredDetail>("payg:signupRequired", {
|
||||
detail: { category },
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// SSR / test environments without a real window — no-op.
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user