Compare commits

...
Author SHA1 Message Date
Connor Yoh 69c4401476 fe(payg): F5 — remove /dev/payg-preview dev route + component
The dev preview was a scaffolding route for iterating on the PAYG plan UI
before real wallet endpoints existed. Now that useWallet calls the real
GET /api/v1/payg/wallet and the upgrade modal is wired end-to-end, the
preview route + component are dead code.

Kept (graceful-degradation paths):
- useWallet.ts buildDevPreviewWallet + localStorage fallback — still
  useful when the /wallet endpoint is unreachable locally
- StripeCheckoutPanel mock-mode placeholder — still used when
  VITE_STRIPE_PUBLISHABLE_KEY is unset
2026-06-09 15:56:34 +01:00
Connor Yoh a24bffdb8e fe(payg): F4 — wrap new PAYG copy in t() + add en-GB TOML keys
PaygFree leader + member, UpgradeModal step 1-3, StripeCheckoutPanel
loading/error/mock states, and the FREE_TIER_EXHAUSTED toast are now
all routed through i18n with English fallbacks. New [payg.*] sections
added to translation.toml: checkout, confirm, error, exhausted, free.*,
signupRequired, stripe.toast, subcaps.editor/toast, upgrade.*. Existing
SignupRequiredBootstrap copy was already wrapped — keys backfilled
into TOML so en-GB loads them at runtime instead of falling back.

paygErrorInterceptor.ts lives outside the React tree so uses
i18n.t() directly (same pattern as backendHealthMonitor).
StripeCheckoutPanel stashes t in a ref so the single-flight effect
deps don't have to include it.

No new TS errors vs baseline (153 pre-existing saas-resolver hits
remain unchanged).
2026-06-09 15:48:55 +01:00
Connor Yoh 9f3b3b174d fe(payg): intercept 402 FEATURE_DEGRADED + 401 SIGNUP_REQUIRED
Classifies the EntitlementGuard's PAYG sentinels in the apiClient
response interceptor before the existing 401/refresh logic and surfaces
appropriate UI:

- 402 FEATURE_DEGRADED → persistent warning toast "You've hit your free
  monthly limit" with a "Go to billing" CTA that opens the Plan tab via
  the existing appConfig:open + appConfig:navigate event bus.
- 401 SIGNUP_REQUIRED → dispatches payg:signupRequired CustomEvent
  carrying the category from the response body. A new top-level
  SignupRequiredBootstrap (mirroring TrialExpiredBootstrap's pattern)
  listens for the event and opens a Mantine modal explaining the free
  500-op/month allowance with a "Sign up free" CTA.

Both paths short-circuit the existing session-refresh / redirect-to-
login logic so anonymous users hitting billable endpoints don't get
bounced to /login when /signup is what they need.

Tests: 12 new unit tests on the classifier + handler (parsing edge
cases, button callback wiring, custom-event dispatch). Existing
apiClient.test.ts still passes — the new path is gated on body.error
sentinels so plain 401s flow through the unchanged refresh path.
2026-06-09 15:09:26 +01:00
Connor Yoh 1de5177f96 fe(payg): same-tab portal redirect + suppressErrorToast on payg mutations
F2 review fixes on useWallet.ts:

- openPortal: switched from window.open(url, "_blank", ...) after an
  await to window.location.assign(url). Browsers treat window.open
  after an awaited promise as non-user-gesture and silently popup-block
  it; the sibling ManageBillingButton already uses same-tab redirect.
  Stripe's customer portal is a full-page experience and returns the
  user to returnUrl on close, so a popup buys us nothing.

- openPortal + updateSubCap: now pass { suppressErrorToast: true } as
  the third axios option. The global response interceptor in
  saas/services/apiClient.ts otherwise shows a generic toast that
  stacks on top of the component-level friendly toast (PORTAL_NOT_
  CONFIGURED on Stripe portal, clamp/validation errors on sub-caps).
  Matches the established pattern in saas/services/userManagementService.ts.
2026-06-09 14:59:16 +01:00
Connor Yoh c3a3c95b7c fe(payg): wire sub-caps editor + Stripe portal button to real BE
useWallet gains updateSubCap (PATCH /api/v1/payg/sub-caps/{userId},
returns effective+clamped so the UI can surface server-side clamping)
and openPortal (POST /api/v1/payg/portal-session, opens the Stripe-
hosted URL in a new tab).

MemberSubCaps now reads from wallet.members[] via a new members prop —
the synthesised 3-row mock only applies when no prop is supplied (dev
preview). Each row has an inline NumberInput editor (Save / Cancel /
Remove cap) with success / clamped / error toasts.

StripePortalLink switches from a static <a href> to a Button that calls
openPortal() with a loading state. On 404 / 503 it shows a friendly
"portal isn't available right now" toast — local dev without the
Supabase edge function configured no longer errors out.
2026-06-09 14:52:48 +01:00
Connor Yoh e6754ede32 fe(payg): delete BE mock controllers, align useWallet shape with real BE
The PaygApiController/PaygApiService mock on this branch is being
superseded by the real backend on payg-c1-cap-entitlement (PR #6574 —
PaygWalletController, served by the wallet_category_summary view +
team_memberships join). Once #6574 lands in main, the mock files would
clash, so they're removed here.

The useWallet Wallet type gains three fields to match the real BE's
WalletSnapshot response: categoryBreakdown (api/ai/automation buckets),
members (per-member sub-cap rows for the leader's dashboard), and recent
(activity feed, V1 = []). Dev preview wallet synthesis populates the
new fields with tier-distinguishable mock values so the design-time
preview still exercises both the free and subscribed visuals.

markSubscribed now swallows 404s from /api/v1/payg/dev/mark-subscribed
since that endpoint disappears with the BE mock. In real-BE mode the
Stripe customer.subscription.created webhook flips the team to
subscribed, so an info-level log + continuing to refetch is the right
move for the FE — no broken modal completion when the dev hook isn't
there.
2026-06-09 14:44:31 +01:00
Connor Yoh 2b11e35530 docs(payg): correct cap-update boundary — cap is application-layer, no Stripe
You'd asked: does cap update really need to touch Stripe? It doesn't.
I was wrong in the prior arch commit calling it a 'stub.' The cap is
permanent in Java.

Stripe has no native hard cap. billing_thresholds.amount_gte is an
early-invoice trigger, not a cut-off. The way you actually cap a
metered subscription is to stop sending meter events — Stripe only
sees what you push. That's an application-layer concern:

  - Cap value lives in wallet_policy.cap_units (Postgres)
  - Before each billable op: gate the meter-payg-units push on
    current_period_spend ≤ cap
  - Hit cap → refuse op, return 402 / DEGRADED state
  - Stripe never knows the cap exists

So PATCH /api/v1/payg/cap is a single SQL UPDATE — no edge function,
no Stripe round-trip. The 'wants to move to update-payg-cap edge fn'
TODO from the prior commit was wrong; removed.

Updated:
- PaygApiController class javadoc — split rationale is about who owns
  the DATA, not who touches Stripe. Java owns app-rule writes
  (wallet_policy, cap, sub-caps); edge fns own Stripe mutations.
- PaygApiController#updateCap section comment — points at the real
  one-line repository write that lands when the mock comes out.
- PaygApiService#updateCap javadoc — same correction.
- PaygApiService class swap-out plan — cap step no longer references
  a non-existent update-payg-cap edge function.

Best practice general rule (recorded in javadoc):
- User-set limits (cap, sub-caps, warn-at %): your DB, your enforcement
- Subscription state, prices, payment methods: Stripe is source of
  truth, mirror via Sync Engine for read perf
- Usage events: push to Stripe, gated by your internal cap check
2026-06-09 10:00:19 +01:00
Connor Yoh e4d8832c27 arch: move Stripe Checkout creation to Supabase edge function
You'd flagged that calling Stripe from Java duplicates the edge function
in PR #300. Fixed.

Java was doing both wallet reads AND creating Stripe Checkout Sessions.
The latter overlaps with `create-payg-team-subscription` (already on
SaaS PR #300, 79 tests, uses Stripe Sync Engine + shared SDK plumbing).
Routing through Java meant:

  - Two Stripe integrations to keep in sync (Java + Deno)
  - Two places for the Stripe secret key to live
  - Useless proxy hop: FE → Java → edge fn → Stripe
  - Doesn't match the existing pattern — usePlans already calls
    `supabase.functions.invoke("stripe-price-lookup", ...)` directly

Now:

  FE → supabase.functions.invoke("create-payg-team-subscription") → Stripe

Java keeps:

  - GET /api/v1/payg/wallet — pure read, no Stripe touch
  - PATCH /api/v1/payg/cap — temporary stub. Cap updates also touch
    Stripe (subscription_item.update with new billing_thresholds), so
    this should move to a new `update-payg-cap` edge function. Marked
    with a clear TODO in the controller javadoc.
  - POST /api/v1/payg/dev/mark-subscribed — dev-only mock side-channel,
    disappears once the real Stripe webhook handler (PR #300) is
    deployed.

Concretely:

  - Removed PaygApiController#createCheckoutSession + CheckoutSessionRequest/Response records
  - Removed PaygApiService#createCheckoutSession + CheckoutSessionResult + createMockClientSecret helper
  - StripeCheckoutPanel now calls supabase.functions.invoke() instead of apiClient.post()
  - Documented the new boundary in both controller + service class javadocs

Verified: dev preview end-to-end flow still works (modal step 1 → step 2 mock placeholder → completion). ./gradlew :saas:compileJava + vite build both clean.
2026-06-09 09:56:04 +01:00
Connor Yoh d3ffc6ebeb review-response: gate dev mark-subscribed, tighten race conditions
Backend (PaygApiController + PaygApiService):
- POST /api/v1/payg/dev/mark-subscribed now requires BOTH
  payg.dev-endpoints.enabled=true (default false) AND ROLE_ADMIN. Returns
  404 when disabled so probes can't enumerate. Marked @Hidden so it stays
  out of the public OpenAPI surface. Closes the "any authenticated user
  can flip their own team to subscribed" hole that would have shipped in
  the prior commit.
- updateCap uses AtomicReference.updateAndGet (CAS) instead of get →
  set so a concurrent markSubscribed can't lose the subscription id.

Frontend (useWallet + StripeCheckoutPanel + Plan + UpgradeModal):
- useWallet: monotonic requestId ref so a slow refetch from tick=N can't
  overwrite a faster one from tick=N+1. Mutations now await the post-
  refetch settle via an in-flight promise ref, so save-button loading
  states drop only once the new state is actually visible.
- useWallet: dev-preview detection requires import.meta.env.DEV AND the
  /dev/ path, so a production tenant with a /dev/ URL prefix can't
  trigger the synthesised fallback.
- StripeCheckoutPanel: same DEV+path gate. onError moved to a ref so it
  doesn't have to live in the effect deps. The single-flight fetchedRef
  now correctly resets per-cap because UpgradeModal keys the panel on
  `co:${effectiveCap}` — editing the cap mid-flow forces a fresh
  Checkout Session for the new amount.
- useRenderCount: hook body fully gated on a module-level IS_DEV const
  so Vite's dead-code-elimination drops the entire counter logic from
  production bundles (only the useRef ref allocation remains, required
  by rules-of-hooks).
- PaygFreeLeader + PaygFreeMember wrapped in React.memo. Plan's
  onUpgraded callback hoisted to useCallback so the prop identity stays
  stable across Plan re-renders (loading/error toggles no longer cascade
  down to the leaves).

Deferred to follow-up commits (noted in PR description):
- isAdmin → real team_memberships.role lookup (currently uses
  ROLE_ADMIN as proxy; correct for now but means most users will land
  in the MEMBER view even if they're a team owner).
- CapEditor's "no cap" toggle (currently only a numeric save).

Verified: ./gradlew :saas:compileJava clean, vite build --mode saas clean.
2026-06-08 22:53:12 +01:00
Connor Yoh b46f65ef8c fe(payg): real useWallet, lazy Stripe Embedded Checkout, confirmation step
End-to-end UI for the new PAYG flow against the backend mock controllers
landed in the previous commit:

- useWallet — calls GET /api/v1/payg/wallet via the shared apiClient (which
  injects the Supabase JWT). Dev preview route synthesises a snapshot
  instead so design iteration stays available. Stable-reference reuse
  optimisation deep-equals each new fetch with the prior snapshot so
  consumers don't re-render on no-op refreshes.

- StripeCheckoutPanel — own module, lazy-imported via React.lazy so the
  Stripe SDK chunk is only fetched when the user reaches step 2. The
  Stripe SDK itself is already pulled into the main bundle by the
  existing TrialExpiredBootstrap / TrialStatusBanner — that chunking is
  out of scope here, but the new modal panel doesn't add extra weight.
  Detects mock-mode (cs_mock_* client_secret OR no VITE_STRIPE_PUBLISHABLE_KEY)
  and renders a friendly placeholder + "Continue with mock subscription"
  button instead of mounting a real iframe with a bad secret.

- UpgradeModal — three sequential panels: cap → checkout (lazy Stripe) →
  confirmation (Welcome to Processor). Cap is held in modal state only;
  nothing reaches the backend until Stripe completes. Backdrop click +
  close button reset the step so reopening starts at cap selection.

- Plan — loading + error states. Passes useWallet's markSubscribed +
  updateCap down to PaygFreeLeader / PaygLeader so completion + cap-save
  trigger a wallet refetch.

- Payg.tsx CapEditor — Save button gains an onSaveCap prop threaded
  through PaygLeader. Currency conversion stays USD-only for V1 (the
  backend's PATCH /cap takes capUsd); non-USD currencies are a follow-up.

- useRenderCount — dev-only render counter. Writes to
  window.__renderCounts so Playwright + manual devtools can assert
  components don't re-render excessively.

- DevPaygPreview — wires its PaygFreeLeader's onUpgraded prop to flip
  the localStorage subscription flag so the dev preview also demos the
  full upgrade cycle (free → modal → subscribed).

Verified: full 3-step modal flow (cap → mock checkout → confirmation)
renders cleanly via the dev preview route. ./gradlew :saas:compileJava
and `vite build --mode saas` both succeed.
2026-06-08 22:46:20 +01:00
Connor Yoh ac33e950b5 be(saas/payg): add /api/v1/payg/{wallet,checkout,cap} controllers (mock)
Adds the three PAYG REST endpoints the new Plan-page UI needs:

  GET   /api/v1/payg/wallet            — subscription state + usage + cap + role
  POST  /api/v1/payg/checkout          — creates a Stripe Checkout Session
  PATCH /api/v1/payg/cap               — updates monthly spend cap

Plus a DEV-ONLY POST /api/v1/payg/dev/mark-subscribed that simulates the
Stripe webhook so the UI can be exercised end-to-end before the real
webhook handler ships.

All endpoints are saas-profile-gated. The service layer is an in-memory
mock keyed by a synthetic team identifier — the real backing tables
(payg_team_extensions.payg_subscription_id + free_tier, payg_meter_event_log)
land in SaaS PR #300 and the Stripe Java SDK isn't on this branch yet.
Each mock returns realistic shapes with a JavaDoc swap-out plan calling
out exactly what changes when the unmerged surfaces land.

Why mock here instead of waiting for PR #300:
- Frontend can iterate against real HTTP round-trips instead of localStorage
- Shapes get exercised before any of it hits prod
- Removing the mock is a service-layer edit, not a controller rewrite

Verified `./gradlew :saas:compileJava` clean.
2026-06-08 22:31:08 +01:00
Connor Yoh c655517ca3 fe(payg): kill old SaaS plan page, branch Plan tab on wallet state
Replaces the legacy multi-tier (Pro / Business / Enterprise + API credit
packs) plan page with the new PAYG plan UI. The Plan tab now internally
branches on (subscription state × team role) and renders one of four views:

  - free + leader     → PaygFreeLeader (upgrade CTA)
  - free + member     → PaygFreeMember (ask-the-owner note)
  - subscribed + L/M  → PaygLeader / PaygMember (Reece's dashboard)

Notable:

- New useWallet() hook (saas/hooks/useWallet.ts). Mock-first: subscription
  state lives in localStorage so the UpgradeModal's onComplete can flip the
  rendered view without a real Stripe round-trip. Role is sourced from
  appConfig.isAdmin (same proxy saasConfigNavSections used before). Swap to
  GET /api/v1/payg/wallet when the endpoint lands.
- PaygFreeLeader gains an optional onUpgraded prop. PlanSection wires it to
  markSubscribed(). Standalone dev-preview keeps the demo alert fallback.
- Drops the duplicate "Pay-as-you-go" nav tab — there is one billing
  surface now, and it's the Plan tab. Removes paygEnabled / isLeader nav
  options (and the related plumbing in AppConfigModal).
- Deletes frontend/editor/src/saas/.../configSections/plan/ (the old
  PlanCard / AvailablePlansSection / ActivePlanSection / ApiPackagesSection
  components). usePlans hook stays — still used by ManageBillingButton +
  TrialStatusBanner.

Verified visually via /dev/payg-preview (screenshots 013–015 in
notes/payg-flow-screenshots/) — all four views render cleanly with the
v2.1 manual-tools framing.
2026-06-08 22:21:32 +01:00
Connor Yoh e99dfb81ae fe(payg): tighten free-tier framing — manual tools vs auto/AI/API
Automation and AI features also have UI surfaces, so the "UI free / not-UI
paid" framing was misleading. Reframed everywhere to be about the type of
work:

- "Manual tools are always free" (viewing, editing, signing, merging,
  splitting, conversion, watermarks, compression, manual OCR)
- "Automation, AI, and API count toward billing" (regardless of where
  they're triggered from)

Also drops the "From $5/month" reassurance because users can set a $0 cap
and pay nothing while still being subscribed; the cap input now accepts
0 and the help text mentions it explicitly.

Touched:
- PaygFree.tsx — module javadoc, header subtitle, CTA reassurance,
  leader + member explainer text, member ask-the-owner note
- UpgradeModal.tsx — promise card, section help, cap input (min={0}),
  billable-list italic footer
2026-06-08 22:06:43 +01:00
Connor Yoh 2faa38c011 PAYG FE: rework promo as PaygFreeLeader/Member; reframe billable scope
Drops the side-by-side card promo (EditorPlanPromo) in favour of two views
that mirror the subscribed PaygLeader/PaygMember dashboards visually. The
free view now feels like an extension of the paid view rather than a
different page, which makes turning on Processor feel like enabling a
switch rather than buying a product.

New billable framing (product call set 2026-06):

  Free, always — anything clicked directly in the Stirling UI:
    viewing, editing, merging, splitting, signing, watermarking,
    compression, conversion, OCR via the interactive UI.

  Counts toward 500/month free tier — and metered above that:
    automation pipelines, AI tools, API calls.

Note: backend design doc + pricing schema don't reflect this yet — FE is
the source of truth in the meantime; backend will catch up.

Components:

  PaygFreeLeader
    - Mirrors PaygLeader: subtitle + role pill header, hero usage card
      showing 62 / 500 free operations with progress bar + "Plenty left"
      status, identical visual language to the subscribed view
    - Below the hero: a gradient-bordered CTA card ("Turn on the Processor
      plan") with three benefit bullets (Automation / AI / API access)
      and footer row containing the CTA button + reassurance copy
    - Bottom: side-by-side explainer cards — "Always free" vs "Counts
      toward 500/month" — using the same colour tokens as the hero
    - CTA opens the existing UpgradeModal

  PaygFreeMember
    - Same header + hero pattern but with team-member pill
    - Replaces the CTA with a lock-icon note explaining the owner can
      enable Processor
    - Same bottom explainer cards but worded for the member viewpoint
      ("Always free for you" / "Shared with the team")

UpgradeModal copy rewrite:

  - Promise card now reads "Everything in the Stirling UI stays free.
    You only pay for what comes from automation pipelines, AI tools, and
    API calls — the things that don't need a person clicking through
    the UI."
  - "Set your monthly spend ceiling" section text updated to
    "Your first 500 automation / AI / API operations every month are free"
  - "What we count toward billing" card now a bulleted list of the three
    billable categories plus an italic line explicitly listing UI tools
    that stay free

Dev preview slots reworked:
  - Was: free / upgrade-modal / active-leader / active-member
  - Now: free-leader / free-member / subscribed-leader / subscribed-member
  - "Upgrade modal" slot removed — the modal now opens from inside
    free-leader, which is the only real entry point

Deleted: EditorPlanPromo.tsx + .css (superseded by PaygFreeLeader).

Screenshots:
  payg-flow-009-free-leader-v1.png  (first cut — stacked header)
  payg-flow-010-free-leader-v2.png  (Mantine Group header, polished CTA)
  payg-flow-011-free-member.png     (member view, lock note instead of CTA)
  payg-flow-012-modal-new-copy.png  (modal with billable framing reworked)
2026-06-08 21:57:07 +01:00
Connor Yoh a15cf5f90d PAYG FE: EditorPlanPromo + UpgradeModal + dev preview route
First visual chunk of the PAYG plan/upgrade flow. Builds on top of Reece's
Payg.tsx (cherry-picked from #6503 into this branch as part of the bundle).

Components:
  - EditorPlanPromo: the page free users see in the Plan slot. Hero anchors
    the "Editing is free, forever" promise; two-card layout shows what they
    get on Editor (current) vs Processor (upgrade); Processor card has a
    gradient border + accent CTA. Reinforcement strip at the bottom restates
    the promise.
  - UpgradeModal: two-step modal inside one frame. Step 1 (cap selection)
    has $10/$25/$50/$100 presets + custom input + no-cap toggle + helper
    card explaining what counts as billable. Step 2 will host Stripe
    Embedded Checkout — currently a placeholder explaining what env vars
    + backend endpoint will activate it. Step indicator transitions
    1-active → 1-done/2-active with colour change. Cap value held in modal
    state, only posted to backend on Checkout success → no side-effects on
    cancel.

Dev preview route (saas/routes/DevPaygPreview.tsx):
  - Gated by import.meta.env.DEV in saas/App.tsx so production tree-shakes
    it out
  - Lets me iterate visually at /dev/payg-preview without going through
    auth + nav config
  - Four slots: free user promo, upgrade modal, subscribed LEADER, subscribed
    MEMBER. The two subscribed slots render Reece's existing Payg.tsx
    (PaygLeader / PaygMember) with mock data so the full surface is visible
  - DELETE before PR ships — flagged in the file's javadoc

Screenshots dropped during iteration:
  payg-flow-002-editor-promo-v1.png   (first cut)
  payg-flow-003-editor-promo-v2.png   (polished)
  payg-flow-004-upgrade-modal-step1.png
  payg-flow-005-upgrade-modal-step2.png
  payg-flow-006-modal-step2-polished.png
  payg-flow-007-full-promo-with-footer.png
  payg-flow-008-leader-dashboard.png

Next commits (same branch, same PR):
  - useWallet() hook wiring Payg.tsx to real /api/v1/payg/wallet endpoint
  - PlanSection.tsx that branches free → EditorPlanPromo / active → PaygLeader/Member
  - Backend Java: wallet endpoint + checkout endpoint + cap endpoint
  - Stripe Embedded Checkout wire-in (placeholder → real iframe)
  - i18n keys for all new copy
  - Remove DevPaygPreview before marking PR ready

No backend changes in this commit — pure visual iteration with mock data.
2026-06-08 21:36:25 +01:00
Connor Yoh e8fd67c004 Remove ?payg=1 demo-override hooks before merge
The PAYG settings screen is mock-only and must stay invisible to users
until the backend wallet endpoint (PR-C3.2) lands and sets
`appConfig.paygEnabled`. The demo overrides bypassed that gate via a URL
query param + localStorage, which would have let anyone with the link see
the mock data.

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