mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72e5283633 | ||
|
|
d049b7523f | ||
|
|
058f6d601a |
@@ -3065,6 +3065,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"
|
||||
@@ -5484,6 +5488,72 @@ 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.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"
|
||||
|
||||
[payg.role]
|
||||
leader = "Team owner"
|
||||
member = "Member"
|
||||
|
||||
[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.subcaps]
|
||||
subtitle = "Optional: cap individual teammates so one person can't drain the team budget."
|
||||
title = "Per-member sub-caps"
|
||||
|
||||
[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;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Modal, Button, Text, ActionIcon } from "@mantine/core";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import Overview from "@app/components/shared/config/configSections/Overview";
|
||||
@@ -107,14 +108,24 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
|
||||
|
||||
// Left navigation structure and icons
|
||||
const { config: appConfig } = useAppConfig();
|
||||
// PAYG settings screen is gated on the backend `appConfig.paygEnabled` flag,
|
||||
// which is currently unset everywhere — the screen is mock-only and hidden
|
||||
// from users until the wallet endpoint (PR-C3.2) lands.
|
||||
// Until team-role lookup lands, proxy LEADER from the tenant-admin flag.
|
||||
const paygEnabled = Boolean(appConfig?.paygEnabled);
|
||||
const isLeader = Boolean(appConfig?.isAdmin);
|
||||
|
||||
const configNavSections = useMemo(
|
||||
() =>
|
||||
createSaasConfigNavSections(Overview, openLogoutConfirm, {
|
||||
isDev,
|
||||
isAnonymous,
|
||||
paygEnabled,
|
||||
isLeader,
|
||||
t,
|
||||
}),
|
||||
[openLogoutConfirm, isDev, isAnonymous, t],
|
||||
[openLogoutConfirm, isDev, isAnonymous, paygEnabled, isLeader, t],
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
/**
|
||||
* Pay-as-you-go billing & usage section.
|
||||
*
|
||||
* Design mockup for the PAYG settings screen. Mocked data, no backend wiring.
|
||||
* Mirrors the framework in docs/PAYG design review:
|
||||
* - doc units as the billing unit (max(pages/25, bytes/10MB))
|
||||
* - customer sets cap in their own currency, backend translates to units
|
||||
* via POST /api/v1/payg/cap-preview
|
||||
* - wallet_policy fields: cap_units, cap_source_money, cap_source_currency,
|
||||
* warn_at_pct (default 80), degrade_at_pct (default 100)
|
||||
* - states: FULL → WARNED (80%) → DEGRADED (100%)
|
||||
* - gates: OFFSITE_PROCESSING, AUTOMATION, AI_SUPPORT, CLIENT_SIDE
|
||||
* - per-member sub-caps
|
||||
*/
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Button, Group, NumberInput, Select, Stack, Text } from "@mantine/core";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import LockIcon from "@mui/icons-material/LockOutlined";
|
||||
import CheckIcon from "@mui/icons-material/CheckRounded";
|
||||
import BoltIcon from "@mui/icons-material/BoltRounded";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
// Relative (not @app/*) so the co-located CSS resolves directly.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./Payg.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
type WalletState = "FULL" | "WARNED" | "DEGRADED";
|
||||
type Gate = "OFFSITE_PROCESSING" | "AUTOMATION" | "AI_SUPPORT" | "CLIENT_SIDE";
|
||||
|
||||
interface MemberSubCap {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
capUnits: number | null; // null = no sub-cap
|
||||
spendUnits: number;
|
||||
}
|
||||
|
||||
interface RecentProcess {
|
||||
id: string;
|
||||
ts: string;
|
||||
label: string;
|
||||
// Only billable categories appear in the feed — everyday tools are free.
|
||||
kind: "ai" | "automation";
|
||||
docUnits: number;
|
||||
}
|
||||
|
||||
interface PaygSnapshot {
|
||||
billingPeriodStart: string; // ISO date
|
||||
billingPeriodEnd: string;
|
||||
spendUnits: number;
|
||||
capUnits: number;
|
||||
capSourceMoney: number; // minor units (cents)
|
||||
capSourceCurrency: string;
|
||||
/**
|
||||
* Account credit in minor units — residual bought-credits / user_credits
|
||||
* migrated to a positive ledger ADJUSTMENT that offsets future debits
|
||||
* (design doc §11-Q8). 0 when the team has none.
|
||||
*/
|
||||
accountCreditMoney: number;
|
||||
warnAtPct: number;
|
||||
degradeAtPct: number;
|
||||
state: WalletState;
|
||||
enabledGates: Gate[];
|
||||
members: MemberSubCap[];
|
||||
recent: RecentProcess[];
|
||||
stripePortalUrl: string;
|
||||
}
|
||||
|
||||
interface PaygProps {
|
||||
role: "LEADER" | "MEMBER";
|
||||
}
|
||||
|
||||
// ─── Mock data hook (replace with real API later) ─────────────────────────
|
||||
|
||||
function usePaygMock(role: PaygProps["role"]): PaygSnapshot {
|
||||
return useMemo(() => {
|
||||
const capUnits = 2500;
|
||||
const spendUnits = 312;
|
||||
const pct = (spendUnits / capUnits) * 100;
|
||||
const state: WalletState =
|
||||
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
|
||||
// Period dates relative to today so the demo doesn't show "Resets in 0 days".
|
||||
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),
|
||||
spendUnits,
|
||||
capUnits,
|
||||
capSourceMoney: 5000,
|
||||
capSourceCurrency: "USD",
|
||||
accountCreditMoney: 1200,
|
||||
warnAtPct: 80,
|
||||
degradeAtPct: 100,
|
||||
state,
|
||||
enabledGates:
|
||||
state === "DEGRADED"
|
||||
? ["CLIENT_SIDE"]
|
||||
: ["OFFSITE_PROCESSING", "AUTOMATION", "AI_SUPPORT", "CLIENT_SIDE"],
|
||||
members:
|
||||
role === "LEADER"
|
||||
? [
|
||||
{
|
||||
userId: "u1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
capUnits: null,
|
||||
spendUnits: 184,
|
||||
},
|
||||
{
|
||||
userId: "u2",
|
||||
name: "Bob",
|
||||
email: "bob@example.com",
|
||||
capUnits: 500,
|
||||
spendUnits: 128,
|
||||
},
|
||||
{
|
||||
userId: "u3",
|
||||
name: "Carol",
|
||||
email: "carol@example.com",
|
||||
capUnits: null,
|
||||
spendUnits: 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
recent: [
|
||||
{
|
||||
id: "p1",
|
||||
ts: "Today 14:32",
|
||||
label: "AI Create — contract summary",
|
||||
kind: "ai",
|
||||
docUnits: 8,
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
ts: "Today 11:20",
|
||||
label: "Auto-redaction pipeline",
|
||||
kind: "automation",
|
||||
docUnits: 12,
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
ts: "Yesterday",
|
||||
label: "AI-OCR — scanned batch",
|
||||
kind: "ai",
|
||||
docUnits: 15,
|
||||
},
|
||||
{
|
||||
id: "p4",
|
||||
ts: "2 days ago",
|
||||
label: "Nightly compress automation",
|
||||
kind: "automation",
|
||||
docUnits: 6,
|
||||
},
|
||||
],
|
||||
stripePortalUrl: "https://billing.stripe.com/p/login/mock",
|
||||
};
|
||||
}, [role]);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const CURRENCY_SYMBOL: Record<string, string> = {
|
||||
USD: "$",
|
||||
GBP: "£",
|
||||
EUR: "€",
|
||||
CNY: "¥",
|
||||
INR: "₹",
|
||||
};
|
||||
|
||||
// Stable-ish avatar colour from a string.
|
||||
const AVATAR_COLORS = [
|
||||
"#0a8bff",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
"#10b981",
|
||||
"#f59e0b",
|
||||
"#06b6d4",
|
||||
];
|
||||
function avatarColor(seed: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
|
||||
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
function formatMoney(minor: number, currency: string): string {
|
||||
const major = minor / 100;
|
||||
const sym = CURRENCY_SYMBOL[currency] ?? "";
|
||||
return `${sym}${major.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function gateLabel(
|
||||
g: Gate,
|
||||
t: (k: string, fallback: string) => string,
|
||||
): string {
|
||||
switch (g) {
|
||||
case "OFFSITE_PROCESSING":
|
||||
return t(
|
||||
"payg.gates.offsite",
|
||||
"Server tools (compress, OCR, convert, watermark…)",
|
||||
);
|
||||
case "AUTOMATION":
|
||||
return t("payg.gates.automation", "Automations & pipelines");
|
||||
case "AI_SUPPORT":
|
||||
return t("payg.gates.ai", "AI tools (AI Create, suggestions, AI-OCR)");
|
||||
case "CLIENT_SIDE":
|
||||
return t(
|
||||
"payg.gates.client",
|
||||
"Browser-only tools (viewer, page editor, file management)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hero usage panel ───────────────────────────────────────────────────────
|
||||
|
||||
function UsageHero({ snap }: { snap: PaygSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.min(100, (snap.spendUnits / snap.capUnits) * 100);
|
||||
const stateLabel = {
|
||||
FULL: t("payg.state.full", "Healthy"),
|
||||
WARNED: t("payg.state.warned", "Approaching cap"),
|
||||
DEGRADED: t("payg.state.degraded", "Cap reached"),
|
||||
}[snap.state];
|
||||
|
||||
const periodEnd = new Date(snap.billingPeriodEnd);
|
||||
const daysLeft = Math.max(
|
||||
0,
|
||||
Math.ceil((periodEnd.getTime() - Date.now()) / 86_400_000),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="payg-hero" data-state={snap.state}>
|
||||
<div className="payg-hero__inner">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<div className="payg-hero__eyebrow">
|
||||
{t("payg.usage.thisPeriod", "This billing period")}
|
||||
</div>
|
||||
<div className="payg-hero__figure">
|
||||
<span className="payg-hero__spend">
|
||||
{snap.spendUnits.toLocaleString()}
|
||||
</span>
|
||||
<span className="payg-hero__cap">
|
||||
/ {snap.capUnits.toLocaleString()}{" "}
|
||||
{t("payg.usage.docs", "documents")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="payg-status" data-state={snap.state}>
|
||||
<span className="payg-status__dot" />
|
||||
{stateLabel}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<div className="payg-bar">
|
||||
<div
|
||||
className="payg-bar__fill"
|
||||
data-state={snap.state}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="payg-hero__meta">
|
||||
<span>
|
||||
{t("payg.usage.spent", "{{spend}} of {{cap}} used", {
|
||||
spend: formatMoney(
|
||||
(snap.capSourceMoney * snap.spendUnits) / snap.capUnits,
|
||||
snap.capSourceCurrency,
|
||||
),
|
||||
cap: formatMoney(snap.capSourceMoney, snap.capSourceCurrency),
|
||||
})}
|
||||
</span>
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>
|
||||
{daysLeft === 1
|
||||
? t("payg.usage.resetsTomorrow", "Resets tomorrow")
|
||||
: t("payg.usage.resetsIn", "Resets in {{days}} days", {
|
||||
days: daysLeft,
|
||||
})}
|
||||
</span>
|
||||
{snap.accountCreditMoney > 0 && (
|
||||
<span className="payg-hero__credit">
|
||||
{t("payg.usage.credit", "{{amount}} account credit", {
|
||||
amount: formatMoney(
|
||||
snap.accountCreditMoney,
|
||||
snap.capSourceCurrency,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cap editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
function CapEditor({ snap }: { snap: PaygSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
// Local edit state — real impl would call POST /api/v1/payg/cap-preview on change
|
||||
const [money, setMoney] = useState<number>(snap.capSourceMoney / 100);
|
||||
const [currency, setCurrency] = useState<string>(snap.capSourceCurrency);
|
||||
const [warnAt, setWarnAt] = useState<number>(snap.warnAtPct);
|
||||
const [degradeAt, setDegradeAt] = useState<number>(snap.degradeAtPct);
|
||||
|
||||
// Mock preview — real impl reads stripe.prices.tiers via Sync Engine
|
||||
const previewUnits = Math.round(
|
||||
(money * 100 * snap.capUnits) / snap.capSourceMoney,
|
||||
);
|
||||
const dirty =
|
||||
money !== snap.capSourceMoney / 100 ||
|
||||
currency !== snap.capSourceCurrency ||
|
||||
warnAt !== snap.warnAtPct ||
|
||||
degradeAt !== snap.degradeAtPct;
|
||||
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<div className="payg-card__title">
|
||||
{t("payg.cap.title", "Monthly spending cap")}
|
||||
</div>
|
||||
<div className="payg-card__subtitle">
|
||||
{t(
|
||||
"payg.cap.subtitle",
|
||||
"Set your maximum spend. We convert this to documents using the current price tier.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Group align="flex-end" gap="sm" wrap="nowrap">
|
||||
<NumberInput
|
||||
label={t("payg.cap.amount", "Cap amount")}
|
||||
value={money}
|
||||
onChange={(v) => setMoney(typeof v === "number" ? v : 0)}
|
||||
min={0}
|
||||
step={5}
|
||||
decimalScale={2}
|
||||
fixedDecimalScale
|
||||
prefix={CURRENCY_SYMBOL[currency] ?? ""}
|
||||
size="md"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
label={t("payg.cap.currency", "Currency")}
|
||||
value={currency}
|
||||
onChange={(v) => setCurrency(v ?? "USD")}
|
||||
// V1 launch currencies per design doc §11-Q9 (USD/EUR/GBP).
|
||||
// Adding a fourth is one Stripe Price + a config update, no code change.
|
||||
data={[
|
||||
{ value: "USD", label: "USD ($)" },
|
||||
{ value: "EUR", label: "EUR (€)" },
|
||||
{ value: "GBP", label: "GBP (£)" },
|
||||
]}
|
||||
size="md"
|
||||
w={150}
|
||||
/>
|
||||
<Text size="sm" pb={10} c="dimmed">
|
||||
{t("payg.cap.perMonth", "/ month")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<div className="payg-preview">
|
||||
<span className="payg-preview__icon">
|
||||
<BoltIcon sx={{ fontSize: 20 }} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="payg-preview__main">
|
||||
{t(
|
||||
"payg.cap.preview",
|
||||
"{{money}} ≈ {{units}} documents per month",
|
||||
{
|
||||
money: `${CURRENCY_SYMBOL[currency] ?? ""}${money.toFixed(2)}`,
|
||||
units: previewUnits.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="payg-preview__note">
|
||||
{t(
|
||||
"payg.cap.previewNote",
|
||||
"At current pricing. Final translation happens server-side on save.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<NumberInput
|
||||
label={t("payg.cap.warnAt", "Warn me at")}
|
||||
description={t(
|
||||
"payg.cap.warnAtDesc",
|
||||
"Notify when usage crosses this threshold.",
|
||||
)}
|
||||
value={warnAt}
|
||||
onChange={(v) => setWarnAt(typeof v === "number" ? v : 80)}
|
||||
min={0}
|
||||
max={100}
|
||||
suffix=" %"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("payg.cap.degradeAt", "Limit spend at")}
|
||||
description={t(
|
||||
"payg.cap.degradeAtDesc",
|
||||
"Set below 100% to leave yourself headroom.",
|
||||
)}
|
||||
value={degradeAt}
|
||||
onChange={(v) => setDegradeAt(typeof v === "number" ? v : 100)}
|
||||
min={Math.max(1, warnAt)}
|
||||
max={100}
|
||||
suffix=" %"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={!dirty}
|
||||
onClick={() => {
|
||||
setMoney(snap.capSourceMoney / 100);
|
||||
setCurrency(snap.capSourceCurrency);
|
||||
setWarnAt(snap.warnAtPct);
|
||||
setDegradeAt(snap.degradeAtPct);
|
||||
}}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={!dirty}
|
||||
leftSection={<LocalIcon icon="check-rounded" />}
|
||||
>
|
||||
{t("payg.cap.save", "Update cap")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapReadOnly({ snap }: { snap: PaygSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="sm">
|
||||
<div className="payg-card__title">
|
||||
{t("payg.cap.title", "Monthly spending cap")}
|
||||
</div>
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text fz={28} fw={750} lh={1}>
|
||||
{formatMoney(snap.capSourceMoney, snap.capSourceCurrency)}
|
||||
</Text>
|
||||
<Text c="dimmed">{t("payg.cap.perMonth", "/ month")}</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"payg.cap.previewShort",
|
||||
"≈ {{units}} documents per month at current pricing.",
|
||||
{ units: snap.capUnits.toLocaleString() },
|
||||
)}
|
||||
</Text>
|
||||
<div className="payg-preview" style={{ marginTop: 6 }}>
|
||||
<div>
|
||||
<div className="payg-preview__main">
|
||||
{t(
|
||||
"payg.member.askLeader",
|
||||
"Only your team owner can change the cap.",
|
||||
)}
|
||||
</div>
|
||||
<Button mt={8} size="xs" variant="light">
|
||||
{t("payg.member.contactLeader", "Ask team owner to raise cap")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Gates ────────────────────────────────────────────────────────────────
|
||||
|
||||
// What still works once the cap is hit. Everyday tools keep running; only AI
|
||||
// and automation/pipelines pause until the cap resets or is raised.
|
||||
const GATE_CAP_BEHAVIOR: { gate: Gate; staysAtCap: boolean }[] = [
|
||||
{ gate: "CLIENT_SIDE", staysAtCap: true },
|
||||
{ gate: "OFFSITE_PROCESSING", staysAtCap: true },
|
||||
{ gate: "AUTOMATION", staysAtCap: false },
|
||||
{ gate: "AI_SUPPORT", staysAtCap: false },
|
||||
];
|
||||
|
||||
function GatesCard() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<div className="payg-card__title">
|
||||
{t("payg.gates.title", "What happens when the cap is reached")}
|
||||
</div>
|
||||
<div className="payg-card__subtitle">
|
||||
{t(
|
||||
"payg.gates.subtitle",
|
||||
"Your everyday tools keep working. Only AI and automation pause until the cap resets or is raised.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="payg-gates">
|
||||
{GATE_CAP_BEHAVIOR.map(({ gate, staysAtCap }) => (
|
||||
<div className="payg-gate" data-enabled={staysAtCap} key={gate}>
|
||||
<span className="payg-gate__chip">
|
||||
{staysAtCap ? (
|
||||
<CheckIcon sx={{ fontSize: 18 }} />
|
||||
) : (
|
||||
<LockIcon sx={{ fontSize: 16 }} />
|
||||
)}
|
||||
</span>
|
||||
<span className="payg-gate__label">{gateLabel(gate, t)}</span>
|
||||
{!staysAtCap && (
|
||||
<span className="payg-gate__tag" data-variant="pause">
|
||||
{t("payg.gates.pauses", "pauses at cap")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Member sub-caps ────────────────────────────────────────────────────────
|
||||
|
||||
function MemberSubCaps({ snap }: { snap: PaygSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<div className="payg-card__title">
|
||||
{t("payg.subcaps.title", "Per-member sub-caps")}
|
||||
</div>
|
||||
<div className="payg-card__subtitle">
|
||||
{t(
|
||||
"payg.subcaps.subtitle",
|
||||
"Optional: cap individual teammates so one person can't drain the team budget.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{snap.members.map((m) => {
|
||||
const subPct =
|
||||
m.capUnits && m.capUnits > 0
|
||||
? Math.min(100, (m.spendUnits / m.capUnits) * 100)
|
||||
: null;
|
||||
return (
|
||||
<div className="payg-member" key={m.userId}>
|
||||
<span
|
||||
className="payg-member__avatar"
|
||||
style={{ background: avatarColor(m.userId) }}
|
||||
>
|
||||
{m.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="payg-member__name">{m.name}</div>
|
||||
<div className="payg-member__email">{m.email}</div>
|
||||
</div>
|
||||
<div className="payg-member__usage">
|
||||
<div className="payg-member__usage-num">
|
||||
{m.spendUnits.toLocaleString()}
|
||||
{m.capUnits !== null ? (
|
||||
<> / {m.capUnits.toLocaleString()}</>
|
||||
) : (
|
||||
<Text span size="xs" c="dimmed">
|
||||
{" "}
|
||||
{t("payg.member.noCap", "· no sub-cap")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{subPct !== null && (
|
||||
<div className="payg-member__minibar">
|
||||
<div
|
||||
className="payg-member__minibar-fill"
|
||||
style={{ width: `${subPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button size="xs" variant="default">
|
||||
{m.capUnits === null
|
||||
? t("payg.member.setCap", "Set cap")
|
||||
: t("payg.member.editCap", "Edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Activity feed ──────────────────────────────────────────────────────────
|
||||
|
||||
function ActivityFeed({ snap }: { snap: PaygSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="payg-card__title">
|
||||
{t("payg.activity.title", "Recent billable activity")}
|
||||
</div>
|
||||
<div className="payg-card__subtitle">
|
||||
{t(
|
||||
"payg.activity.subtitle",
|
||||
"Only AI and automation draw from your budget. Everyday tools are free and aren't listed here.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="default" size="xs" style={{ flexShrink: 0 }}>
|
||||
{t("payg.activity.viewAll", "View all")}
|
||||
</Button>
|
||||
</Group>
|
||||
<div>
|
||||
{snap.recent.map((r) => (
|
||||
<div className="payg-activity-row" key={r.id}>
|
||||
<span className="payg-activity__dot" data-kind={r.kind} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="payg-activity__label">{r.label}</div>
|
||||
<div className="payg-activity__ts">{r.ts}</div>
|
||||
</div>
|
||||
<span className="payg-activity__kind">{r.kind}</span>
|
||||
<span className="payg-activity__units">
|
||||
{r.docUnits} {t("payg.activity.units", "units")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stripe CTA ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StripePortalLink({ snap }: { snap: PaygSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="payg-stripe">
|
||||
<div>
|
||||
<div className="payg-stripe__title">
|
||||
{t("payg.stripe.title", "Manage billing in Stripe")}
|
||||
</div>
|
||||
<div className="payg-stripe__subtitle">
|
||||
{t(
|
||||
"payg.stripe.subtitle",
|
||||
"Receipts, invoices, payment method, billing currency.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
component="a"
|
||||
href={snap.stripePortalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rightSection={<OpenInNewIcon sx={{ fontSize: 16 }} />}
|
||||
variant="light"
|
||||
>
|
||||
{t("payg.stripe.open", "Open billing portal")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────
|
||||
|
||||
const Payg: React.FC<PaygProps> = ({ role }) => {
|
||||
const { t } = useTranslation();
|
||||
const snap = usePaygMock(role);
|
||||
const isLeader = role === "LEADER";
|
||||
|
||||
const fmt = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="payg">
|
||||
<Stack gap="lg">
|
||||
{/* The modal chrome already renders the section title ("Billing &
|
||||
usage"), so we lead with the descriptive subtitle + role pill. */}
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<div className="payg-header__subtitle">
|
||||
{t(
|
||||
"payg.subtitle",
|
||||
"Pay-as-you-go — you only pay for what you process. Billing period {{start}} – {{end}}.",
|
||||
{
|
||||
start: fmt(snap.billingPeriodStart),
|
||||
end: fmt(snap.billingPeriodEnd),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<span className="payg-role-pill" data-leader={isLeader}>
|
||||
{isLeader
|
||||
? t("payg.role.leader", "Team owner")
|
||||
: t("payg.role.member", "Member")}
|
||||
</span>
|
||||
</Group>
|
||||
|
||||
<UsageHero snap={snap} />
|
||||
|
||||
{isLeader ? <CapEditor snap={snap} /> : <CapReadOnly snap={snap} />}
|
||||
|
||||
<GatesCard />
|
||||
|
||||
{isLeader && snap.members.length > 0 && <MemberSubCaps snap={snap} />}
|
||||
|
||||
<ActivityFeed snap={snap} />
|
||||
|
||||
{isLeader && <StripePortalLink snap={snap} />}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Payg;
|
||||
|
||||
// Convenience exports for the config nav to render either variant directly.
|
||||
export const PaygLeader: React.FC = () => <Payg role="LEADER" />;
|
||||
export const PaygMember: React.FC = () => <Payg role="MEMBER" />;
|
||||
@@ -9,12 +9,28 @@ import GeneralSection from "@app/components/shared/config/configSections/General
|
||||
import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity";
|
||||
import ApiKeys from "@app/components/shared/config/configSections/ApiKeys";
|
||||
import Plan from "@app/components/shared/config/configSections/Plan";
|
||||
import {
|
||||
PaygLeader,
|
||||
PaygMember,
|
||||
} from "@app/components/shared/config/configSections/Payg";
|
||||
|
||||
type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>;
|
||||
|
||||
interface CreateSaasConfigNavSectionsOptions {
|
||||
isDev?: boolean;
|
||||
isAnonymous?: boolean;
|
||||
/**
|
||||
* Show the new Pay-as-you-go billing section. Gated on
|
||||
* `appConfig.paygEnabled` so the tenant has to be opted in.
|
||||
*/
|
||||
paygEnabled?: boolean;
|
||||
/**
|
||||
* Whether the viewer is the team owner (LEADER) — controls whether the PAYG
|
||||
* screen shows editable cap/sub-cap controls or a read-only member view.
|
||||
* Until team roles land we proxy this from `appConfig.isAdmin`; replace with
|
||||
* a real team-role lookup when available.
|
||||
*/
|
||||
isLeader?: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
}
|
||||
|
||||
@@ -108,10 +124,40 @@ function appendBillingSection(
|
||||
];
|
||||
}
|
||||
|
||||
function appendPaygSection(
|
||||
sections: ConfigNavSection[],
|
||||
t: TFunction<"translation", undefined>,
|
||||
isLeader: boolean,
|
||||
): ConfigNavSection[] {
|
||||
if (sections.some((s) => s.items.some((i) => i.key === "payg"))) {
|
||||
return sections;
|
||||
}
|
||||
return [
|
||||
...sections,
|
||||
{
|
||||
title: t("config.payg.section", "Pay-as-you-go"),
|
||||
items: [
|
||||
{
|
||||
key: "payg",
|
||||
label: t("config.payg.label", "Billing & usage"),
|
||||
icon: "speed-rounded",
|
||||
component: isLeader ? <PaygLeader /> : <PaygMember />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createSaasConfigNavSections(
|
||||
Overview: OverviewComponent,
|
||||
onLogoutClick: () => void,
|
||||
{ isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
|
||||
{
|
||||
isDev = false,
|
||||
isAnonymous = false,
|
||||
paygEnabled = false,
|
||||
isLeader = false,
|
||||
t,
|
||||
}: CreateSaasConfigNavSectionsOptions,
|
||||
): ConfigNavSection[] {
|
||||
const baseSections = createCoreConfigNavSections(false, false, false);
|
||||
|
||||
@@ -154,6 +200,9 @@ export function createSaasConfigNavSections(
|
||||
|
||||
if (!isAnonymous) {
|
||||
sections = appendBillingSection(sections, t);
|
||||
if (paygEnabled) {
|
||||
sections = appendPaygSection(sections, t, isLeader);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
|
||||
Reference in New Issue
Block a user