mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
PAYG: pay-as-you-go billing — metered automation/AI/API + one-time free grant (#6589)
## Summary Pay-as-you-go (PAYG) billing for Stirling-PDF SaaS. Manual PDF editing stays free forever; only **automation, AI, and API** usage is metered. Every team gets a **one-time lifetime free grant** (default 500 PDFs) before any billing; past that, a team adds a card and pays per metered document, with a self-set monthly spending cap. This branch combines and supersedes the in-flight BE (#6574) and FE (#6579) work plus the SaaS edge functions (Stirling-PDF-SaaS PR, now on `v3`), hardened into a single reviewable feature after a pre-merge dead-code/security review. ## Billing model - **Always free:** manual / JWT web-tool usage is `BYPASSED` — never metered, no matter where it's triggered. - **Billable categories:** `AUTOMATION`, `AI`, `API`. - **One-time lifetime free grant** (`pricing_policy.free_tier_units`, default 500): never resets, survives subscribing. It gates unsubscribed teams (billable API calls hard-stop with a 402 once exhausted) and decides the free-vs-paid split of every job. - **Subscribed:** paid documents (beyond the grant) are metered to a Stripe Billing Meter; an optional monthly spending cap degrades billable categories when reached. - **Dedup:** the same file pushed through several steps within a workflow window counts **once** (lineage join), so API/AI chaining on one file isn't double-charged. ## What's included **Database** — Flyway migrations `V11`→`V21` with matching Supabase twins: pricing policy + per-team sidecar (`payg_team_extensions`: subscription id, Stripe customer, free-grant counter), append-only `wallet_ledger`, shadow charges, subscription-state RPCs (`V14`), audit logs (`V15`), billing category (`V16`), one-time lifetime free grant (`V19`), launch-grant seed (`V20`), drop of the unused `wallet_category_summary` view (`V21`). **Charge pipeline** — `PaygChargeInterceptor` (open/join a process, split the free grant, write the ledger DEBIT), `JobChargeService` (consume the grant under a row lock, restore it on a first-step refund, meter only the paid portion on completion), `StaleJobCloser` fallback (idempotent close → meter). **Entitlement** — `EntitlementService` (per-team cached snapshot: grant-gated for free teams, monthly-cap-gated for subscribed) + `EntitlementGuard` (401 `SIGNUP_REQUIRED` / 402 `FEATURE_DEGRADED` / `PAYG_LIMIT_REACHED`). **Metering** — `PaygMeterReportingService` writes a durable `payg_meter_event_log` row around every POST to the `meter-payg-units` edge fn (pending → posted/failed); `PaygMeterReconcileScheduler` retries unposted events under the same idempotency key inside Stripe's 24h dedup window. **Billing facts** — `TeamBillingService` reads the synced `stripe.*` mirror (subscription window, per-document rate; the unsubscribed-team estimate resolves the rate by Price `lookup_key = plan:processor`). **Wallet API** — `PaygWalletController`: `GET /api/v1/payg/wallet`, `PATCH /api/v1/payg/cap`. **Frontend** — PAYG Plan page (two-card free layout + subscribed views), `useWallet`, upgrade modal with lazy-loaded Stripe Embedded Checkout and a shared `SpendCapControl`, customer-portal link, 402/401 interceptor toast, en-GB i18n. (Per-member usage shows each teammate's spend; the activity feed is behind a flag until polished.) **SaaS edge functions** (`Stirling-PDF-SaaS` `v3`) — `create-checkout-session`, `create-payg-team-subscription`, `create-customer-portal-session`, `meter-payg-units`, `payg-subscription-webhook`, `stripe-sync`, plus the stripe-sync `migrate` + scoped-`backfill` scripts. All price lookup is DB-driven (no `STRIPE_PAYG_PRICE_ID_*` env vars). ## Release prerequisites (prod) 1. Apply Flyway migrations (`V11`→`V21`) and the Supabase migration twins. 2. Stripe Sync Engine: run `stripe-sync:migrate`, then a **scoped** backfill — `product`, `price`, `customer`, `subscription` only (not `all`, which rate-limits). 3. Register 2 PAYG webhook endpoints (each its own signing secret): `stripe-sync` (product/price/customer/subscription `.*`) and `payg-subscription-webhook` (`customer.subscription.created`/`.deleted` drive state; `.updated` + `invoice.*` observed). Keep the legacy `stripe-webhook` only if credits/self-hosted flows still run. 4. Stripe Billing Meter: `event_name = payg_doc_units`, value key `processed_documents`. 5. Env: `PAYG_METER_ENDPOINT` + `SUPABASE_EDGE_FUNCTION_SECRET` (backend); the webhook signing secrets (edge fns). The default pricing policy must point at the PAYG Stripe Price(s); `V20` seeds `free_tier_units = 500`. ## Testing - `:saas:test` green, `:saas:spotlessCheck` clean, edge-fn Deno tests green, FE saas typecheck clean (the remaining errors are pre-existing `proprietary/*` + `prototypes/*`, untouched here). Cucumber shadow-mode suite + CI workflow included. ## Pre-merge review An independent dead-code/security pass came back **clean on security** (team-derived authz / no IDOR, leader-only cap mutation, no billing-category downgrade, dev/mock hooks gated to `import.meta.env.DEV` + `/dev/`, no secrets/injection, fail-open metering by design). The dead/unwired code it flagged has been removed in this branch (unenforced sub-cap control, an unused JDBC DAO + its view, dead methods). ## Follow-ups (tracked, not blocking) - **Enforce per-member sub-caps** — the control was removed because it read for display but never gated a request; the per-member usage display and `cap_units` column are retained for when enforcement is wired. - **API/AI chaining billing model + `ProcessType` enum** — confirm same-file dedup covers API chaining; define per-tool AI charging; decide whether the unused enum values stay. - **Activity feed** — hidden behind a flag until the meter-event surface is polished. --------- Co-authored-by: Reece <[email protected]>
This commit is contained in:
@@ -3090,6 +3090,18 @@ 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"
|
||||
saas = "Connected to Stirling Cloud"
|
||||
selfhostedChecking = "Connected to self-hosted server (checking...)"
|
||||
selfhostedOffline = "Self-hosted server unreachable"
|
||||
selfhostedOnline = "Connected to self-hosted server"
|
||||
|
||||
[convert]
|
||||
tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as"
|
||||
autoRotate = "Auto Rotate"
|
||||
@@ -5132,6 +5144,239 @@ title = "Page Ranges"
|
||||
bullet1 = "<strong>all</strong> → selects all pages"
|
||||
title = "Special Keywords"
|
||||
|
||||
[pageSelection.tooltip.syntax]
|
||||
text = "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported."
|
||||
title = "Syntax Basics"
|
||||
|
||||
[pageSelection.tooltip.syntax.bullets]
|
||||
keywords = "Keywords: odd, even"
|
||||
numbers = "Numbers/ranges: 5, 10-20"
|
||||
progressions = "Progressions: 3n, 4n+1"
|
||||
|
||||
[pageSelection.tooltip.tips]
|
||||
bullet1 = "Page numbers start from 1 (not 0)"
|
||||
bullet2 = "Spaces are automatically removed"
|
||||
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"
|
||||
custom = "Custom"
|
||||
degradeAt = "Limit spend at"
|
||||
degradeAtDesc = "Set below 100% to leave yourself headroom."
|
||||
docsEstimate = "≈ {{docs}} processed PDFs / month"
|
||||
docsRate = "at {{rate}} / PDF"
|
||||
noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time."
|
||||
noCapLabel = "No cap"
|
||||
perMonth = "/ month"
|
||||
preview = "{{money}} ≈ {{units}} PDFs per month"
|
||||
previewNote = "At current pricing. Final translation happens server-side on save."
|
||||
previewShort = "≈ {{units}} PDFs per month at current pricing."
|
||||
save = "Update cap"
|
||||
subtitle = "Set your maximum spend. We convert this to PDFs 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 your 500 free PDFs."
|
||||
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 all 500 of your free PDFs. Upgrade to Processor to keep going."
|
||||
cta = "Go to billing"
|
||||
title = "You've used your free PDFs"
|
||||
|
||||
[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 = "Keep going past your 500 free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control."
|
||||
title = "Turn on the Processor plan"
|
||||
|
||||
[payg.free.editor]
|
||||
eyebrow = "Editor plan · Always free"
|
||||
|
||||
[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). Past your free PDFs you'll need Processor."
|
||||
countsLabel = "Counts toward your 500 free"
|
||||
sharedBody = "Automation pipelines, AI tools, and API calls share a single one-time allowance of 500 free PDFs across the whole team. Once it's used up, 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 PDFs"
|
||||
eyebrow = "Your free PDFs"
|
||||
metaCategories = "Automation · AI · API requests"
|
||||
neverResets = "One-time — never resets"
|
||||
|
||||
[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 share the team's one-time allowance of 500 free PDFs."
|
||||
ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like."
|
||||
title = "Want to process more than your 500 free PDFs?"
|
||||
|
||||
[payg.free.proc]
|
||||
eyebrow = "Processor plan · metered"
|
||||
|
||||
[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."
|
||||
|
||||
[payg.members]
|
||||
docs = "PDFs"
|
||||
subtitle = "Billable PDFs each teammate has processed this period."
|
||||
title = "Team member usage"
|
||||
|
||||
[payg.role]
|
||||
leader = "Team owner"
|
||||
member = "Member"
|
||||
|
||||
[payg.signupRequired]
|
||||
body = "Stirling PDF gives every signed-up account 500 free operations — 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.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. 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"
|
||||
usdNote = "Estimated in USD. You can adjust your cap any time after subscribing — in your own currency."
|
||||
|
||||
[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..."
|
||||
canCloseWindow = "You can now close this window."
|
||||
|
||||
@@ -31,6 +31,7 @@ export const VALID_NAV_KEYS = [
|
||||
"adminStorageSharing",
|
||||
"adminMcp",
|
||||
"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 />} />
|
||||
|
||||
@@ -18,11 +18,8 @@ import {
|
||||
CreditSummary,
|
||||
SubscriptionInfo,
|
||||
CreditCheckResult,
|
||||
ApiCredits,
|
||||
} from "@app/types/credits";
|
||||
import apiClient, {
|
||||
setGlobalCreditUpdateCallback,
|
||||
} from "@app/services/apiClient";
|
||||
import { setGlobalCreditUpdateCallback } from "@app/services/apiClient";
|
||||
import { synchronizeUserUpgrade } from "@app/services/userService";
|
||||
import {
|
||||
syncOAuthAvatar,
|
||||
@@ -145,72 +142,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [profilePictureMetadata, setProfilePictureMetadata] =
|
||||
useState<ProfilePictureMetadata | null>(null);
|
||||
|
||||
const fetchCredits = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug("[Auth Debug] No user session, skipping credit fetch");
|
||||
setCreditBalance(null);
|
||||
setCreditSummary(null);
|
||||
setSubscription(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug(
|
||||
"[Auth Debug] Fetching credits for user:",
|
||||
currentSession.user.id,
|
||||
);
|
||||
// Auto-fires on session init and TOKEN_REFRESHED; a backend 401 must
|
||||
// stay local rather than trigger the global login redirect.
|
||||
const response = await apiClient.get<ApiCredits>("/api/v1/credits", {
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
const apiCredits = response.data;
|
||||
|
||||
// Map server payload to app CreditSummary
|
||||
const credits: CreditSummary = {
|
||||
currentCredits: apiCredits.totalAvailableCredits,
|
||||
maxCredits:
|
||||
apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
|
||||
creditsUsed:
|
||||
apiCredits.weeklyCreditsAllocated -
|
||||
apiCredits.weeklyCreditsRemaining +
|
||||
(apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining),
|
||||
creditsRemaining: apiCredits.totalAvailableCredits,
|
||||
resetDate: apiCredits.weeklyResetDate,
|
||||
weeklyAllowance: apiCredits.weeklyCreditsAllocated,
|
||||
};
|
||||
|
||||
setCreditSummary(credits);
|
||||
setCreditBalance(credits.creditsRemaining);
|
||||
|
||||
const subscriptionInfo: SubscriptionInfo = {
|
||||
status: "active",
|
||||
tier: (credits.weeklyAllowance || 0) > 100 ? "premium" : "free",
|
||||
creditsPerWeek: credits.weeklyAllowance,
|
||||
maxCredits: credits.maxCredits,
|
||||
};
|
||||
setSubscription(subscriptionInfo);
|
||||
|
||||
console.debug("[Auth Debug] Credits fetched successfully:", credits);
|
||||
} catch (error: unknown) {
|
||||
console.debug("[Auth Debug] Failed to fetch credits:", error);
|
||||
// Don't set error state for credit fetching failures to avoid disrupting auth flow
|
||||
// Credits might not be available in all deployments
|
||||
setCreditBalance(null);
|
||||
setCreditSummary(null);
|
||||
setSubscription(null);
|
||||
}
|
||||
},
|
||||
[session],
|
||||
);
|
||||
// Legacy weekly-credits feed (GET /api/v1/credits) is dead. PAYG replaces it via
|
||||
// useWallet() reading /api/v1/payg/wallet. Symbols are kept as no-ops so existing
|
||||
// consumers of useAuth() that still destructure creditBalance / refreshCredits
|
||||
// compile cleanly; values just stay null forever and refreshCredits is a noop.
|
||||
// _ underscore on the param keeps the public signature stable for callers.
|
||||
const fetchCredits = useCallback(async (_sessionToUse?: Session | null) => {
|
||||
/* legacy credit fetch removed — see comment above */
|
||||
}, []);
|
||||
|
||||
const refreshCredits = useCallback(async () => {
|
||||
await fetchCredits();
|
||||
}, [fetchCredits]);
|
||||
/* legacy credit refresh removed — useWallet() replaces this */
|
||||
}, []);
|
||||
|
||||
const fetchProStatus = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
|
||||
@@ -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 — 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>
|
||||
);
|
||||
}
|
||||
@@ -61,6 +61,24 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
window.removeEventListener("appConfig:notice", handler as EventListener);
|
||||
}, []);
|
||||
|
||||
// Full-screen overlays that live inside our React tree (e.g. the PAYG
|
||||
// UpgradeModal, portal'd to document.body) announce themselves here so we
|
||||
// can hide — not unmount — while they're up. Unmounting would kill the
|
||||
// overlay itself since it's our descendant; hiding keeps all section state
|
||||
// (active tab, scroll, wallet data) intact for when the overlay closes.
|
||||
const [overlayActive, setOverlayActive] = useState(false);
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as
|
||||
| { open?: boolean }
|
||||
| undefined;
|
||||
setOverlayActive(Boolean(detail?.open));
|
||||
};
|
||||
window.addEventListener("appConfig:overlay", handler as EventListener);
|
||||
return () =>
|
||||
window.removeEventListener("appConfig:overlay", handler as EventListener);
|
||||
}, []);
|
||||
|
||||
// When the modal opens to Plan, proactively refresh credits and log values
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
@@ -106,7 +124,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, {
|
||||
@@ -139,7 +159,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
size={isMobile ? "100%" : 1200}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
@@ -147,6 +167,17 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
// Hidden (not closed) while a child overlay like the PAYG UpgradeModal
|
||||
// is up — see the appConfig:overlay listener above. The focus trap and
|
||||
// escape/outside-close must release too: the trap would steal focus
|
||||
// from the Stripe card iframe, and Escape would close US underneath
|
||||
// the overlay — unmounting the checkout mid-payment.
|
||||
styles={{
|
||||
root: { display: overlayActive ? "none" : undefined },
|
||||
}}
|
||||
trapFocus={!overlayActive}
|
||||
closeOnEscape={!overlayActive}
|
||||
closeOnClickOutside={!overlayActive}
|
||||
>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
|
||||
@@ -42,10 +42,7 @@ export function ManageBillingButton({
|
||||
url: string;
|
||||
error?: string;
|
||||
}>("manage-billing", {
|
||||
body: {
|
||||
name: "Functions",
|
||||
return_url: returnUrl,
|
||||
},
|
||||
body: { return_url: returnUrl },
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data || "error" in data)
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* ── Plan header: free-vs-metered split (shared by free + subscribed) ──── */
|
||||
.payg-planhead {
|
||||
padding: 14px 20px;
|
||||
border-radius: var(--payg-radius);
|
||||
background: var(--payg-card-bg);
|
||||
border: 1px solid var(--payg-card-border);
|
||||
}
|
||||
.payg-planhead__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.payg-planhead__eyebrow {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.payg-planhead__split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
}
|
||||
.payg-planhead__col {
|
||||
padding-right: 22px;
|
||||
}
|
||||
.payg-planhead__col--meter {
|
||||
padding-right: 0;
|
||||
padding-left: 22px;
|
||||
border-left: 1px solid var(--payg-divider);
|
||||
}
|
||||
.payg-planhead__lbl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.payg-planhead__lbl--free {
|
||||
color: #10b981;
|
||||
}
|
||||
.payg-planhead__lbl--meter {
|
||||
color: var(--payg-accent);
|
||||
}
|
||||
.payg-planhead__lbl-icon {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
.payg-planhead__title {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.payg-planhead__body {
|
||||
margin: 5px 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.payg-planhead__split {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.payg-planhead__col {
|
||||
padding-right: 0;
|
||||
}
|
||||
.payg-planhead__col--meter {
|
||||
padding-left: 0;
|
||||
padding-top: 16px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--payg-divider);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Generic card ───────────────────────────────────────────────────── */
|
||||
.payg-card {
|
||||
background: var(--payg-card-bg);
|
||||
border: 1px solid var(--payg-card-border);
|
||||
border-radius: var(--payg-radius);
|
||||
padding: 16px 20px;
|
||||
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: 18px 22px;
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── "What counts as a document?" expandable help ───────────────────── */
|
||||
.payg-help {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.payg-help__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.payg-help__toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.payg-help__chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.payg-help__toggle[aria-expanded="true"] .payg-help__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.payg-help__panel {
|
||||
margin-top: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--payg-card-border);
|
||||
background: var(--bg-muted);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.payg-help__panel ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
/**
|
||||
* Pay-as-you-go billing & usage section — the SUBSCRIBED leader/member views.
|
||||
*
|
||||
* All data comes from the real {@code Wallet} snapshot ({@code GET
|
||||
* /api/v1/payg/wallet} via {@link useWallet}); there is no mock fallback. What
|
||||
* the wallet doesn't provide, the UI doesn't show:
|
||||
*
|
||||
* - spend + cap render in the units/USD the backend actually returns
|
||||
* ({@code spendUnitsThisPeriod}, {@code capUsd}, {@code noCap})
|
||||
* - the per-category breakdown comes from {@code categoryBreakdown}
|
||||
* (wallet_category_summary view)
|
||||
* - money-equivalent display and the units↔money cap preview need
|
||||
* stripe.prices via Sync Engine (design §13 / PR-C2) and are deliberately
|
||||
* absent until that ships
|
||||
* - the activity feed renders {@code wallet.recent}, which is {@code []} in
|
||||
* V1 — so it shows a real empty state, not fabricated rows
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useRenderCount } from "@app/hooks/useRenderCount";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import LockIcon from "@mui/icons-material/LockOutlined";
|
||||
import CheckIcon from "@mui/icons-material/CheckRounded";
|
||||
import HelpOutlineIcon from "@mui/icons-material/HelpOutlineRounded";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMoreRounded";
|
||||
import BoltIcon from "@mui/icons-material/BoltRounded";
|
||||
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
|
||||
import { alert as showToast } from "@app/components/toast";
|
||||
// Relative (not @app/*) so the co-located CSS + sibling component resolve directly.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./Payg.css";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import SpendCapControl from "./SpendCapControl";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Wallet } from "@app/hooks/useWallet";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
type Gate = "OFFSITE_PROCESSING" | "AUTOMATION" | "AI_SUPPORT" | "CLIENT_SIDE";
|
||||
|
||||
interface PaygProps {
|
||||
role: "LEADER" | "MEMBER";
|
||||
/** Real wallet snapshot from {@code useWallet}. Single source of truth. */
|
||||
wallet: Wallet;
|
||||
/**
|
||||
* Persist a cap change. Provided by {@code Plan} → {@code useWallet} for the
|
||||
* leader view; absent on the member view (read-only).
|
||||
*/
|
||||
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
|
||||
/**
|
||||
* Open the Stripe Customer Portal. When omitted the Stripe card is hidden.
|
||||
* On error the implementation shows a friendly toast and resolves — callers
|
||||
* don't need to wrap in try/catch.
|
||||
*/
|
||||
onOpenPortal?: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Stable-ish avatar colour from a string.
|
||||
const AVATAR_COLORS = [
|
||||
"#0a8bff",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
"#10b981",
|
||||
"#f59e0b",
|
||||
"#06b6d4",
|
||||
];
|
||||
function avatarColor(seed: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
|
||||
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
function gateLabel(
|
||||
g: Gate,
|
||||
t: (k: string, fallback: string) => string,
|
||||
): string {
|
||||
switch (g) {
|
||||
case "OFFSITE_PROCESSING":
|
||||
return t(
|
||||
"payg.gates.offsite",
|
||||
"Server tools (compress, OCR, convert, watermark…)",
|
||||
);
|
||||
case "AUTOMATION":
|
||||
return t("payg.gates.automation", "Automations & pipelines");
|
||||
case "AI_SUPPORT":
|
||||
return t("payg.gates.ai", "AI tools (AI Create, suggestions, AI-OCR)");
|
||||
case "CLIENT_SIDE":
|
||||
return t(
|
||||
"payg.gates.client",
|
||||
"Browser-only tools (viewer, page editor, file management)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── "What counts as a document?" help ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Expandable explainer for the billing unit. Shared by the subscribed hero
|
||||
* here and the free-tier hero in {@code PaygFree.tsx}. The bullets state the
|
||||
* real charge mechanics (DefaultDocumentClassifier + JobChargeService)
|
||||
* without hardcoding the policy-tunable thresholds: each non-empty file is
|
||||
* at least one document; page count / file size can make it more; chained
|
||||
* steps on the same file join the open process instead of re-charging; a
|
||||
* first-step failure writes a compensating refund.
|
||||
*/
|
||||
export function DocHelp() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="payg-help">
|
||||
<button
|
||||
type="button"
|
||||
className="payg-help__toggle"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<HelpOutlineIcon sx={{ fontSize: 15 }} />
|
||||
{t("payg.docHelp.toggle", "What counts as a PDF?")}
|
||||
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="payg-help__panel">
|
||||
<ul>
|
||||
<li>
|
||||
{t(
|
||||
"payg.docHelp.billable",
|
||||
"Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"payg.docHelp.perFile",
|
||||
"Each file you process counts as one PDF. Very long or very large files can count as more than one.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"payg.docHelp.chains",
|
||||
"Running the same file through several steps of one automation counts it once, not once per step.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"payg.docHelp.refunds",
|
||||
"If a job fails on its first step, the PDF is credited back automatically.",
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hero usage panel ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format minor units of an ISO currency for display ("$2.24", "£0.40"). Only
|
||||
* called when the backend resolved the rate — currency is always present
|
||||
* alongside a non-null money amount.
|
||||
*/
|
||||
function formatMinor(minor: number, currency: string | null): string {
|
||||
const code = (currency ?? "usd").toUpperCase();
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
}).format(minor / 100);
|
||||
} catch {
|
||||
return `${(minor / 100).toFixed(2)} ${code}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Currency symbol for compact inline use; falls back to the ISO code. */
|
||||
function currencySymbol(currency: string | null): string {
|
||||
switch ((currency ?? "").toLowerCase()) {
|
||||
case "usd":
|
||||
return "$";
|
||||
case "eur":
|
||||
return "€";
|
||||
case "gbp":
|
||||
return "£";
|
||||
default:
|
||||
return currency ? currency.toUpperCase() + " " : "$";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage hero — gradient panel with the allowance bar. Every number is real:
|
||||
* {@code billableUsed} is the ledger's period sum over the team's actual
|
||||
* billing window (the Stripe subscription period); {@code billableLimit} is
|
||||
* the backend-derived document ceiling (free allowance + what the money cap
|
||||
* buys at the subscription Price's per-document rate, null when uncapped);
|
||||
* {@code estimatedBillMinor} is spend beyond the allowance at that rate.
|
||||
* Fields the backend couldn't resolve are null and simply not rendered.
|
||||
*/
|
||||
function UsageHero({ wallet }: { wallet: Wallet }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const periodEnd = new Date(wallet.billingPeriodEnd);
|
||||
const daysLeft = Math.max(
|
||||
0,
|
||||
Math.ceil((periodEnd.getTime() - Date.now()) / 86_400_000),
|
||||
);
|
||||
const hasCap = !wallet.noCap && wallet.capUsd != null;
|
||||
const breakdown = wallet.categoryBreakdown;
|
||||
|
||||
const limit = wallet.billableLimit;
|
||||
const hasLimit = limit != null && limit > 0;
|
||||
const pct = hasLimit
|
||||
? Math.min(100, (wallet.billableUsed / limit) * 100)
|
||||
: 0;
|
||||
const state = hasLimit
|
||||
? pct >= 100
|
||||
? "DEGRADED"
|
||||
: pct >= 80
|
||||
? "WARNED"
|
||||
: "FULL"
|
||||
: "FULL";
|
||||
const stateLabel = {
|
||||
FULL: t("payg.state.full", "Healthy"),
|
||||
WARNED: t("payg.state.warned", "Approaching cap"),
|
||||
DEGRADED: t("payg.state.degraded", "Cap reached"),
|
||||
}[state];
|
||||
|
||||
return (
|
||||
<div className="payg-hero" data-state={state}>
|
||||
<div className="payg-hero__inner">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<div className="payg-hero__eyebrow">
|
||||
{t("payg.usage.thisPeriod", "This billing period")}
|
||||
</div>
|
||||
<div className="payg-hero__figure">
|
||||
<span className="payg-hero__spend">
|
||||
{wallet.billableUsed.toLocaleString()}
|
||||
</span>
|
||||
<span className="payg-hero__cap">
|
||||
{hasLimit
|
||||
? t(
|
||||
"payg.usage.ofLimitProcessed",
|
||||
"/ {{limit}} PDFs processed",
|
||||
{ limit: limit.toLocaleString() },
|
||||
)
|
||||
: t("payg.usage.processed", "PDFs processed")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="payg-status" data-state={state}>
|
||||
<span className="payg-status__dot" />
|
||||
{stateLabel}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{hasLimit && (
|
||||
<div className="payg-bar">
|
||||
<div
|
||||
className="payg-bar__fill"
|
||||
data-state={state}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="payg-hero__meta">
|
||||
<span>
|
||||
{t("payg.usage.firstFree", "First {{free}} free", {
|
||||
free: wallet.freeAllowance.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
{wallet.estimatedBillMinor != null && (
|
||||
<>
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>
|
||||
{t("payg.usage.estBill", "≈ {{amount}} so far this period", {
|
||||
amount: formatMinor(
|
||||
wallet.estimatedBillMinor,
|
||||
wallet.currency,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>
|
||||
{hasCap
|
||||
? t("payg.usage.capLine", "{{cap}}/mo cap", {
|
||||
cap: `${currencySymbol(wallet.currency)}${wallet.capUsd}`,
|
||||
})
|
||||
: t("payg.usage.noCap", "No monthly cap")}
|
||||
</span>
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>
|
||||
{daysLeft === 1
|
||||
? t("payg.usage.resetsTomorrow", "Resets tomorrow")
|
||||
: t("payg.usage.resetsIn", "Resets in {{days}} days", {
|
||||
days: daysLeft,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="payg-hero__meta" style={{ marginTop: 6 }}>
|
||||
<span>
|
||||
{t(
|
||||
"payg.usage.breakdown",
|
||||
"AI {{ai}} • Automation {{automation}} • API {{api}}",
|
||||
{
|
||||
ai: breakdown.ai.toLocaleString(),
|
||||
automation: breakdown.automation.toLocaleString(),
|
||||
api: breakdown.api.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DocHelp />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cap editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface CapEditorProps {
|
||||
/** Current cap in major currency units; null = no cap set. */
|
||||
capUsd: number | null;
|
||||
/** True when the leader explicitly disabled the cap. */
|
||||
noCap: boolean;
|
||||
/** Per-document rate in minor units; null when unknown — preview hides. */
|
||||
pricePerDocMinor: number | null;
|
||||
/** Currency of the rate; pairs with {@link CapEditorProps#pricePerDocMinor}. */
|
||||
currency: string | null;
|
||||
/**
|
||||
* Persist the cap change. Receives whole major units (matches the backend's
|
||||
* {@code PATCH /api/v1/payg/cap} body) or null for no-cap.
|
||||
*/
|
||||
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-row cap editor: the shared {@link SpendCapControl} (preset chips +
|
||||
* inline custom-entry pill + no-cap + inline Save) over a live "≈ N paid
|
||||
* PDFs/month" estimate, wrapped in the plan-page card chrome + the cap-reached
|
||||
* disclosure. Save-only — the working value is local, so abandoning the card
|
||||
* abandons the edit. The very same control drives the upgrade checkout flow.
|
||||
*/
|
||||
function CapEditor({
|
||||
capUsd,
|
||||
noCap,
|
||||
pricePerDocMinor,
|
||||
currency,
|
||||
onSaveCap,
|
||||
}: CapEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const savedCap = noCap || capUsd == null ? null : capUsd;
|
||||
const [working, setWorking] = useState<number | null>(savedCap);
|
||||
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<div className="payg-card__title">
|
||||
{t("payg.cap.title", "Monthly spending cap")}
|
||||
</div>
|
||||
<div className="payg-card__subtitle">
|
||||
{t(
|
||||
"payg.cap.subtitle",
|
||||
"The most your team can spend per month. Billable processing pauses at the cap and resumes next period.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SpendCapControl
|
||||
capUsd={working}
|
||||
onChange={setWorking}
|
||||
pricePerDocMinor={pricePerDocMinor}
|
||||
currency={currency}
|
||||
savedCapUsd={savedCap}
|
||||
onSave={onSaveCap}
|
||||
/>
|
||||
|
||||
<CapReachedHelp />
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapReadOnly({ capUsd, noCap }: { capUsd: number | null; noCap: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const hasCap = !noCap && capUsd != null;
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="sm">
|
||||
<div className="payg-card__title">
|
||||
{t("payg.cap.title", "Monthly spending cap")}
|
||||
</div>
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text fz={28} fw={750} lh={1}>
|
||||
{hasCap ? `$${capUsd}` : t("payg.cap.noneShort", "No cap")}
|
||||
</Text>
|
||||
{hasCap && (
|
||||
<Text c="dimmed">{t("payg.cap.perMonth", "/ month")}</Text>
|
||||
)}
|
||||
</Group>
|
||||
<div className="payg-preview" style={{ marginTop: 6 }}>
|
||||
<div>
|
||||
<div className="payg-preview__main">
|
||||
{t(
|
||||
"payg.member.askLeader",
|
||||
"Only your team owner can change the cap.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CapReachedHelp />
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Gates ────────────────────────────────────────────────────────────────
|
||||
|
||||
// What still works once the cap is hit. Everyday tools keep running; only AI
|
||||
// and automation/pipelines pause until the cap resets or is raised.
|
||||
const GATE_CAP_BEHAVIOR: { gate: Gate; staysAtCap: boolean }[] = [
|
||||
{ gate: "CLIENT_SIDE", staysAtCap: true },
|
||||
{ gate: "OFFSITE_PROCESSING", staysAtCap: true },
|
||||
{ gate: "AUTOMATION", staysAtCap: false },
|
||||
{ gate: "AI_SUPPORT", staysAtCap: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Folded-in "what happens at the cap" — previously its own GatesCard, now a
|
||||
* collapsed disclosure rendered inside the cap card(s) to save vertical space.
|
||||
* Mirrors the {@link DocHelp} toggle pattern; default-collapsed so it costs no
|
||||
* height until the user opens it.
|
||||
*/
|
||||
function CapReachedHelp() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="payg-help">
|
||||
<button
|
||||
type="button"
|
||||
className="payg-help__toggle"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<HelpOutlineIcon sx={{ fontSize: 15 }} />
|
||||
{t("payg.gates.title", "What happens when the cap is reached")}
|
||||
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="payg-help__panel">
|
||||
<div className="payg-gates">
|
||||
{GATE_CAP_BEHAVIOR.map(({ gate, staysAtCap }) => (
|
||||
<div className="payg-gate" data-enabled={staysAtCap} key={gate}>
|
||||
<span className="payg-gate__chip">
|
||||
{staysAtCap ? (
|
||||
<CheckIcon sx={{ fontSize: 18 }} />
|
||||
) : (
|
||||
<LockIcon sx={{ fontSize: 16 }} />
|
||||
)}
|
||||
</span>
|
||||
<span className="payg-gate__label">{gateLabel(gate, t)}</span>
|
||||
{!staysAtCap && (
|
||||
<span className="payg-gate__tag" data-variant="pause">
|
||||
{t("payg.gates.pauses", "pauses at cap")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Per-member usage ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Leader-only roster of each teammate's billable usage this period. Display-only — per-member
|
||||
* sub-cap enforcement isn't shipped (see the follow-ups note), so there's no cap control here.
|
||||
*/
|
||||
function MemberUsage({ members }: { members: Wallet["members"] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<div className="payg-card__title">
|
||||
{t("payg.members.title", "Team member usage")}
|
||||
</div>
|
||||
<div className="payg-card__subtitle">
|
||||
{t(
|
||||
"payg.members.subtitle",
|
||||
"Billable PDFs each teammate has processed this period.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{members.map((m) => (
|
||||
<div className="payg-member" key={m.userId}>
|
||||
<span
|
||||
className="payg-member__avatar"
|
||||
style={{ background: avatarColor(m.userId) }}
|
||||
>
|
||||
{m.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="payg-member__name">{m.name}</div>
|
||||
<div className="payg-member__email">{m.email}</div>
|
||||
</div>
|
||||
<div className="payg-member__usage">
|
||||
<div className="payg-member__usage-num">
|
||||
{m.spendUnits.toLocaleString()}{" "}
|
||||
<Text span size="xs" c="dimmed">
|
||||
{t("payg.members.docs", "PDFs")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Activity feed ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Feature flag — the activity feed is hidden until the meter-event surface is
|
||||
* built and polished (Wave 2). The backend returns {@code []} today, so an
|
||||
* unpolished "No billable activity yet" card adds nothing. Flip to {@code true}
|
||||
* once {@code wallet.recent} carries real rows. Kept as a flag (not deleted) so
|
||||
* the renderer below stays wired and ready.
|
||||
*/
|
||||
const SHOW_ACTIVITY_FEED = false;
|
||||
|
||||
/**
|
||||
* Renders {@code wallet.recent} — the backend returns {@code []} in V1 (the
|
||||
* meter-event surface ships in Wave 2), so today this shows a real empty
|
||||
* state. The row renderer is ready for when the rows arrive; fields are read
|
||||
* defensively because the activity-row shape isn't finalised yet (the Wallet
|
||||
* type carries {@code Record<string, unknown>} for the same reason).
|
||||
*/
|
||||
function ActivityFeed({ recent }: { recent: Wallet["recent"] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<div className="payg-card__title">
|
||||
{t("payg.activity.title", "Recent billable activity")}
|
||||
</div>
|
||||
<div className="payg-card__subtitle">
|
||||
{t(
|
||||
"payg.activity.subtitle",
|
||||
"Only AI and automation draw from your budget. Everyday tools are free and aren't listed here.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{recent.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"payg.activity.empty",
|
||||
"No billable activity yet this period.",
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<div>
|
||||
{recent.map((r, i) => (
|
||||
<div className="payg-activity-row" key={String(r.id ?? i)}>
|
||||
<span
|
||||
className="payg-activity__dot"
|
||||
data-kind={String(r.kind ?? "")}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="payg-activity__label">
|
||||
{String(r.label ?? "")}
|
||||
</div>
|
||||
<div className="payg-activity__ts">{String(r.ts ?? "")}</div>
|
||||
</div>
|
||||
<span className="payg-activity__kind">
|
||||
{String(r.kind ?? "")}
|
||||
</span>
|
||||
<span className="payg-activity__units">
|
||||
{String(r.docUnits ?? 0)} {t("payg.activity.docs", "docs")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stripe CTA ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise<void> }) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onOpenPortal();
|
||||
} catch (e: unknown) {
|
||||
// 503 = Supabase edge fn isn't configured (local dev without
|
||||
// PORTAL_NOT_CONFIGURED env). 404 = no Stripe customer yet (e.g. the
|
||||
// team was force-subscribed via dev hooks). Both are user-actionable
|
||||
// in roughly the same way ("try again later or contact support") so
|
||||
// we don't bother branching the copy.
|
||||
console.warn("[Payg] portal session failed", e);
|
||||
showToast({
|
||||
alertType: "warning",
|
||||
title: t(
|
||||
"payg.stripe.toast.unavailable.title",
|
||||
"Billing portal unavailable",
|
||||
),
|
||||
body: t(
|
||||
"payg.stripe.toast.unavailable.body",
|
||||
"Billing portal isn't available right now. Try again in a moment.",
|
||||
),
|
||||
location: "bottom-right",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="payg-stripe">
|
||||
<div>
|
||||
<div className="payg-stripe__title">
|
||||
{t("payg.stripe.title", "Manage billing in Stripe")}
|
||||
</div>
|
||||
<div className="payg-stripe__subtitle">
|
||||
{t(
|
||||
"payg.stripe.subtitle",
|
||||
"Receipts, invoices, payment method, billing currency.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
loading={loading}
|
||||
rightSection={<OpenInNewIcon sx={{ fontSize: 16 }} />}
|
||||
variant="light"
|
||||
>
|
||||
{t("payg.stripe.open", "Open billing portal")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────
|
||||
|
||||
const Payg: React.FC<PaygProps> = ({ role, wallet, onSaveCap, onOpenPortal }) => {
|
||||
useRenderCount(role === "LEADER" ? "PaygLeader" : "PaygMember");
|
||||
const { t } = useTranslation();
|
||||
const isLeader = role === "LEADER";
|
||||
|
||||
const fmt = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="payg">
|
||||
<Stack gap="md">
|
||||
{/* The modal chrome already renders the section title ("Billing &
|
||||
usage"), so we lead with the descriptive subtitle + role pill. */}
|
||||
<div className="payg-planhead">
|
||||
<div className="payg-planhead__top">
|
||||
<span className="payg-planhead__eyebrow">
|
||||
{t("payg.header.eyebrow", "Processor plan · {{start}} – {{end}}", {
|
||||
start: fmt(wallet.billingPeriodStart),
|
||||
end: fmt(wallet.billingPeriodEnd),
|
||||
})}
|
||||
</span>
|
||||
<span className="payg-role-pill" data-leader={isLeader}>
|
||||
{isLeader
|
||||
? t("payg.role.leader", "Team owner")
|
||||
: t("payg.role.member", "Member")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="payg-planhead__split">
|
||||
<div className="payg-planhead__col">
|
||||
<div className="payg-planhead__lbl payg-planhead__lbl--free">
|
||||
<AllInclusiveIcon
|
||||
className="payg-planhead__lbl-icon"
|
||||
fontSize="small"
|
||||
/>
|
||||
{t("payg.header.freeLabel", "Always free")}
|
||||
</div>
|
||||
<p className="payg-planhead__title">
|
||||
{t("payg.header.freeTitle", "Unlimited PDF editing")}
|
||||
</p>
|
||||
<p className="payg-planhead__body">
|
||||
{t(
|
||||
"payg.header.freeBody",
|
||||
"View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="payg-planhead__col payg-planhead__col--meter">
|
||||
<div className="payg-planhead__lbl payg-planhead__lbl--meter">
|
||||
<BoltIcon className="payg-planhead__lbl-icon" fontSize="small" />
|
||||
{t("payg.header.meterLabel", "Metered")}
|
||||
</div>
|
||||
<p className="payg-planhead__title">
|
||||
{t("payg.header.meterTitle", "Automation · AI · API")}
|
||||
</p>
|
||||
<p className="payg-planhead__body">
|
||||
{t(
|
||||
"payg.header.meterBody",
|
||||
"{{limit}} free PDFs to start, then billed per PDF up to your cap.",
|
||||
{ limit: wallet.freeAllowance.toLocaleString() },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsageHero wallet={wallet} />
|
||||
|
||||
{isLeader ? (
|
||||
<CapEditor
|
||||
capUsd={wallet.capUsd}
|
||||
noCap={wallet.noCap}
|
||||
pricePerDocMinor={wallet.pricePerDocMinor}
|
||||
currency={wallet.currency}
|
||||
onSaveCap={onSaveCap}
|
||||
/>
|
||||
) : (
|
||||
<CapReadOnly capUsd={wallet.capUsd} noCap={wallet.noCap} />
|
||||
)}
|
||||
|
||||
{isLeader && wallet.members.length > 0 && (
|
||||
<MemberUsage members={wallet.members} />
|
||||
)}
|
||||
|
||||
{SHOW_ACTIVITY_FEED && <ActivityFeed recent={wallet.recent} />}
|
||||
|
||||
{isLeader && onOpenPortal && (
|
||||
<StripePortalLink onOpenPortal={onOpenPortal} />
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Payg;
|
||||
|
||||
// Convenience exports for the config nav to render either variant directly.
|
||||
export interface PaygLeaderProps {
|
||||
/** See {@link PaygProps#wallet}. */
|
||||
wallet: Wallet;
|
||||
/** See {@link PaygProps#onSaveCap}. */
|
||||
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
|
||||
/** See {@link PaygProps#onOpenPortal}. */
|
||||
onOpenPortal?: () => Promise<void>;
|
||||
}
|
||||
export const PaygLeader: React.FC<PaygLeaderProps> = ({
|
||||
wallet,
|
||||
onSaveCap,
|
||||
onOpenPortal,
|
||||
}) => (
|
||||
<Payg
|
||||
role="LEADER"
|
||||
wallet={wallet}
|
||||
onSaveCap={onSaveCap}
|
||||
onOpenPortal={onOpenPortal}
|
||||
/>
|
||||
);
|
||||
export const PaygMember: React.FC<{ wallet: Wallet }> = ({ wallet }) => (
|
||||
<Payg role="MEMBER" wallet={wallet} />
|
||||
);
|
||||
@@ -0,0 +1,322 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* ── Editor plan card (always-free tools only, no billing window) ─────── */
|
||||
/* Reuses .payg-planhead chrome; the eyebrow sits in the flex top row so its
|
||||
default 8px bottom margin would misalign it against the role pill. */
|
||||
.paygf-editorcard__eyebrow {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Processor plan card: two-column (pitch + benefits | meter + CTA) ──── */
|
||||
.paygf-proc {
|
||||
gap: 14px;
|
||||
}
|
||||
.paygf-proc__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--payg-accent);
|
||||
}
|
||||
.paygf-proc__split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.paygf-proc__pitch {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.paygf-proc__pitch .paygf-cta__subtitle {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* Force the benefits into a single column inside the narrower left column. */
|
||||
.paygf-proc__benefits {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.paygf-proc__aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--payg-divider);
|
||||
}
|
||||
.paygf-proc__cta {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.paygf-proc__reassure {
|
||||
text-align: center;
|
||||
}
|
||||
.paygf-proc__membernote {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.paygf-proc__membernote-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem !important;
|
||||
margin-top: 1px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.paygf-proc__split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.paygf-proc__aside {
|
||||
padding-left: 0;
|
||||
padding-top: 16px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--payg-divider);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Compact one-time free meter (inside the Processor aside) ─────────── */
|
||||
.paygf-meter {
|
||||
padding: 14px 16px;
|
||||
border-radius: 11px;
|
||||
background: var(--payg-inset-bg);
|
||||
border: 1px solid var(--payg-card-border);
|
||||
}
|
||||
.paygf-meter__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.paygf-meter__figure {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
}
|
||||
.paygf-meter__num {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.paygf-meter__cap {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.paygf-meter .payg-bar {
|
||||
margin-top: 11px;
|
||||
}
|
||||
.paygf-meter__meta {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Leader CTA card ──────────────────────────────────────────────────── */
|
||||
|
||||
.paygf-cta {
|
||||
padding: 18px 22px;
|
||||
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 {
|
||||
padding: 18px 22px;
|
||||
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,374 @@
|
||||
/**
|
||||
* 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 one-time free grant (default 500) applies
|
||||
* <em>only</em> to the three billable categories — it is a lifetime allowance,
|
||||
* not a monthly one, and a team keeps any unused portion after subscribing.
|
||||
*
|
||||
* <p>Layout: a slim <b>Editor plan</b> card (always-free tools only — no dates,
|
||||
* no metered split) on top, then a single <b>Processor plan</b> card that
|
||||
* two-columns the upgrade pitch + benefits (left) against the one-time free
|
||||
* meter stacked over the call-to-action (right).
|
||||
*
|
||||
* <p>Two variants:
|
||||
* - {@link PaygFreeLeader} — the right column's CTA opens the upgrade modal.
|
||||
* - {@link PaygFreeMember} — read-only; the CTA is replaced with an
|
||||
* ask-the-owner note.
|
||||
*/
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import BoltIcon from "@mui/icons-material/BoltRounded";
|
||||
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
|
||||
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";
|
||||
import { useWallet } from "@app/hooks/useWallet";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./Payg.css";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./PaygFree.css";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import UpgradeModal from "./UpgradeModal";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { DocHelp } from "./Payg";
|
||||
|
||||
// ─── Shared free-tier snapshot ────────────────────────────
|
||||
|
||||
interface FreeSnapshot {
|
||||
/** One-time free documents used so far (grant − remaining). */
|
||||
billableUsed: number;
|
||||
/**
|
||||
* The team's one-time free grant size in documents. Real value from the
|
||||
* wallet endpoint (pricing_policy.free_tier_units); 500 below is only the
|
||||
* pre-load placeholder for the first paint.
|
||||
*/
|
||||
billableLimit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read free-tier snapshot from the real {@link useWallet} hook. Falls back to
|
||||
* a zeroed view if the wallet hasn't loaded yet — this only happens briefly on
|
||||
* first paint; once the snapshot arrives the component re-renders with real
|
||||
* numbers. Earlier versions returned a mock "62 of 500" sentinel which leaked
|
||||
* into the rendered UI and made the page look like nothing was wired up.
|
||||
*/
|
||||
function useFreeSnapshot(): FreeSnapshot {
|
||||
const { wallet } = useWallet();
|
||||
return useMemo(() => {
|
||||
if (wallet) {
|
||||
return {
|
||||
// Used = grant − remaining, derived straight from the one-time grant so
|
||||
// the free view never depends on the per-state meaning of billableUsed.
|
||||
billableUsed: Math.max(0, wallet.freeAllowance - wallet.freeRemaining),
|
||||
// The free view's ceiling IS the one-time grant size.
|
||||
billableLimit: wallet.freeAllowance,
|
||||
};
|
||||
}
|
||||
return { billableUsed: 0, billableLimit: 500 };
|
||||
}, [wallet]);
|
||||
}
|
||||
|
||||
type MeterState = "FULL" | "WARNED" | "DEGRADED";
|
||||
|
||||
/** Warn/degrade band for the one-time grant meter (mirrors the BE thresholds). */
|
||||
function meterState(used: number, limit: number): { state: MeterState; pct: number } {
|
||||
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100;
|
||||
const state: MeterState =
|
||||
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
|
||||
return { state, pct };
|
||||
}
|
||||
|
||||
// ─── Editor plan card (always-free tools only) ────────────────────────────
|
||||
|
||||
interface EditorPlanCardProps {
|
||||
/** Role pill text on the right. */
|
||||
pill: string;
|
||||
/** LEADER pill colour treatment. */
|
||||
leader?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The top card: the free Editor plan. Manual tools only, no billing window —
|
||||
* the one-time grant lives in the Processor card below, so there's no period
|
||||
* to show here.
|
||||
*/
|
||||
function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="payg-planhead paygf-editorcard">
|
||||
<div className="payg-planhead__top">
|
||||
<span className="payg-planhead__lbl payg-planhead__lbl--free paygf-editorcard__eyebrow">
|
||||
<AllInclusiveIcon
|
||||
className="payg-planhead__lbl-icon"
|
||||
fontSize="small"
|
||||
/>
|
||||
{t("payg.free.editor.eyebrow", "Editor plan · Always free")}
|
||||
</span>
|
||||
<span className="payg-role-pill" data-leader={leader ? "true" : "false"}>
|
||||
{pill}
|
||||
</span>
|
||||
</div>
|
||||
<p className="payg-planhead__title">
|
||||
{t("payg.free.header.freeTitle", "Unlimited PDF editing")}
|
||||
</p>
|
||||
<p className="payg-planhead__body">
|
||||
{t(
|
||||
"payg.free.header.freeBody",
|
||||
"View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Compact one-time free meter (right column of the Processor card) ──────
|
||||
|
||||
function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
|
||||
const stateLabel =
|
||||
state === "DEGRADED"
|
||||
? t("payg.free.state.limitReached", "Limit reached")
|
||||
: state === "WARNED"
|
||||
? t("payg.free.state.approachingLimit", "Approaching limit")
|
||||
: t("payg.free.state.plentyLeft", "Plenty left");
|
||||
|
||||
return (
|
||||
<div className="paygf-meter" data-state={state}>
|
||||
<div className="paygf-meter__top">
|
||||
<div className="paygf-meter__figure">
|
||||
<span className="paygf-meter__num">
|
||||
{snap.billableUsed.toLocaleString()}
|
||||
</span>
|
||||
<span className="paygf-meter__cap">
|
||||
{t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
|
||||
limit: snap.billableLimit.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span className="payg-status" data-state={state}>
|
||||
<span className="payg-status__dot" />
|
||||
{stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="payg-bar">
|
||||
<div
|
||||
className="payg-bar__fill"
|
||||
data-state={state}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="paygf-meter__meta">
|
||||
<span>
|
||||
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
|
||||
</span>
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>{t("payg.free.hero.neverResets", "One-time — never resets")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Processor plan card (two-column: pitch + benefits | meter + CTA) ──────
|
||||
|
||||
interface ProcessorCardProps {
|
||||
snap: FreeSnapshot;
|
||||
/** Leaders get the live CTA; members get the ask-owner note. */
|
||||
isLeader: boolean;
|
||||
/** Opens the upgrade modal — leader only. */
|
||||
onTurnOn?: () => void;
|
||||
}
|
||||
|
||||
function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="paygf-cta paygf-proc">
|
||||
<span className="paygf-proc__eyebrow">
|
||||
<BoltIcon className="payg-planhead__lbl-icon" fontSize="small" />
|
||||
{t("payg.free.proc.eyebrow", "Processor plan · metered")}
|
||||
</span>
|
||||
|
||||
<div className="paygf-proc__split">
|
||||
<div className="paygf-proc__pitch">
|
||||
<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",
|
||||
"Keep going past your {{limit}} free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control.",
|
||||
{ limit: snap.billableLimit.toLocaleString() },
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ul className="paygf-cta__benefits paygf-proc__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>
|
||||
|
||||
<DocHelp />
|
||||
</div>
|
||||
|
||||
<div className="paygf-proc__aside">
|
||||
<FreeMeterPanel snap={snap} />
|
||||
{isLeader ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="paygf-cta__button paygf-proc__cta"
|
||||
onClick={onTurnOn}
|
||||
data-testid="turn-on-processor"
|
||||
>
|
||||
{t("payg.free.cta.button", "Turn on Processor →")}
|
||||
</button>
|
||||
<span className="paygf-cta__reassurance paygf-proc__reassure">
|
||||
{t(
|
||||
"payg.free.cta.reassurance",
|
||||
"No minimum · Set a $0 cap to test · Cancel anytime",
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="paygf-proc__membernote">
|
||||
<LockIcon
|
||||
className="paygf-proc__membernote-icon"
|
||||
fontSize="small"
|
||||
/>
|
||||
<span>
|
||||
{t(
|
||||
"payg.free.member.ownerOnly",
|
||||
"Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Free LEADER ──────────────────────────────────────────────────────────
|
||||
|
||||
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 = useFreeSnapshot();
|
||||
const { wallet } = useWallet();
|
||||
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="payg">
|
||||
<Stack gap="md">
|
||||
<EditorPlanCard pill={t("payg.role.leader", "Team owner")} leader />
|
||||
<ProcessorCard
|
||||
snap={snap}
|
||||
isLeader
|
||||
onTurnOn={() => setUpgradeOpen(true)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{wallet?.teamId != null && (
|
||||
<UpgradeModal
|
||||
open={upgradeOpen}
|
||||
teamId={wallet.teamId}
|
||||
freeLimit={snap.billableLimit}
|
||||
pricePerDocMinor={wallet.pricePerDocMinor}
|
||||
rateCurrency={wallet.currency}
|
||||
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.
|
||||
alert(
|
||||
`Demo: subscription complete. Cap = ${
|
||||
capUsd === null ? "no cap" : `$${capUsd}/mo`
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Free MEMBER ──────────────────────────────────────────────────────────
|
||||
|
||||
function PaygFreeMemberInner() {
|
||||
useRenderCount("PaygFreeMember");
|
||||
const { t } = useTranslation();
|
||||
const snap = useFreeSnapshot();
|
||||
|
||||
return (
|
||||
<div className="payg">
|
||||
<Stack gap="md">
|
||||
<EditorPlanCard pill={t("payg.role.member", "Member")} />
|
||||
<ProcessorCard snap={snap} isLeader={false} />
|
||||
</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,245 +1,91 @@
|
||||
import React, { lazy, Suspense, useState, useCallback, useEffect } from "react";
|
||||
import { Divider, Loader, Alert, Select, Group, Text } from "@mantine/core";
|
||||
import { usePlans, PlanTier } from "@app/hooks/usePlans";
|
||||
import type {
|
||||
PurchaseType,
|
||||
CreditsPack,
|
||||
PlanID,
|
||||
} from "@app/components/shared/StripeCheckoutSaas";
|
||||
|
||||
const StripeCheckout = lazy(
|
||||
() => import("@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, 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:[email protected]?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
|
||||
wallet={wallet}
|
||||
onSaveCap={updateCap}
|
||||
onOpenPortal={openPortal}
|
||||
/>
|
||||
) : (
|
||||
<PaygMember wallet={wallet} />
|
||||
);
|
||||
}
|
||||
|
||||
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 */}
|
||||
{checkoutOpen &&
|
||||
(selectedPlan !== null || selectedCreditsPack !== null) && (
|
||||
<Suspense fallback={null}>
|
||||
<StripeCheckout
|
||||
opened={true}
|
||||
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"
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// Free tier — only the leader sees the upgrade CTA.
|
||||
if (wallet.role === "leader") {
|
||||
return <PaygFreeLeader onUpgraded={onUpgraded} />;
|
||||
}
|
||||
return <PaygFreeMember />;
|
||||
};
|
||||
|
||||
export default Plan;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/* Reusable monthly spend-cap control — preset chips + an inline custom-entry
|
||||
pill + a no-cap chip, with a live "≈ N PDFs/month" estimate beneath. Shared
|
||||
by the subscribed plan-page cap editor (Payg.tsx) and the upgrade checkout
|
||||
flow (UpgradeModal.tsx).
|
||||
|
||||
Self-contained tokens so the control looks right whether it's dropped into
|
||||
the Plan card (light/dark modal content) or the dark upgrade modal. We lean
|
||||
on the app's semantic theme tokens and override per scheme, mirroring the
|
||||
Payg.css / UpgradeModal.css treatments the control sits beside. */
|
||||
|
||||
.scc {
|
||||
--scc-accent: #0a8bff;
|
||||
--scc-accent-text: #0a8bff;
|
||||
--scc-accent-soft: rgba(10, 139, 255, 0.12);
|
||||
--scc-accent-border: rgba(10, 139, 255, 0.25);
|
||||
--scc-chip-bg: var(--bg-muted);
|
||||
--scc-chip-border: var(--border-default);
|
||||
/* Consistent vertical rhythm between the control row, the estimate, and any
|
||||
note — without it the blocks sit flush. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .scc {
|
||||
--scc-accent-text: #66b8ff;
|
||||
--scc-accent-soft: rgba(10, 139, 255, 0.16);
|
||||
--scc-chip-bg: #272d35;
|
||||
--scc-chip-border: #3d444e;
|
||||
}
|
||||
|
||||
/* ── Inline row: presets · custom · no-cap · (save) ──────────────────── */
|
||||
.scc-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Shared pill shape for presets, the custom-entry pill, and no-cap. Keeping a
|
||||
single height/radius/border is what makes the custom field read as "one more
|
||||
button" rather than a form input bolted on. */
|
||||
.scc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--scc-chip-border);
|
||||
background: var(--scc-chip-bg);
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.scc-chip:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.scc-chip[data-selected="true"] {
|
||||
background: var(--scc-accent-soft);
|
||||
border-color: var(--scc-accent);
|
||||
color: var(--scc-accent-text);
|
||||
}
|
||||
.scc-chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Custom-entry pill — Option A: a dashed pill that matches the presets and
|
||||
reads as "or type your own", filling solid like a selected chip once it
|
||||
carries a value. */
|
||||
.scc-custom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed var(--scc-chip-border);
|
||||
background: transparent;
|
||||
cursor: text;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.scc-custom:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.scc-custom[data-active="true"] {
|
||||
border-style: solid;
|
||||
border-color: var(--scc-accent);
|
||||
background: var(--scc-accent-soft);
|
||||
}
|
||||
.scc-custom__symbol {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.scc-custom[data-active="true"] .scc-custom__symbol,
|
||||
.scc-custom[data-active="true"] .scc-custom__input {
|
||||
color: var(--scc-accent-text);
|
||||
}
|
||||
.scc-custom__input {
|
||||
width: 70px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.scc-custom__input::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.scc-custom:disabled,
|
||||
.scc-custom[data-disabled="true"] {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.scc-row__spacer {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Live PDF estimate ───────────────────────────────────────────────── */
|
||||
.scc-estimate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--scc-accent-soft);
|
||||
border: 1px solid var(--scc-accent-border);
|
||||
}
|
||||
.scc-estimate__icon {
|
||||
color: var(--scc-accent-text);
|
||||
display: flex;
|
||||
}
|
||||
.scc-estimate__main {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 550;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.scc-estimate__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Quiet helper line (e.g. "Shown in USD — change later in your currency"). */
|
||||
.scc-note {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Reusable monthly spend-cap control.
|
||||
*
|
||||
* One inline row — preset chips, a custom-entry pill that matches the presets,
|
||||
* a "No cap" chip, and (optionally) a Save button — over a live "≈ N PDFs /
|
||||
* month" estimate. Extracted from the subscribed plan-page cap editor so the
|
||||
* exact same control drives the upgrade checkout flow.
|
||||
*
|
||||
* <h2>Currency-agnostic by design</h2>
|
||||
*
|
||||
* The control never decides a currency. It takes {@code pricePerDocMinor} +
|
||||
* {@code currency} and renders whatever it's handed: the subscribed plan page
|
||||
* passes the team's real Stripe-subscription rate/currency; the unsubscribed
|
||||
* checkout flow passes a USD rate (Stripe hasn't assigned the team a currency
|
||||
* yet) plus a {@code note} explaining the cap is editable later. When no rate
|
||||
* is supplied the estimate simply hides.
|
||||
*
|
||||
* <h2>Controlled</h2>
|
||||
*
|
||||
* Fully controlled via {@code capUsd} ({@code null} = no cap, {@code 0} = a
|
||||
* real $0 cap that keeps everything free) + {@code onChange}. The parent owns
|
||||
* the working value. When {@code onSave} is provided the control renders the
|
||||
* inline Save button and computes "dirty" against {@code savedCapUsd}.
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./SpendCapControl.css";
|
||||
|
||||
// Quick amounts offered everywhere — recognition over recall.
|
||||
export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const;
|
||||
|
||||
export interface SpendCapControlProps {
|
||||
/** Current cap in major currency units; {@code null} = no cap. Controlled. */
|
||||
capUsd: number | null;
|
||||
/** Working-value setter. {@code null} signals no-cap. */
|
||||
onChange: (capUsd: number | null) => void;
|
||||
/** Per-document rate in minor units; null/0 hides the estimate. May be fractional. */
|
||||
pricePerDocMinor?: number | null;
|
||||
/** Lower-case ISO currency of the rate; pairs with {@link #pricePerDocMinor}. */
|
||||
currency?: string | null;
|
||||
/** Quick-amount presets (major units). Defaults to {@link DEFAULT_CAP_PRESETS}. */
|
||||
presets?: readonly number[];
|
||||
/**
|
||||
* When provided, the control renders an inline Save button. Receives whole
|
||||
* major units, or {@code null} for no-cap.
|
||||
*/
|
||||
onSave?: (capUsd: number | null) => Promise<void> | void;
|
||||
/** Label for the Save button. */
|
||||
saveLabel?: string;
|
||||
/**
|
||||
* The persisted value to diff against for the dirty check. Same encoding as
|
||||
* {@link #capUsd} ({@code null} = persisted no-cap). Only used with
|
||||
* {@link #onSave}.
|
||||
*/
|
||||
savedCapUsd?: number | null;
|
||||
/** Quiet helper line under the estimate (e.g. the USD / editable-later note). */
|
||||
note?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Format minor units of an ISO currency ("$2.24", "£0.40"). */
|
||||
function formatMinor(minor: number, currency: string | null | undefined): string {
|
||||
const code = (currency ?? "usd").toUpperCase();
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
// Per-doc rates are often sub-cent (e.g. $0.02 → 2 minor, but a half-cent
|
||||
// rate is 0.5). Allow up to 3 fraction digits so they don't round to $0.
|
||||
maximumFractionDigits: 3,
|
||||
}).format(minor / 100);
|
||||
} catch {
|
||||
return `${(minor / 100).toFixed(2)} ${code}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Currency symbol for compact inline use; falls back to the ISO code. */
|
||||
function currencySymbol(currency: string | null | undefined): string {
|
||||
switch ((currency ?? "").toLowerCase()) {
|
||||
case "usd":
|
||||
case "":
|
||||
return "$";
|
||||
case "eur":
|
||||
return "€";
|
||||
case "gbp":
|
||||
return "£";
|
||||
default:
|
||||
return currency!.toUpperCase() + " ";
|
||||
}
|
||||
}
|
||||
|
||||
const SpendCapControl: React.FC<SpendCapControlProps> = ({
|
||||
capUsd,
|
||||
onChange,
|
||||
pricePerDocMinor,
|
||||
currency,
|
||||
presets = DEFAULT_CAP_PRESETS,
|
||||
onSave,
|
||||
saveLabel,
|
||||
savedCapUsd,
|
||||
note,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const sym = currencySymbol(currency);
|
||||
const isNoCap = capUsd === null;
|
||||
const presetSelected = capUsd != null && presets.includes(capUsd);
|
||||
// Custom is "active" when a cap is set that isn't one of the presets — i.e.
|
||||
// the value came from the custom pill.
|
||||
const customActive = capUsd != null && !presets.includes(capUsd);
|
||||
|
||||
// Local mirror of the custom field's text so partial/empty entry doesn't get
|
||||
// clobbered by the controlled value. Seeded from a non-preset incoming cap.
|
||||
const [customText, setCustomText] = useState<string>(
|
||||
customActive ? String(capUsd) : "",
|
||||
);
|
||||
|
||||
// Mirror of the backend's docCapForMoney: floor(capMinor / rate). The
|
||||
// one-time free grant is a separate lifetime pool and is NOT added here —
|
||||
// this is the paid PDFs the monthly cap buys.
|
||||
const rate =
|
||||
pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null;
|
||||
const previewDocs =
|
||||
capUsd != null && rate != null ? Math.floor((capUsd * 100) / rate) : null;
|
||||
|
||||
const dirty = onSave != null && capUsd !== (savedCapUsd ?? null);
|
||||
|
||||
const selectPreset = (preset: number) => {
|
||||
setCustomText("");
|
||||
onChange(preset);
|
||||
};
|
||||
const selectNoCap = () => {
|
||||
setCustomText("");
|
||||
onChange(null);
|
||||
};
|
||||
const onCustomInput = (raw: string) => {
|
||||
// Digits only; an empty field reads as "no custom value yet" → 0 so the
|
||||
// estimate still renders sensibly without flipping to no-cap.
|
||||
const cleaned = raw.replace(/[^0-9]/g, "");
|
||||
setCustomText(cleaned);
|
||||
const v = cleaned === "" ? 0 : parseInt(cleaned, 10);
|
||||
onChange(Number.isNaN(v) ? 0 : v);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(isNoCap ? null : Math.round(capUsd ?? 0));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scc">
|
||||
<div className="scc-row">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
className="scc-chip"
|
||||
data-selected={presetSelected && capUsd === preset}
|
||||
onClick={() => selectPreset(preset)}
|
||||
>
|
||||
{sym}
|
||||
{preset.toLocaleString()}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Custom-entry pill — dashed until it carries a value, then it fills
|
||||
like a selected chip. */}
|
||||
<label className="scc-custom" data-active={customActive}>
|
||||
<span className="scc-custom__symbol">{sym}</span>
|
||||
<input
|
||||
className="scc-custom__input"
|
||||
inputMode="numeric"
|
||||
value={customActive ? customText : ""}
|
||||
placeholder={t("payg.cap.custom", "Custom")}
|
||||
aria-label={t("payg.cap.amount", "Cap amount")}
|
||||
onChange={(e) => onCustomInput(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`scc-chip${onSave ? "" : " scc-row__spacer"}`}
|
||||
data-selected={isNoCap}
|
||||
onClick={selectNoCap}
|
||||
>
|
||||
{t("payg.cap.noCapLabel", "No cap")}
|
||||
</button>
|
||||
|
||||
{onSave && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
className="scc-row__spacer"
|
||||
disabled={!dirty || saving}
|
||||
loading={saving}
|
||||
leftSection={<LocalIcon icon="check-rounded" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveLabel ?? t("payg.cap.save", "Update cap")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewDocs != null && (
|
||||
<div className="scc-estimate">
|
||||
<DescriptionIcon className="scc-estimate__icon" sx={{ fontSize: 22 }} />
|
||||
<div>
|
||||
<div className="scc-estimate__main">
|
||||
{t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
|
||||
docs: previewDocs.toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
<div className="scc-estimate__sub">
|
||||
{t("payg.cap.docsRate", "at {{rate}} / PDF", {
|
||||
rate: formatMinor(pricePerDocMinor ?? 0, currency),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNoCap && (
|
||||
<div className="scc-note">
|
||||
{t(
|
||||
"payg.cap.noCapDesc",
|
||||
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{note && <div className="scc-note">{note}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpendCapControl;
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 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-checkout-session}
|
||||
* 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-checkout-session", {team_id,
|
||||
* currency, success_url, cancel_url})} to obtain a {@code client_secret}. The spending cap is
|
||||
* NOT set here — it's an application-layer setting applied via {@code PATCH /payg/cap} after
|
||||
* the subscription lands.
|
||||
* <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";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
// 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 {
|
||||
/**
|
||||
* The caller's team_id — required by the Supabase edge function, which can't
|
||||
* derive it from the JWT alone (the function runs outside our app's Spring
|
||||
* Security context and has no access to the {@code team_memberships} table
|
||||
* other than via this hint).
|
||||
*/
|
||||
teamId: number;
|
||||
/** Currency lower-case 3-letter ISO (e.g. {@code "gbp"}). Selects the Stripe Price. */
|
||||
currency?: string;
|
||||
/** Cap in USD; null means no cap. Tracked locally; set on the wallet via PATCH after subscription. */
|
||||
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-checkout-session} Supabase edge
|
||||
* function. Mirrors what the function returns.
|
||||
*/
|
||||
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> = ({
|
||||
teamId,
|
||||
currency = "gbp",
|
||||
// capUsd is part of the props contract but intentionally unused here — the cap is set
|
||||
// application-side via PATCH /payg/cap after the subscription lands, not during checkout.
|
||||
onComplete,
|
||||
onError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
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);
|
||||
|
||||
// Billing email for the Stripe Checkout Session. Passing customer_email to
|
||||
// Stripe prefills the email field AND locks it (Stripe's own behaviour for
|
||||
// sessions created with customer_email or an attached customer) — the user
|
||||
// can't bill a different address than the account they're signed in with.
|
||||
const billingEmail = user?.email ?? null;
|
||||
|
||||
// Stable ref for the error callback so we don't have to include it in the
|
||||
// effect deps.
|
||||
const onErrorRef = useRef<typeof onError>(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
// Stash t() in a ref so the effect 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 (devPreview) {
|
||||
setClientSecret("cs_mock_devpreview");
|
||||
setIsMock(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// React 18 strict-mode dev mounts effects twice. We use `cancelled` to
|
||||
// discard the first mount's response (its setState calls become no-ops),
|
||||
// and the second mount's response wins. We deliberately do NOT short-
|
||||
// circuit the second mount with a ref — earlier versions did that and
|
||||
// ran into the trap where mount 1 was cancelled but the live mount
|
||||
// never re-fetched, leaving `loading` stuck at true. Two network calls
|
||||
// in dev is an acceptable cost; prod has no strict mode → single fetch.
|
||||
let cancelled = false;
|
||||
async function createSession() {
|
||||
try {
|
||||
// Direct Supabase edge function invocation. We call
|
||||
// {@code create-checkout-session} (not {@code create-payg-team-subscription} —
|
||||
// that one creates a subscription directly without going through the
|
||||
// hosted Stripe Embedded Checkout iframe and doesn't return a
|
||||
// client_secret). team_id is required because the edge fn runs
|
||||
// outside our Spring Security context and can't resolve it from the
|
||||
// JWT alone. The cap is *not* set during checkout — it's an
|
||||
// application-layer setting, applied via PATCH /payg/cap after the
|
||||
// subscription lands. window.location.href is the success_url so the
|
||||
// user comes back to the Plan tab after Stripe finishes the redirect.
|
||||
const returnUrl = window.location.href;
|
||||
const { data, error: invokeError } =
|
||||
await supabase.functions.invoke<CheckoutResponse>(
|
||||
"create-checkout-session",
|
||||
{
|
||||
body: {
|
||||
team_id: teamId,
|
||||
currency,
|
||||
success_url: returnUrl,
|
||||
cancel_url: returnUrl,
|
||||
// Maps to Stripe's customer_email when the team has no Stripe
|
||||
// customer yet — prefills + locks the email field in Checkout.
|
||||
// Teams with an existing customer get the email locked from the
|
||||
// customer record instead; this field is ignored for them.
|
||||
...(billingEmail ? { billing_owner_email: billingEmail } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (invokeError) {
|
||||
throw invokeError;
|
||||
}
|
||||
if (!data?.client_secret) {
|
||||
throw new Error("Edge function returned no client_secret");
|
||||
}
|
||||
if (cancelled) return;
|
||||
setClientSecret(data.client_secret);
|
||||
setIsMock(Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"));
|
||||
} catch (e: unknown) {
|
||||
if (cancelled) return;
|
||||
const msg =
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: tRef.current(
|
||||
"payg.checkout.error.startFailed",
|
||||
"Couldn't start checkout session",
|
||||
);
|
||||
setError(msg);
|
||||
onErrorRef.current?.(msg);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
void createSession();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teamId, currency, devPreview, billingEmail]);
|
||||
|
||||
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,440 @@
|
||||
/* 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. z-index matches
|
||||
Z_INDEX_OVER_SETTINGS_MODAL (styles/zIndex.ts) — must sit above the
|
||||
AppConfigModal's 1300 (Z_INDEX_OVER_FULLSCREEN_SURFACE). The config modal
|
||||
also hides itself while we're open (appConfig:overlay event), so this is
|
||||
belt-and-braces for the brief overlap during open/close transitions. */
|
||||
.upm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1400;
|
||||
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%;
|
||||
/* Stripe Embedded Checkout flips from its single-column layout to the two-column
|
||||
"horizontal" one (order summary beside the payment form) once the iframe is
|
||||
~1000px wide — measured empirically against a live test session; Stripe doesn't
|
||||
document the breakpoint. The live iframe gets frame_width − 44px (the 22px body
|
||||
padding each side; the live mount's own padding is removed — see
|
||||
.upm-stripe-mount[data-state="live"]). So 1100px → ~1056px iframe, comfortably
|
||||
past the ~1000px threshold. Because the frame stays width:100% under this cap,
|
||||
narrow windows shrink it and Stripe falls back to single column — horizontal
|
||||
when it fits, vertical when it doesn't. Cap + confirm steps read fine at this
|
||||
width (their internal text is already capped — see upm-confirm__body,
|
||||
upm-section-help). */
|
||||
max-width: 1100px;
|
||||
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);
|
||||
}
|
||||
|
||||
/* Left cluster: optional back arrow + title. The back arrow only renders on the
|
||||
checkout step (cap/confirm have no parent step to return to) — it replaces the
|
||||
old footer "← Back" button. */
|
||||
.upm-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.upm-header__back {
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
margin-left: -6px;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.upm-header__back: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 selection (presets + custom + no-cap + estimate) now lives in the shared
|
||||
SpendCapControl (SpendCapControl.css). The old upm-cap-* / upm-no-cap-toggle
|
||||
rules were removed with it. */
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* On the checkout step, step 1's "Set monthly ceiling" label is replaced (inside
|
||||
the step bar) by the chosen ceiling + an inline Edit affordance — see
|
||||
UpgradeModal.tsx. Kept here next to the step-bar markup it styles. */
|
||||
.upm-step__chosen {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--upm-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.upm-step__edit {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--upm-accent);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.upm-step__edit:hover {
|
||||
background: rgba(10, 139, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Placeholder card for the loading / error / mock states — a dashed, centered
|
||||
box with helper text. The live Stripe iframe is NOT meant to wear this chrome;
|
||||
it gets a full-width reset below (data-state="live"). */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Live Stripe Embedded Checkout — strip the placeholder card chrome so the
|
||||
iframe spans the full body width and Stripe renders its two-column
|
||||
"horizontal" layout. Without this the dashed border + 24px padding + centering
|
||||
above box the iframe ~48px narrower, dropping it back to single column. */
|
||||
.upm-stripe-mount[data-state="live"] {
|
||||
display: block;
|
||||
border: none;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.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: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 22px;
|
||||
border-top: 1px solid var(--upm-divider);
|
||||
background: var(--upm-card-bg);
|
||||
}
|
||||
.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,532 @@
|
||||
/**
|
||||
* 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, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import CloseIcon from "@mui/icons-material/CloseRounded";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBackRounded";
|
||||
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";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import SpendCapControl from "./SpendCapControl";
|
||||
|
||||
/**
|
||||
* Tell the AppConfigModal (or any other full-screen surface listening) that an
|
||||
* upgrade overlay is opening/closing so it can hide itself rather than stack
|
||||
* under us. Same window-event pattern the config modal already uses for
|
||||
* appConfig:navigate / appConfig:notice.
|
||||
*/
|
||||
function dispatchOverlay(open: boolean) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("appConfig:overlay", { detail: { open } }),
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
/**
|
||||
* The caller's team_id. Threaded through to {@link StripeCheckoutPanel} so the
|
||||
* {@code create-checkout-session} edge function can scope the subscription to
|
||||
* the right team.
|
||||
*/
|
||||
teamId: number;
|
||||
/** 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";
|
||||
/**
|
||||
* The team's one-time free grant in documents — the real {@code
|
||||
* wallet.freeAllowance}, threaded from the free-leader view so the step copy
|
||||
* quotes the backend's number instead of a hardcoded one. A lifetime grant,
|
||||
* not a monthly one.
|
||||
*/
|
||||
freeLimit: number;
|
||||
/**
|
||||
* Per-document rate in minor units for the live "≈ N paid PDFs/month"
|
||||
* estimate, threaded from {@code wallet.pricePerDocMinor}. For unsubscribed
|
||||
* teams the backend resolves this from the default pricing policy's USD
|
||||
* Price (Stripe hasn't assigned the team a currency yet). Null hides the
|
||||
* estimate.
|
||||
*/
|
||||
pricePerDocMinor?: number | null;
|
||||
/** Lower-case ISO currency of {@link #pricePerDocMinor} (e.g. {@code "usd"}). */
|
||||
rateCurrency?: string | null;
|
||||
}
|
||||
|
||||
type Step = "cap" | "checkout" | "confirm";
|
||||
|
||||
function currencySymbol(c: UpgradeModalProps["currency"]): string {
|
||||
switch (c) {
|
||||
case "EUR":
|
||||
return "€";
|
||||
case "GBP":
|
||||
return "£";
|
||||
default:
|
||||
return "$";
|
||||
}
|
||||
}
|
||||
|
||||
export default function UpgradeModal({
|
||||
open,
|
||||
teamId,
|
||||
onClose,
|
||||
onComplete,
|
||||
currency = "USD",
|
||||
freeLimit,
|
||||
pricePerDocMinor,
|
||||
rateCurrency,
|
||||
}: UpgradeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>("cap");
|
||||
const [capUsd, setCapUsd] = useState<number>(500);
|
||||
const [noCap, setNoCap] = useState<boolean>(false);
|
||||
|
||||
// The config modal hides itself while we're open (it listens for this event)
|
||||
// so the upgrade flow visually REPLACES it instead of stacking inside it.
|
||||
// Cleanup fires open=false on unmount too, so the config modal can't get
|
||||
// stuck hidden if we unmount without a clean close.
|
||||
useEffect(() => {
|
||||
dispatchOverlay(open);
|
||||
return () => dispatchOverlay(false);
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveCap = noCap ? null : capUsd;
|
||||
const sym = currencySymbol(currency);
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
// Portal to document.body so the overlay escapes the config modal's portal /
|
||||
// stacking context. Without this the fixed-position backdrop layers inside
|
||||
// the Mantine modal (z-index 1300) instead of over the whole page, producing
|
||||
// the modal-in-modal look.
|
||||
return createPortal(
|
||||
<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">
|
||||
<div className="upm-header__left">
|
||||
{step === "checkout" && (
|
||||
<button
|
||||
type="button"
|
||||
className="upm-header__back"
|
||||
aria-label={t("payg.upgrade.backAria", "Back")}
|
||||
onClick={goBackToCap}
|
||||
>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
{step === "checkout" ? (
|
||||
<span className="upm-step__chosen">
|
||||
{effectiveCap === null
|
||||
? t("payg.upgrade.checkout.noCap", "No cap")
|
||||
: t(
|
||||
"payg.upgrade.checkout.capValue",
|
||||
"{{symbol}}{{amount}} / month",
|
||||
{ symbol: sym, amount: effectiveCap },
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="upm-step__edit"
|
||||
onClick={goBackToCap}
|
||||
>
|
||||
{t("payg.upgrade.checkout.edit", "Edit")}
|
||||
</button>
|
||||
</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}
|
||||
pricePerDocMinor={pricePerDocMinor}
|
||||
rateCurrency={rateCurrency}
|
||||
/>
|
||||
)}
|
||||
{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"}`}
|
||||
teamId={teamId}
|
||||
effectiveCap={effectiveCap}
|
||||
currency={currency}
|
||||
onComplete={goToConfirm}
|
||||
/>
|
||||
)}
|
||||
{step === "confirm" && (
|
||||
<ConfirmationStep
|
||||
effectiveCap={effectiveCap}
|
||||
currency={currency}
|
||||
freeLimit={freeLimit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step !== "checkout" && (
|
||||
<footer className="upm-footer">
|
||||
<div className="upm-footer__actions">
|
||||
{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>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 1: cap selection ──────────────────────────────────────────────
|
||||
|
||||
interface CapStepProps {
|
||||
capUsd: number;
|
||||
setCapUsd: (v: number) => void;
|
||||
noCap: boolean;
|
||||
setNoCap: (v: boolean) => void;
|
||||
pricePerDocMinor?: number | null;
|
||||
rateCurrency?: string | null;
|
||||
}
|
||||
|
||||
function CapStep({
|
||||
capUsd,
|
||||
setCapUsd,
|
||||
noCap,
|
||||
setNoCap,
|
||||
pricePerDocMinor,
|
||||
rateCurrency,
|
||||
}: CapStepProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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. Set $0 if you want to keep everything free while testing.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Same control the subscribed plan page renders. null = no cap; the
|
||||
shared control owns the presets, the inline custom-entry pill, the
|
||||
no-cap chip, and the live processed-PDF estimate. */}
|
||||
<SpendCapControl
|
||||
capUsd={noCap ? null : capUsd}
|
||||
onChange={(v) => {
|
||||
if (v === null) {
|
||||
setNoCap(true);
|
||||
} else {
|
||||
setNoCap(false);
|
||||
setCapUsd(v);
|
||||
}
|
||||
}}
|
||||
pricePerDocMinor={pricePerDocMinor}
|
||||
currency={rateCurrency}
|
||||
note={t(
|
||||
"payg.upgrade.cap.usdNote",
|
||||
"Estimated in USD. You can adjust your cap any time after subscribing — in your own currency.",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="upm-help-card" style={{ marginTop: 16 }}>
|
||||
<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 {
|
||||
teamId: number;
|
||||
effectiveCap: number | null;
|
||||
currency: UpgradeModalProps["currency"];
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
function CheckoutStep({
|
||||
teamId,
|
||||
effectiveCap,
|
||||
currency,
|
||||
onComplete,
|
||||
}: CheckoutStepProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
teamId={teamId}
|
||||
currency={currency?.toLowerCase() ?? "gbp"}
|
||||
capUsd={effectiveCap}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 3: confirmation ──────────────────────────────────────────────
|
||||
|
||||
interface ConfirmationStepProps {
|
||||
effectiveCap: number | null;
|
||||
currency: UpgradeModalProps["currency"];
|
||||
freeLimit: number;
|
||||
}
|
||||
|
||||
function ConfirmationStep({
|
||||
effectiveCap,
|
||||
currency,
|
||||
freeLimit,
|
||||
}: 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 process documents with automation, AI, and the API beyond your {{limit}} free PDFs.",
|
||||
{ limit: freeLimit.toLocaleString() },
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+13
-60
@@ -1,79 +1,32 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { ApiCredits } from "@app/types/credits";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
|
||||
function coerceNumber(value: unknown, fallback = 0): number {
|
||||
const n = typeof value === "string" ? Number(value) : (value as number);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function normalizeCredits(raw: Record<string, unknown>): ApiCredits {
|
||||
// Accept a variety of possible backend keys to be resilient
|
||||
return {
|
||||
weeklyCreditsRemaining: coerceNumber(
|
||||
raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left,
|
||||
),
|
||||
weeklyCreditsAllocated: coerceNumber(
|
||||
raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total,
|
||||
),
|
||||
boughtCreditsRemaining: coerceNumber(
|
||||
raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left,
|
||||
),
|
||||
totalBoughtCredits: coerceNumber(
|
||||
raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total,
|
||||
),
|
||||
totalAvailableCredits: coerceNumber(
|
||||
raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total,
|
||||
),
|
||||
weeklyResetDate: String(
|
||||
raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date ?? "",
|
||||
),
|
||||
lastApiUsage: String(
|
||||
raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at ?? "",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function useCredits() {
|
||||
const { session, loading, user } = useAuth();
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
const [data, setData] = useState<ApiCredits | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
// Gutted hook (legacy /api/v1/credits is dead) — these stay at their initial values; only
|
||||
// hasAttempted toggles, so the rest have no setters.
|
||||
const [data] = useState<ApiCredits | null>(null);
|
||||
const [isLoading] = useState<boolean>(false);
|
||||
const [error] = useState<Error | null>(null);
|
||||
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
|
||||
|
||||
// Legacy weekly-credits endpoint (/api/v1/credits) is dead. PAYG replaces this — the
|
||||
// wallet hook (useWallet) carries the equivalent state via /api/v1/payg/wallet. The
|
||||
// hook surface is preserved for the ApiKeys page consumer (it destructures `data`,
|
||||
// `isLoading`, `error`, `refetch`, `hasAttempted`), but `data` always stays null —
|
||||
// the consumer's usage widget will render its "no data" state.
|
||||
const fetchCredits = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res =
|
||||
await apiClient.get<Record<string, unknown>>("/api/v1/credits");
|
||||
const normalized = normalizeCredits(res.data);
|
||||
// If backend returns an "empty" payload, keep data null so the UI stays in loading/skeleton
|
||||
const isEmpty =
|
||||
!normalized.weeklyCreditsAllocated &&
|
||||
!normalized.weeklyCreditsRemaining &&
|
||||
!normalized.totalBoughtCredits &&
|
||||
!normalized.boughtCreditsRemaining &&
|
||||
!normalized.totalAvailableCredits &&
|
||||
!normalized.weeklyResetDate &&
|
||||
!normalized.lastApiUsage;
|
||||
setData(isEmpty ? null : normalized);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setHasAttempted(true);
|
||||
}
|
||||
setHasAttempted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && session && !hasAttempted && !isAnonymous) {
|
||||
fetchCredits();
|
||||
setHasAttempted(true);
|
||||
}
|
||||
}, [loading, session, hasAttempted, isAnonymous, fetchCredits]);
|
||||
}, [loading, session, hasAttempted, isAnonymous]);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
-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 type { 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;
|
||||
@@ -151,7 +151,11 @@ function appendMcpSection(
|
||||
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);
|
||||
|
||||
@@ -203,6 +207,9 @@ export function createSaasConfigNavSections(
|
||||
sections = appendMcpSection(sections, t);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +1,26 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCredits } from "@app/hooks/useCredits";
|
||||
import { getToolCreditCost } from "@app/utils/creditCosts";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
export function useCreditCheck(operationType?: string, _endpoint?: string) {
|
||||
const { hasSufficientCredits, isPro, creditBalance, refreshCredits } =
|
||||
useCredits();
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* Pre-flight credit-balance check, formerly run before every billable tool call.
|
||||
*
|
||||
* Replaced by PAYG's reactive 402 FEATURE_DEGRADED handler (see paygErrorInterceptor.ts):
|
||||
* we no longer try to predict whether the user has "enough credits" before the request —
|
||||
* we make the request, and if the wallet hits the free-tier ceiling the BE returns 402
|
||||
* with a discriminating code that the global axios interceptor turns into a toast +
|
||||
* prompt-to-add-card. This is more accurate (no race between FE balance cache and the
|
||||
* BE's atomic debit) and avoids the round-trip latency of a pre-flight call.
|
||||
*
|
||||
* The hook signature is preserved as a no-op so {@code useToolOperation} (the sole
|
||||
* caller) compiles without modification. {@code checkCredits} always resolves to null
|
||||
* — the BE's 402 handler is now the only gate.
|
||||
*/
|
||||
export function useCreditCheck(
|
||||
_operationType?: string,
|
||||
_endpoint?: string,
|
||||
): { checkCredits: (_runtimeEndpoint?: string) => Promise<string | null> } {
|
||||
const checkCredits = useCallback(
|
||||
async (_runtimeEndpoint?: string): Promise<string | null> => {
|
||||
const requiredCredits = getToolCreditCost(operationType as ToolId);
|
||||
const creditCheck = hasSufficientCredits(requiredCredits);
|
||||
|
||||
if (creditBalance === null) {
|
||||
try {
|
||||
await refreshCredits();
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
return t("loadingCredits", "Checking credits...");
|
||||
}
|
||||
|
||||
if (isPro === null) {
|
||||
return t("loadingProStatus", "Checking subscription status...");
|
||||
}
|
||||
|
||||
if (!isPro && !creditCheck.hasSufficientCredits) {
|
||||
const shortfall = creditCheck.shortfall || 0;
|
||||
const error = t(
|
||||
"insufficientCredits",
|
||||
"Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}",
|
||||
{
|
||||
requiredCredits,
|
||||
currentBalance: creditCheck.currentBalance,
|
||||
shortfall,
|
||||
},
|
||||
);
|
||||
const notice = t(
|
||||
"noticeTopUpOrPlan",
|
||||
"Not enough credits, please top up or upgrade to a plan",
|
||||
);
|
||||
openPlanSettings(notice);
|
||||
return error;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[
|
||||
hasSufficientCredits,
|
||||
isPro,
|
||||
creditBalance,
|
||||
refreshCredits,
|
||||
operationType,
|
||||
t,
|
||||
],
|
||||
async (_runtimeEndpoint?: string): Promise<string | null> => null,
|
||||
[],
|
||||
);
|
||||
|
||||
return { checkCredits };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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,526 @@
|
||||
/**
|
||||
* 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";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
|
||||
// ─── 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 their per-member usage display. Mirrors a row of
|
||||
* the backend's {@code members} array on {@code WalletSnapshot} (joined with
|
||||
* {@code team_memberships}).
|
||||
*/
|
||||
export interface WalletMember {
|
||||
/** Supabase user id of the member. */
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
/** 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 {
|
||||
/**
|
||||
* The caller's primary team_id. Needed when invoking Supabase edge functions
|
||||
* (create-checkout-session, etc.) that run outside Spring Security and have
|
||||
* no other way to resolve the caller's team. May be null on the synthetic
|
||||
* empty snapshot returned to anonymous / team-less callers.
|
||||
*/
|
||||
teamId: number | null;
|
||||
status: WalletStatus;
|
||||
role: WalletRole;
|
||||
/**
|
||||
* ISO yyyy-mm-dd. The Stripe subscription's current period when subscribed;
|
||||
* the calendar month for free teams.
|
||||
*/
|
||||
billingPeriodStart: string;
|
||||
billingPeriodEnd: string;
|
||||
/**
|
||||
* For a free team: the one-time free documents used so far ({@code
|
||||
* freeAllowance − freeRemaining}). For a subscribed team: documents
|
||||
* processed this month across automation + AI + API.
|
||||
*/
|
||||
billableUsed: number;
|
||||
/**
|
||||
* The team's document ceiling for the matching window: the one-time free
|
||||
* grant ({@code freeAllowance}) for free teams; the monthly paid-doc cap
|
||||
* {@code floor(cap / perDocRate)} for capped subscribed teams; null when
|
||||
* subscribed with no cap (uncapped).
|
||||
*/
|
||||
billableLimit: number | null;
|
||||
/**
|
||||
* The team's one-time free document grant size — the "N" in "X of N free".
|
||||
* A lifetime grant ({@code pricing_policy.free_tier_units}): it never resets
|
||||
* and is not lost when the team subscribes.
|
||||
*/
|
||||
freeAllowance: number;
|
||||
/**
|
||||
* One-time free documents still available to the team
|
||||
* ({@code payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
|
||||
* Survives subscribing — a subscribed team keeps any unused grant.
|
||||
*/
|
||||
freeRemaining: number;
|
||||
/**
|
||||
* Paid per-document rate in minor units of {@link Wallet#currency} (may be
|
||||
* fractional); null when the rate can't be resolved — render "unknown",
|
||||
* never substitute.
|
||||
*/
|
||||
pricePerDocMinor: number | null;
|
||||
/** Lower-case ISO 4217 currency of the subscription's Stripe Price; null when unknown. */
|
||||
currency: string | null;
|
||||
/**
|
||||
* Estimated charges so far this period in minor units of currency: paid
|
||||
* (Stripe-metered) documents this period × rate. The free portion was
|
||||
* already netted out at charge time. Informational — the Stripe invoice
|
||||
* is authoritative. Null when the rate is unknown.
|
||||
*/
|
||||
estimatedBillMinor: number | null;
|
||||
/** Monthly cap in major currency units 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>>;
|
||||
}
|
||||
|
||||
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>;
|
||||
/**
|
||||
* Mint a Stripe Customer Portal session and navigate to it. Calls
|
||||
* the {@code create-customer-portal-session} Supabase edge function
|
||||
* directly with the user's JWT (same pattern as checkout — no backend
|
||||
* proxy; the function's RPC enforces team membership) and assigns
|
||||
* {@code window.location} to the returned URL — same-tab redirect.
|
||||
* 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 return_url} on close. Throws on error so the caller can
|
||||
* show a friendly toast — notably 404 {@code team_not_subscribed}.
|
||||
*/
|
||||
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.freeAllowance !== next.freeAllowance ||
|
||||
prev.freeRemaining !== next.freeRemaining ||
|
||||
prev.pricePerDocMinor !== next.pricePerDocMinor ||
|
||||
prev.currency !== next.currency ||
|
||||
prev.estimatedBillMinor !== next.estimatedBillMinor ||
|
||||
prev.capUsd !== next.capUsd ||
|
||||
prev.noCap !== next.noCap ||
|
||||
prev.stripeSubscriptionId !== next.stripeSubscriptionId ||
|
||||
prev.spendUnitsThisPeriod !== next.spendUnitsThisPeriod
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
if (prev.recent.length !== next.recent.length) {
|
||||
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.spendUnits !== b.spendUnits
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
// recent length-mismatch already returned `next` above; content (Wave 1 = []) is identical
|
||||
// otherwise, so reuse the prior reference for stable child memoisation.
|
||||
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 {
|
||||
teamId: null,
|
||||
status: subscribed ? "subscribed" : "free",
|
||||
role,
|
||||
billingPeriodStart: isoDay(periodStart),
|
||||
billingPeriodEnd: isoDay(periodEnd),
|
||||
billableUsed: 62,
|
||||
billableLimit: subscribed ? 1250 : 500,
|
||||
freeAllowance: 500,
|
||||
// One-time grant: a free team has used 62 of 500 (438 left); the dev
|
||||
// subscribed team is shown with its grant fully spent (kept across the
|
||||
// subscribe — it just no longer gates them).
|
||||
freeRemaining: subscribed ? 0 : 438,
|
||||
// Free teams also carry a rate now — the backend resolves it from the
|
||||
// default policy's USD Price so the upgrade-flow cap estimate ("≈ N paid
|
||||
// PDFs/month") can render before subscribing. Mirror that here.
|
||||
pricePerDocMinor: 2,
|
||||
currency: "usd",
|
||||
estimatedBillMinor: subscribed ? 0 : null,
|
||||
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;
|
||||
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.
|
||||
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 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;
|
||||
}
|
||||
// Direct edge-fn invocation with the user's JWT — same pattern as
|
||||
// create-checkout-session. The payg_get_checkout_context RPC inside the
|
||||
// fn enforces team membership, so no backend proxy is needed; team_id
|
||||
// must be a NUMBER (the fn type-checks and rejects strings).
|
||||
const teamId = wallet?.teamId;
|
||||
if (teamId == null) {
|
||||
throw new Error("No team resolved yet");
|
||||
}
|
||||
const { data, error: invokeError } = await supabase.functions.invoke<{
|
||||
url?: string;
|
||||
error?: string;
|
||||
}>("create-customer-portal-session", {
|
||||
body: { team_id: teamId, return_url: window.location.href },
|
||||
});
|
||||
if (invokeError) {
|
||||
// FunctionsHttpError etc. — the StripePortalLink caller catches and
|
||||
// shows a friendly toast (404 team_not_subscribed, 403, outage).
|
||||
throw invokeError;
|
||||
}
|
||||
if (!data?.url) {
|
||||
throw new Error(data?.error ?? "Portal session response missing url");
|
||||
}
|
||||
// Same-tab navigation rather than window.open(...,"_blank"): Stripe's
|
||||
// customer portal is a full-page experience and brings the user back
|
||||
// via return_url, 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(data.url);
|
||||
}, [devPreview, wallet?.teamId]);
|
||||
|
||||
return {
|
||||
wallet,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
markSubscribed,
|
||||
updateCap,
|
||||
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";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
|
||||
// Global credit update callback - will be set by the AuthProvider
|
||||
@@ -159,12 +163,6 @@ apiClient.interceptors.response.use(
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.config?.url?.includes("/api/v1/credits")) {
|
||||
console.debug(
|
||||
"[API Client] Credits endpoint response headers:",
|
||||
response.headers,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
@@ -174,6 +172,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);
|
||||
}
|
||||
|
||||
// On a first 401, refresh and retry — public endpoints included, since an
|
||||
// expired Bearer token is rejected on any route during cold load.
|
||||
if (status === 401 && !originalRequest._retry) {
|
||||
|
||||
@@ -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