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)
This commit is contained in:
Connor Yoh
2026-06-08 21:57:07 +01:00
parent a15cf5f90d
commit 2faa38c011
6 changed files with 589 additions and 493 deletions
@@ -1,249 +0,0 @@
/* Editor → Processor plan promo. Shown to free users in place of the PAYG
dashboard. Uses the same theme tokens as Payg.css. */
.epp {
--epp-accent: #0a8bff;
--epp-accent-2: #6c5ce7;
--epp-accent-soft: rgba(10, 139, 255, 0.08);
--epp-success: #10b981;
--epp-radius: 18px;
--epp-card-bg: var(--bg-surface);
--epp-card-border: var(--border-default);
--epp-divider: var(--border-subtle);
display: flex;
flex-direction: column;
gap: 28px;
padding: 4px 4px 24px;
}
[data-mantine-color-scheme="dark"] .epp {
--epp-card-bg: #313842;
--epp-card-border: #3d444e;
--epp-accent-soft: rgba(10, 139, 255, 0.12);
--epp-divider: #3d444e;
}
/* ── Hero ─────────────────────────────────────────────────────────────── */
.epp-hero {
text-align: center;
padding: 12px 0 0;
}
.epp-hero__eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
margin-bottom: 14px;
background: var(--epp-accent-soft);
color: var(--epp-accent);
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.epp-hero__title {
margin: 0;
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
color: var(--text-primary);
}
.epp-hero__title .epp-hero__accent {
background: linear-gradient(135deg, var(--epp-accent) 0%, var(--epp-accent-2) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.epp-hero__subtitle {
margin: 14px auto 0;
max-width: 540px;
font-size: 1rem;
line-height: 1.55;
color: var(--text-muted);
}
/* ── Cards row ────────────────────────────────────────────────────────── */
.epp-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
align-items: stretch;
}
@media (max-width: 720px) {
.epp-cards {
grid-template-columns: 1fr;
}
}
.epp-card {
background: var(--epp-card-bg);
border: 1px solid var(--epp-card-border);
border-radius: var(--epp-radius);
padding: 26px 26px 22px;
display: flex;
flex-direction: column;
gap: 14px;
position: relative;
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease;
}
.epp-card[data-emphasis="true"] {
border-color: transparent;
background:
linear-gradient(var(--epp-card-bg), var(--epp-card-bg)) padding-box,
linear-gradient(135deg, var(--epp-accent) 0%, var(--epp-accent-2) 100%) border-box;
border: 1.5px solid transparent;
box-shadow: 0 10px 28px -16px rgba(10, 139, 255, 0.35);
}
.epp-card[data-emphasis="true"]:hover {
transform: translateY(-2px);
box-shadow: 0 18px 40px -16px rgba(10, 139, 255, 0.45);
}
/* Card header — name + inline status pill on the same row, no orphan badges */
.epp-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 2px;
}
.epp-card__name {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.005em;
color: var(--text-primary);
}
.epp-card__pill {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 3px 9px;
border-radius: 999px;
}
.epp-card__pill[data-tone="current"] {
background: var(--epp-divider);
color: var(--text-muted);
}
.epp-card__pill[data-tone="upgrade"] {
background: linear-gradient(135deg, var(--epp-accent) 0%, var(--epp-accent-2) 100%);
color: white;
}
.epp-card__price {
display: flex;
align-items: baseline;
gap: 6px;
margin: 4px 0 -2px;
}
.epp-card__price-amount {
font-size: 2.1rem;
font-weight: 700;
letter-spacing: -0.025em;
color: var(--text-primary);
line-height: 1;
}
.epp-card__price-period {
font-size: 0.875rem;
color: var(--text-muted);
}
.epp-card__price-sub {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.epp-card__tagline {
font-size: 0.875rem;
color: var(--text-muted);
line-height: 1.45;
}
.epp-card__features {
list-style: none;
margin: 0;
padding: 12px 0 4px;
display: flex;
flex-direction: column;
gap: 9px;
flex-grow: 1;
border-top: 1px solid var(--epp-divider);
}
.epp-card__feature {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 0.875rem;
color: var(--text-primary);
line-height: 1.4;
}
.epp-card__feature-icon {
flex-shrink: 0;
margin-top: 1px;
color: var(--epp-success);
font-size: 1rem;
}
.epp-card[data-emphasis="true"] .epp-card__feature-icon {
color: var(--epp-accent);
}
.epp-card__cta {
display: block;
width: 100%;
padding: 13px 18px;
border-radius: 11px;
border: none;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
margin-top: 10px;
}
.epp-card__cta[data-variant="primary"] {
background: linear-gradient(135deg, var(--epp-accent) 0%, var(--epp-accent-2) 100%);
color: white;
}
.epp-card__cta[data-variant="primary"]:hover {
transform: translateY(-1px);
box-shadow: 0 8px 22px -6px rgba(10, 139, 255, 0.55);
}
.epp-card__cta[data-variant="neutral"] {
background: transparent;
color: var(--text-muted);
border: 1px dashed var(--epp-divider);
cursor: default;
}
.epp-card__footnote {
font-size: 0.75rem;
color: var(--text-muted);
text-align: center;
margin-top: 4px;
}
/* ── Reassurance strip ────────────────────────────────────────────────── */
.epp-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px 20px;
background: var(--epp-accent-soft);
border-radius: 12px;
font-size: 0.875rem;
color: var(--text-primary);
line-height: 1.5;
text-align: center;
flex-wrap: wrap;
}
.epp-footer__highlight {
font-weight: 700;
}
.epp-footer__divider {
color: var(--text-muted);
opacity: 0.5;
margin: 0 4px;
}
@@ -1,174 +0,0 @@
/**
* Editor → Processor plan promo page.
*
* Shown to free (unsubscribed) users in the Plan / Billing nav slot, in place of
* the {@code Payg.tsx} dashboard that subscribed users see. The single job of this
* screen is to make the "editing is always free; you only pay when our servers
* process documents" promise unmissable, and to make the upgrade flow one click
* away.
*
* Marketing names (per 2026-06 product call):
* - "Editor plan" = free; everything that runs in the browser
* - "Processor plan" = pay-as-you-go; server-side jobs, AI, automation
*
* Code keeps the internal name PAYG. Renaming the FE strings later is a one-place
* change via the i18n keys this component reads.
*/
import React from "react";
import CheckIcon from "@mui/icons-material/CheckRounded";
import BoltIcon from "@mui/icons-material/BoltRounded";
import ShieldIcon from "@mui/icons-material/ShieldOutlined";
// eslint-disable-next-line no-restricted-imports
import "./EditorPlanPromo.css";
interface EditorPlanPromoProps {
/**
* Called when the user clicks the "Upgrade to Processor" CTA. Parent owns the
* modal so the promo stays presentation-only.
*/
onUpgradeClick: () => void;
}
const EDITOR_FEATURES = [
"View any PDF — fast in-browser viewer",
"Edit pages: reorder, split, merge",
"Annotate, highlight, redact",
"Fill and sign forms",
"Add images, watermarks, page numbers",
"Export PDFs and password-protect",
];
const PROCESSOR_FEATURES = [
"Everything in the Editor plan",
"OCR — extract text from scans",
"Compress + convert (Word, Excel, images, …)",
"AI tools — summarise, redact, classify, AI-OCR",
"Server-side automation pipelines",
"Batch processing on the server",
];
export default function EditorPlanPromo({
onUpgradeClick,
}: EditorPlanPromoProps) {
return (
<div className="epp" data-testid="editor-plan-promo">
{/* Hero — anchors the promise. Eyebrow pill + headline + supporting line. */}
<header className="epp-hero">
<div className="epp-hero__eyebrow">
<ShieldIcon style={{ fontSize: "0.85rem" }} />
No card needed to edit
</div>
<h2 className="epp-hero__title">
Editing is{" "}
<span className="epp-hero__accent">free, forever.</span>
</h2>
<p className="epp-hero__subtitle">
You only pay when our servers process documents — OCR, conversions,
AI, automation. Everything else stays free.
</p>
</header>
{/* Two-card comparison. Left is the current plan (neutral); right is the
upgrade option (gradient border + interactive). */}
<div className="epp-cards">
<article className="epp-card" data-testid="editor-plan-card">
<div className="epp-card__head">
<span className="epp-card__name">Editor plan</span>
<span className="epp-card__pill" data-tone="current">
Current
</span>
</div>
<div className="epp-card__price">
<span className="epp-card__price-amount">Free</span>
<span className="epp-card__price-period">forever</span>
</div>
<p className="epp-card__tagline">
Everything that runs in your browser. No account upgrade required.
</p>
<ul className="epp-card__features">
{EDITOR_FEATURES.map((feature) => (
<li key={feature} className="epp-card__feature">
<CheckIcon className="epp-card__feature-icon" fontSize="small" />
<span>{feature}</span>
</li>
))}
</ul>
<button
type="button"
className="epp-card__cta"
data-variant="neutral"
disabled
>
You're on this plan
</button>
<p className="epp-card__footnote">No payment method required.</p>
</article>
<article
className="epp-card"
data-emphasis="true"
data-testid="processor-plan-card"
>
<div className="epp-card__head">
<span className="epp-card__name">
<BoltIcon
style={{
fontSize: "1.05rem",
marginRight: 4,
verticalAlign: "-3px",
color: "var(--epp-accent)",
}}
/>
Processor plan
</span>
<span className="epp-card__pill" data-tone="upgrade">
Upgrade
</span>
</div>
<div className="epp-card__price">
<span className="epp-card__price-amount">Pay-as-you-go</span>
</div>
<p className="epp-card__price-sub">
From $0.02 per processed document · monthly cap is yours to set
</p>
<p className="epp-card__tagline">
Unlock server-side tools. You set a monthly ceiling; you only pay
for what's actually processed.
</p>
<ul className="epp-card__features">
{PROCESSOR_FEATURES.map((feature) => (
<li key={feature} className="epp-card__feature">
<CheckIcon className="epp-card__feature-icon" fontSize="small" />
<span>{feature}</span>
</li>
))}
</ul>
<button
type="button"
className="epp-card__cta"
data-variant="primary"
onClick={onUpgradeClick}
data-testid="upgrade-cta"
>
Upgrade to Processor →
</button>
<p className="epp-card__footnote">
Set your monthly ceiling · Cancel anytime
</p>
</article>
</div>
{/* Bottom reinforcement strip. Belt to the hero's braces. */}
<footer className="epp-footer">
<span className="epp-footer__highlight">
The Editor stays free regardless.
</span>
<span className="epp-footer__divider">·</span>
<span>
Processor billing only kicks in when you upload a document for our
servers to process — and only above your free-tier allowance.
</span>
</footer>
</div>
);
}
@@ -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,333 @@
/**
* 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. Anything done
* directly via the Stirling UI — viewing, editing, signing, merging, splitting,
* conversion, OCR, compression when used as an interactive click — is
* unmetered. The 500/month free allowance applies <em>only</em> to those 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";
// 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 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"
? "Limit reached"
: state === "WARNED"
? "Approaching limit"
: "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">This billing period</div>
<div className="payg-hero__figure">
<span className="payg-hero__spend">
{snap.billableUsed.toLocaleString()}
</span>
<span className="payg-hero__cap">
/ {snap.billableLimit.toLocaleString()} free operations
</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>Automation · AI · API requests</span>
<span className="payg-hero__meta-dot">•</span>
<span>
{daysLeft === 1
? "Resets tomorrow"
: `Resets in ${daysLeft} days`}
</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) {
return (
<Group justify="space-between" align="center" wrap="nowrap">
<div className="payg-header__subtitle">
Editor plan — everything in the Stirling UI is free. Pay only for
automation, AI &amp; API. Billing 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 function PaygFreeLeader() {
const snap = useFreeMock();
const [upgradeOpen, setUpgradeOpen] = useState(false);
return (
<div className="payg">
<Stack gap="lg">
<SectionHeader snap={snap} pill="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">Turn on the Processor plan</h3>
<p className="paygf-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>Automation pipelines</strong> — chain tools, schedule
runs, batch process
</span>
</li>
<li>
<CheckIcon className="paygf-cta__check" fontSize="small" />
<span>
<strong>AI tools</strong> — summarise, classify, redact, AI-OCR
</span>
</li>
<li>
<CheckIcon className="paygf-cta__check" fontSize="small" />
<span>
<strong>API access</strong> — 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"
>
Turn on Processor →
</button>
<span className="paygf-cta__reassurance">
From $5/month · Cancel anytime · Set your own cap first
</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"
/>
Always free
</div>
<p className="paygf-explainer__text">
Anything you click in the Stirling UI: viewing, editing, merging,
splitting, signing, watermarks, compression, conversion, OCR via
the interactive UI — never counted, never capped.
</p>
</div>
<div className="paygf-explainer__col">
<div className="paygf-explainer__label">
<BoltIcon
className="paygf-explainer__icon paygf-explainer__icon--paid"
fontSize="small"
/>
Counts toward 500/month
</div>
<p className="paygf-explainer__text">
Automation pipelines (running tools without UI clicks), 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);
// eslint-disable-next-line no-alert
alert(
`Demo: subscription complete. Cap = ${
capUsd === null ? "no cap" : `$${capUsd}/mo`
}. Real flow refreshes the wallet snapshot here.`,
);
}}
/>
</div>
);
}
// ─── Free MEMBER: hero + ask-owner note (no CTA) ─────────────────────────
export function PaygFreeMember() {
const snap = useFreeMock();
return (
<div className="payg">
<Stack gap="lg">
<SectionHeader snap={snap} pill="Team member" />
<FreeHero snap={snap} />
<div className="paygf-member-note">
<LockIcon className="paygf-member-note__icon" />
<div>
<h3 className="paygf-member-note__title">
Want more than 500 automation, AI, or API operations a month?
</h3>
<p className="paygf-member-note__body">
Your team owner can enable the Processor plan and set a monthly
ceiling. Until then, everything you can do in the Stirling UI is
free — 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"
/>
Always free for you
</div>
<p className="paygf-explainer__text">
Anything you click in the Stirling UI: viewing, editing, merging,
splitting, signing, watermarks, compression, conversion, OCR via
the interactive UI — 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"
/>
Shared with the team
</div>
<p className="paygf-explainer__text">
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>
);
}
@@ -205,16 +205,19 @@ function CapStep({ capUsd, setCapUsd, noCap, setNoCap, currency }: CapStepProps)
<div className="upm-promise">
<ShieldIcon className="upm-promise__icon" fontSize="small" />
<div>
<span className="upm-promise__highlight">Editing stays free.</span>{" "}
You only pay when our servers process documents — OCR, conversions,
AI tools, automation. Browsing and editing in your browser never
count toward your spend.
<span className="upm-promise__highlight">
Everything in the Stirling UI stays free.
</span>{" "}
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. Use the editor as much as you want, no charge.
</div>
</div>
<h3 className="upm-section-title">Set your monthly spend ceiling</h3>
<p className="upm-section-help">
We'll never charge above this. Free-tier documents don't count.
We'll never charge above this. Your first 500 automation / AI / API
operations every month are free.
</p>
<div className="upm-cap-presets" role="radiogroup" aria-label="Monthly cap preset">
@@ -275,12 +278,25 @@ function CapStep({ capUsd, setCapUsd, noCap, setNoCap, currency }: CapStepProps)
</label>
<div className="upm-help-card">
<span className="upm-help-card__title">
Documents we count toward billing
</span>
Server-side jobs: OCR, format conversion, compression, AI tools,
automation pipelines, batch processing. Anything where Stirling's
servers do real work on your file.
<span className="upm-help-card__title">What we count toward billing</span>
<ul style={{ margin: "4px 0 0", paddingLeft: 18, lineHeight: 1.55 }}>
<li>
<strong>Automation pipelines</strong> — chained tools or scheduled
runs that don't need clicks
</li>
<li>
<strong>AI tools</strong> — summarise, classify, redact, AI-OCR
</li>
<li>
<strong>API calls</strong> — programmatic access to any Stirling
endpoint
</li>
</ul>
<div style={{ marginTop: 8, fontStyle: "italic" }}>
Anything you click directly in the Stirling UI — viewing, editing,
merging, splitting, signing, watermarking, compressing, OCR via the
UI — is always free, even past 500.
</div>
</div>
</>
);
@@ -2,31 +2,39 @@
* DEV-ONLY preview route for the new PAYG plan + upgrade flow UI. NOT shipped
* to production — gated by {@code import.meta.env.DEV} in {@code App.tsx}.
*
* Purpose: lets me iterate on the visual design of EditorPlanPromo +
* UpgradeModal + the wired-up Payg dashboard without going through auth + the
* config-modal nav. Each preview slot just renders one of the new components
* with mock data and a state toggle.
* Purpose: lets me iterate on the visual design of PaygFreeLeader,
* PaygFreeMember, and the upgrade modal without going through auth + the
* config-modal nav. Each preview slot just renders one component with mock
* data; the slot toggle at the top is the only chrome.
*
* Delete this file (and its route registration in App.tsx) before the bundle
* PR is marked ready-for-review.
*/
import React, { useState } from "react";
import EditorPlanPromo from "@app/components/shared/config/configSections/EditorPlanPromo";
import UpgradeModal from "@app/components/shared/config/configSections/UpgradeModal";
import { PaygLeader, PaygMember } from "@app/components/shared/config/configSections/Payg";
import {
PaygLeader,
PaygMember,
} from "@app/components/shared/config/configSections/Payg";
import {
PaygFreeLeader,
PaygFreeMember,
} from "@app/components/shared/config/configSections/PaygFree";
type PreviewSlot = "free" | "upgrade-modal" | "active-leader" | "active-member";
type PreviewSlot =
| "free-leader"
| "free-member"
| "subscribed-leader"
| "subscribed-member";
const SLOT_LABELS: Record<PreviewSlot, string> = {
free: "Free user — Editor plan promo",
"upgrade-modal": "Upgrade modal (cap → Stripe)",
"active-leader": "Subscribed — LEADER dashboard",
"active-member": "Subscribed — MEMBER dashboard",
"free-leader": "Free · LEADER",
"free-member": "Free · MEMBER",
"subscribed-leader": "Subscribed · LEADER",
"subscribed-member": "Subscribed · MEMBER",
};
export default function DevPaygPreview() {
const [slot, setSlot] = useState<PreviewSlot>("free");
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [slot, setSlot] = useState<PreviewSlot>("free-leader");
return (
<div
@@ -55,7 +63,7 @@ export default function DevPaygPreview() {
type="button"
onClick={() => setSlot(s)}
style={{
padding: "6px 12px",
padding: "6px 14px",
borderRadius: 6,
border: "1px solid #d8dce1",
background: slot === s ? "#2563eb" : "white",
@@ -72,51 +80,12 @@ export default function DevPaygPreview() {
Not shipped to production. Removed before PR merge.
</span>
</header>
<main
style={{ maxWidth: 1100, margin: "0 auto", padding: "32px 24px" }}
>
{slot === "free" && (
<EditorPlanPromo onUpgradeClick={() => setUpgradeOpen(true)} />
)}
{slot === "upgrade-modal" && (
<div>
<p style={{ color: "#666", marginBottom: 16 }}>
The modal opens over whatever is behind it. Click below to
re-open if you've closed it.
</p>
<button
type="button"
onClick={() => setUpgradeOpen(true)}
style={{
padding: "10px 18px",
background: "#2563eb",
color: "white",
border: "none",
borderRadius: 8,
fontWeight: 600,
cursor: "pointer",
}}
>
Open upgrade modal
</button>
</div>
)}
{slot === "active-leader" && <PaygLeader />}
{slot === "active-member" && <PaygMember />}
<main style={{ maxWidth: 920, margin: "0 auto", padding: "32px 24px" }}>
{slot === "free-leader" && <PaygFreeLeader />}
{slot === "free-member" && <PaygFreeMember />}
{slot === "subscribed-leader" && <PaygLeader />}
{slot === "subscribed-member" && <PaygMember />}
</main>
<UpgradeModal
open={upgradeOpen}
onClose={() => setUpgradeOpen(false)}
onComplete={({ capUsd }) => {
setUpgradeOpen(false);
// eslint-disable-next-line no-alert
alert(
`Demo: subscription complete. Cap = ${
capUsd === null ? "no cap" : `$${capUsd}/mo`
}. Real flow would refresh the wallet snapshot here.`,
);
}}
/>
</div>
);
}