update saas onboarding (#6619)

<img width="1002" height="487" alt="Screenshot 2026-06-11 at 6 20 10 PM"
src="https://github.com/user-attachments/assets/5ee3cfc2-6c4f-4b35-9586-ef45fa216c6a"
/>
This commit is contained in:
EthanHealy01
2026-06-11 19:39:02 +01:00
committed by GitHub
parent 1d598d5caa
commit 33026e1a82
27 changed files with 659 additions and 413 deletions
@@ -50,12 +50,9 @@ help = "Help"
imgPrompt = "Select Image(s)" imgPrompt = "Select Image(s)"
incorrectPasswordMessage = "Current password is incorrect." incorrectPasswordMessage = "Current password is incorrect."
info = "Info" info = "Info"
insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}"
invalidUndoData = "Cannot undo: invalid operation data" invalidUndoData = "Cannot undo: invalid operation data"
keepWorking = "Keep Working" keepWorking = "Keep Working"
loading = "Loading..." loading = "Loading..."
loadingCredits = "Checking credits..."
loadingProStatus = "Checking subscription status..."
logOut = "Log out" logOut = "Log out"
marginTooltip = "Distance between the page number and the edge of the page." marginTooltip = "Distance between the page number and the edge of the page."
moreOptions = "More Options" moreOptions = "More Options"
@@ -66,7 +63,6 @@ noFileSelected = "No file loaded. Please upload one."
noFilesToUndo = "Cannot undo: no files were processed in the last operation" noFilesToUndo = "Cannot undo: no files were processed in the last operation"
noOperationToUndo = "No operation to undo" noOperationToUndo = "No operation to undo"
nothingToUndo = "Nothing to undo" nothingToUndo = "Nothing to undo"
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
noValidFiles = "No valid files to process" noValidFiles = "No valid files to process"
oops = "Oops!" oops = "Oops!"
openInNewWindow = "Open in new window" openInNewWindow = "Open in new window"
@@ -3099,18 +3095,6 @@ integration = "Integration Configuration"
security = "Security Configuration" security = "Security Configuration"
system = "System Configuration" system = "System Configuration"
[config.payg]
label = "Billing & usage"
section = "Pay-as-you-go"
[connectionMode.status]
localOffline = "Offline mode running"
localOnline = "Offline mode running"
saas = "Connected to Stirling Cloud"
selfhostedChecking = "Connected to self-hosted server (checking...)"
selfhostedOffline = "Self-hosted server unreachable"
selfhostedOnline = "Connected to self-hosted server"
[convert] [convert]
autoRotate = "Auto Rotate" autoRotate = "Auto Rotate"
autoRotateDescription = "Automatically rotate images to better fit the PDF page" autoRotateDescription = "Automatically rotate images to better fit the PDF page"
@@ -4903,15 +4887,6 @@ body = "Stirling works best as a desktop app. You can use it offline, access doc
title = "Download" title = "Download"
titleWithOs = "Download for {{osLabel}}" titleWithOs = "Download for {{osLabel}}"
[onboarding.freeTrial]
afterTrialWithoutPayment = "After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access."
afterTrialWithPayment = "Your Pro subscription will start automatically when the trial ends."
body = "You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing."
daysRemaining = "{{days}} days remaining"
daysRemainingSingular = "{{days}} day remaining"
title = "Your 30-Day Pro Trial"
trialEnds = "Trial ends {{date}}"
[onboarding.mfa] [onboarding.mfa]
authenticationCode = "Authentication code" authenticationCode = "Authentication code"
qrCodeLoading = "Generating your QR code…" qrCodeLoading = "Generating your QR code…"
@@ -4923,6 +4898,22 @@ adminTitle = "Admin Overview"
userBody = "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use." userBody = "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use."
userTitle = "Plan Overview" userTitle = "Plan Overview"
[onboarding.saas.freeEditor]
freeLine = "And the best part? The editor is now <free>completely free</free>."
premium = "We've added a whole host of new features, including <strong>Policies</strong> and <strong>Agent Chat</strong>."
title = "Welcome to Stirling"
[onboarding.saas.team]
addButton = "Add"
createBody = "Work on documents together — teammates share files, automations and your plan. Add the first member by email to create your team."
createTitle = "Create your team"
inviteBody = "Everyone on your team shares files, automations and your plan. Add teammates by email — they'll get an invite right away."
inviteTitle = "Invite members to your team"
[onboarding.saas.usage]
body = "Automations, AI and API requests draw from your one-time free allowance. Here's where you stand - manual editing never counts against it."
title = "Your free Processor allowance"
[onboarding.securityCheck] [onboarding.securityCheck]
message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue." message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue."
roleAdmin = "Admin" roleAdmin = "Admin"
@@ -5193,50 +5184,24 @@ title = "Page Ranges"
bullet1 = "<strong>all</strong> → selects all pages" bullet1 = "<strong>all</strong> → selects all pages"
title = "Special Keywords" title = "Special Keywords"
[pageSelection.tooltip.syntax]
text = "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported."
title = "Syntax Basics"
[pageSelection.tooltip.syntax.bullets]
keywords = "Keywords: odd, even"
numbers = "Numbers/ranges: 5, 10-20"
progressions = "Progressions: 3n, 4n+1"
[pageSelection.tooltip.tips]
bullet1 = "Page numbers start from 1 (not 0)"
bullet2 = "Spaces are automatically removed"
bullet3 = "Invalid expressions are ignored"
text = "Keep these guidelines in mind:"
title = "Tips"
[payg]
subtitle = "Pay-as-you-go: you only pay for what you process. Billing period {{start}} {{end}}."
[payg.activity] [payg.activity]
docs = "docs"
empty = "No billable activity yet this period."
subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here." subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here."
title = "Recent billable activity" title = "Recent billable activity"
units = "units"
viewAll = "View all"
[payg.cap] [payg.cap]
amount = "Cap amount" amount = "Cap amount"
currency = "Currency"
custom = "Custom" custom = "Custom"
degradeAt = "Limit spend at"
degradeAtDesc = "Set below 100% to leave yourself headroom."
docsEstimate = "≈ {{docs}} processed PDFs / month" docsEstimate = "≈ {{docs}} processed PDFs / month"
docsRate = "at {{rate}} / PDF" docsRate = "at {{rate}} / PDF"
noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time." noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time."
noCapLabel = "No cap" noCapLabel = "No cap"
noneShort = "No cap"
perMonth = "/ month" perMonth = "/ month"
preview = "{{money}} ≈ {{units}} PDFs per month"
previewNote = "At current pricing. Final translation happens server-side on save."
previewShort = "≈ {{units}} PDFs per month at current pricing."
save = "Update cap" save = "Update cap"
subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier." subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier."
title = "Monthly spending cap" title = "Monthly spending cap"
warnAt = "Warn me at"
warnAtDesc = "Notify when usage crosses this threshold."
[payg.checkout] [payg.checkout]
connecting = "Connecting to Stripe…" connecting = "Connecting to Stripe…"
@@ -5259,6 +5224,13 @@ note = "You can change your cap, cancel, or open the Stripe customer portal any
summaryLabel = "Monthly ceiling" summaryLabel = "Monthly ceiling"
title = "Welcome to the Processor plan" title = "Welcome to the Processor plan"
[payg.docHelp]
billable = "Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free."
chains = "Running the same file through several steps of one automation counts it once, not once per step."
perFile = "Each file you process counts as one PDF. Very long or very large files can count as more than one."
refunds = "If a job fails on its first step, the PDF is credited back automatically."
toggle = "What counts as a PDF?"
[payg.error] [payg.error]
body = "We couldn't reach the billing service. Refresh the page to try again." body = "We couldn't reach the billing service. Refresh the page to try again."
title = "Couldn't load your plan" title = "Couldn't load your plan"
@@ -5283,29 +5255,17 @@ title = "Turn on the Processor plan"
[payg.free.editor] [payg.free.editor]
eyebrow = "Editor plan · Always free" eyebrow = "Editor plan · Always free"
[payg.free.explainer]
alwaysFreeBody = "Manual tools: viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, no matter where you trigger them from."
alwaysFreeForYouBody = "Manual tools: viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, never counted."
alwaysFreeForYouLabel = "Always free for you"
alwaysFreeLabel = "Always free"
countsBody = "Automation pipelines (chained tools, scheduled runs), AI tools (summaries, classification, AI-OCR), and API calls (programmatic access). Past your free PDFs you'll need Processor."
countsLabel = "Counts toward your 500 free"
sharedBody = "Automation pipelines, AI tools, and API calls share a single one-time allowance of 500 free PDFs across the whole team. Once it's used up, ask your owner to enable Processor."
sharedLabel = "Shared with the team"
[payg.free.header] [payg.free.header]
subtitle = "Editor plan: manual tools are always free. Pay only for automation, AI & API. Billing period {{period}}." freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it."
freeTitle = "Unlimited PDF editing"
[payg.free.hero] [payg.free.hero]
capSuffix = "/ {{limit}} free PDFs" capSuffix = "/ {{limit}} free PDFs"
eyebrow = "Your free PDFs"
metaCategories = "Automation · AI · API requests" metaCategories = "Automation · AI · API requests"
neverResets = "One-time, never resets" neverResets = "One-time, never resets"
[payg.free.member] [payg.free.member]
body = "Your team owner can enable the Processor plan and set a monthly ceiling. Until then, manual tools are free for you to use as much as you like. Automation, AI, and API access share the team's one-time allowance of 500 free PDFs."
ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like." ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like."
title = "Want to process more than your 500 free PDFs?"
[payg.free.proc] [payg.free.proc]
eyebrow = "Processor plan · metered" eyebrow = "Processor plan · metered"
@@ -5321,9 +5281,17 @@ automation = "Automations & pipelines"
client = "Browser-only tools (viewer, page editor, file management)" client = "Browser-only tools (viewer, page editor, file management)"
offsite = "Server tools (compress, OCR, convert, watermark…)" offsite = "Server tools (compress, OCR, convert, watermark…)"
pauses = "pauses at cap" 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" title = "What happens when the cap is reached"
[payg.header]
eyebrow = "Processor plan · {{start}} {{end}}"
freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it."
freeLabel = "Always free"
freeTitle = "Unlimited PDF editing"
meterBody = "{{limit}} free PDFs to start, then billed per PDF up to your cap."
meterLabel = "Metered"
meterTitle = "Automation · AI · API"
[payg.member] [payg.member]
askLeader = "Only your team owner can change the cap." askLeader = "Only your team owner can change the cap."
@@ -5364,26 +5332,20 @@ body = "Billing portal isn't available right now. Try again in a moment."
title = "Billing portal unavailable" title = "Billing portal unavailable"
[payg.upgrade] [payg.upgrade]
backAria = "Back"
closeAria = "Close" closeAria = "Close"
[payg.upgrade.button] [payg.upgrade.button]
back = "← Back"
cancel = "Cancel" cancel = "Cancel"
continue = "Continue →" continue = "Continue →"
finish = "Finish" finish = "Finish"
[payg.upgrade.cap] [payg.upgrade.cap]
customPlaceholder = "Or enter your own amount ($0 keeps it free)"
help = "We'll never charge above this. Set $0 if you want to keep everything free while testing." help = "We'll never charge above this. Set $0 if you want to keep everything free while testing."
noCapHint = "You can still cancel anytime from the customer portal."
noCapLabel = "No cap, I'll manage spend manually"
perMonthSuffix = "/ month"
presetsAria = "Monthly cap preset"
title = "Set your monthly spend ceiling" title = "Set your monthly spend ceiling"
usdNote = "Estimated in USD. You can adjust your cap any time after subscribing, in your own currency." usdNote = "Estimated in USD. You can adjust your cap any time after subscribing, in your own currency."
[payg.upgrade.checkout] [payg.upgrade.checkout]
capLabel = "Monthly ceiling:"
capValue = "{{symbol}}{{amount}} / month" capValue = "{{symbol}}{{amount}} / month"
edit = "Edit" edit = "Edit"
help = "Stripe handles your card details. Stirling never sees them." help = "Stripe handles your card details. Stirling never sees them."
@@ -5391,11 +5353,6 @@ loading = "Loading checkout…"
noCap = "No cap" noCap = "No cap"
title = "Add your payment method" 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] [payg.upgrade.help]
aiBody = "summarise, classify, redact, AI-OCR" aiBody = "summarise, classify, redact, AI-OCR"
aiTitle = "AI tools" aiTitle = "AI tools"
@@ -5419,11 +5376,15 @@ confirm = "You're subscribed"
default = "Upgrade to Processor plan" default = "Upgrade to Processor plan"
[payg.usage] [payg.usage]
credit = "{{amount}} account credit" breakdown = "AI {{ai}} • Automation {{automation}} • API {{api}}"
docs = "documents" capLine = "{{cap}}/mo cap"
estBill = "≈ {{amount}} so far this period"
firstFree = "First {{free}} free"
noCap = "No monthly cap"
ofLimitProcessed = "/ {{limit}} PDFs processed"
processed = "PDFs processed"
resetsIn = "Resets in {{days}} days" resetsIn = "Resets in {{days}} days"
resetsTomorrow = "Resets tomorrow" resetsTomorrow = "Resets tomorrow"
spent = "{{spend}} of {{cap}} used"
thisPeriod = "This billing period" thisPeriod = "This billing period"
[payment] [payment]
@@ -5749,28 +5710,17 @@ manage = "Manage"
perMonth = "/month" perMonth = "/month"
perSeat = "/seat" perSeat = "/seat"
popular = "Popular" popular = "Popular"
purchase = "Purchase"
selectCredits = "Select Credit Amount"
selectPlan = "Select Plan" selectPlan = "Select Plan"
showComparison = "Compare All Features" showComparison = "Compare All Features"
totalCost = "Total Cost"
upgrade = "Upgrade" upgrade = "Upgrade"
withServer = "+ Server Plan" withServer = "+ Server Plan"
[plan.activePlan]
subtitle = "Your current subscription details"
title = "Active Plan"
[plan.api] [plan.api]
large = "5,000 Credits" large = "5,000 Credits"
medium = "1,000 Credits" medium = "1,000 Credits"
small = "500 Credits" small = "500 Credits"
xsmall = "100 Credits" xsmall = "100 Credits"
[plan.apiPackages]
subtitle = "Purchase API credits for your applications"
title = "API Credit Packages"
[plan.availablePlans] [plan.availablePlans]
loadError = "Unable to load plan pricing. Using default values." loadError = "Unable to load plan pricing. Using default values."
subtitle = "Choose the plan that fits your needs" subtitle = "Choose the plan that fits your needs"
@@ -5881,18 +5831,17 @@ successMessage = "Your license has been successfully activated. You can now clos
name = "Team" name = "Team"
[plan.trial] [plan.trial]
badge = "Trial"
continueWithFree = "Continue with Free" continueWithFree = "Continue with Free"
daysRemaining = "Your trial ends in {{days}} days"
endDate = "Expires: {{date}}"
expired = "Your Trial Has Ended" expired = "Your Trial Has Ended"
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier." expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
freeTierLimitations = "Free tier includes basic PDF tools with usage limits." freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
message = "" message = ""
subscribe = "Subscribe to Pro" subscribe = "Subscribe to Pro"
subscribeToPro = "Subscribe to Pro" subscribeToPro = "Subscribe to Pro"
subscriptionScheduled = "Subscription scheduled - starts {{date}}"
title = "Free Trial Active" [policies]
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
deleteConfirmTitle = "Delete {{label}} policy?"
[policies] [policies]
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
@@ -34,8 +34,8 @@
} }
.standaloneIcon { .standaloneIcon {
width: 96px; width: 80px;
height: 96px; height: 80px;
object-fit: contain; object-fit: contain;
animation: heroLogoScale 0.25s ease forwards; animation: heroLogoScale 0.25s ease forwards;
} }
@@ -124,8 +124,6 @@
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
animation: heroLogoEnter 0.25s ease forwards; animation: heroLogoEnter 0.25s ease forwards;
position: relative;
top: 1rem;
} }
.iconWrapper { .iconWrapper {
@@ -175,8 +173,8 @@
} }
.downloadIcon { .downloadIcon {
width: 96px; width: 80px;
height: 96px; height: 80px;
object-fit: contain; object-fit: contain;
animation: heroLogoScale 0.25s ease forwards; animation: heroLogoScale 0.25s ease forwards;
} }
@@ -205,6 +203,7 @@
} }
.bodyCopy { .bodyCopy {
text-align: center;
opacity: 0; opacity: 0;
transform: translateX(24px); transform: translateX(24px);
animation: bodySlideIn 0.25s ease forwards; animation: bodySlideIn 0.25s ease forwards;
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types"; import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; import { UNIFIED_LIGHT_BACKGROUND } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { import {
DesktopInstallTitle, DesktopInstallTitle,
type OSOption, type OSOption,
@@ -47,9 +47,6 @@ export default function DesktopInstallSlide({
), ),
body: <DesktopInstallBody />, body: <DesktopInstallBody />,
downloadUrl: osUrl, downloadUrl: osUrl,
background: { background: UNIFIED_LIGHT_BACKGROUND,
gradientStops: ["#2563EB", "#0EA5E9"],
circles: UNIFIED_CIRCLE_CONFIG,
},
}; };
} }
@@ -1,4 +1,7 @@
import { AnimatedCircleConfig } from "@app/types/types"; import {
AnimatedCircleConfig,
AnimatedSlideBackgroundProps,
} from "@app/types/types";
/** /**
* Unified circle background configuration used across all onboarding slides. * Unified circle background configuration used across all onboarding slides.
@@ -27,3 +30,36 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
offsetY: 18, offsetY: 18,
}, },
]; ];
/**
* Build a light slide background in a slide-specific accent colour: a white
* hero fading into a pale horizon tint, with the unified sphere geometry
* glowing in the accent on each sphere's inner edge.
*/
export function createLightSlideBackground(
accentRgb: [number, number, number],
horizon: string,
): AnimatedSlideBackgroundProps {
const [r, g, b] = accentRgb;
const sphereColors = [
`linear-gradient(45deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.3))`,
`linear-gradient(225deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.26))`,
];
return {
gradientStops: ["#FFFFFF", horizon],
circles: UNIFIED_CIRCLE_CONFIG.map((circle, index) => ({
...circle,
color: sphereColors[index],
})),
tone: "light",
};
}
/**
* Default light slide background: white with a light blue horizon and
* blue-glow spheres.
*/
export const UNIFIED_LIGHT_BACKGROUND = createLightSlideBackground(
[37, 99, 235],
"#DBEAFE",
);
+2
View File
@@ -16,6 +16,8 @@ export interface AnimatedCircleConfig {
export interface AnimatedSlideBackgroundProps { export interface AnimatedSlideBackgroundProps {
gradientStops: [string, string]; gradientStops: [string, string];
circles: AnimatedCircleConfig[]; circles: AnimatedCircleConfig[];
/** Overall background tone; controls on top of the hero (e.g. close button) adapt to stay visible. Defaults to "dark". */
tone?: "light" | "dark";
} }
export interface SlideConfig { export interface SlideConfig {
@@ -21,7 +21,9 @@ export function PolicyDeleteConfirmModal({
<Modal <Modal
opened={opened} opened={opened}
onClose={onCancel} onClose={onCancel}
title={t("policies.deleteConfirmTitle", `Delete ${label} policy?`)} title={t("policies.deleteConfirmTitle", "Delete {{label}} policy?", {
label,
})}
centered centered
size="sm" size="sm"
> >
@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import { Modal, Stack } from "@mantine/core"; import { Modal, Stack } from "@mantine/core";
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined"; import BoltRoundedIcon from "@mui/icons-material/BoltRounded";
import GroupAddRoundedIcon from "@mui/icons-material/GroupAddRounded";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
import OnboardingStepper from "@app/components/onboarding/OnboardingStepper"; import OnboardingStepper from "@app/components/onboarding/OnboardingStepper";
import { renderButtons } from "@app/components/onboarding/renderButtons"; import { renderButtons } from "@app/components/onboarding/renderButtons";
@@ -48,18 +48,23 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
); );
} }
if (slideDefinition.hero.type === "logo") {
return (
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling logo"
className={styles.standaloneIcon}
/>
);
}
return ( return (
<div className={styles.heroLogoCircle}> <div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === "rocket" && ( {slideDefinition.hero.type === "bolt" && (
<LocalIcon <BoltRoundedIcon sx={{ fontSize: 64, color: "#000000" }} />
icon="rocket-launch"
width={64}
height={64}
className={styles.heroIcon}
/>
)} )}
{slideDefinition.hero.type === "diamond" && ( {slideDefinition.hero.type === "team" && (
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} /> <GroupAddRoundedIcon sx={{ fontSize: 56, color: "#000000" }} />
)} )}
</div> </div>
); );
@@ -1,42 +1,25 @@
import { TrialStatus } from "@app/auth/UseSession"; import { SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig";
import {
FLOW_SEQUENCES,
SlideId,
} from "@app/components/onboarding/saasOnboardingFlowConfig";
export interface FlowConfig { export interface SaasFlowInputs {
type: "saas-trial" | "saas-paid"; /** Free-tier wallet with one-time allowance remaining — show the usage meter. */
ids: SlideId[]; showUsageSlide: boolean;
/** Team leaders only — invited members and anonymous guests skip the team slide. */
showTeamSlide: boolean;
} }
/** /**
* Resolves the appropriate onboarding flow based on user's subscription status. * Resolves the SaaS onboarding slide sequence. The free-editor pitch and
* * desktop install bookend the flow; the usage meter and team slides slot in
* @param trialStatus - User's trial information from Supabase * when their conditions hold.
* @param _isPro - Whether user has Pro subscription
* @returns FlowConfig with the appropriate slide sequence
*/ */
export function resolveSaasFlow( export function resolveSaasFlow({
trialStatus: TrialStatus | null, showUsageSlide,
_isPro: boolean | null, showTeamSlide,
): FlowConfig { }: SaasFlowInputs): SlideId[] {
// Show free trial card if: return [
// 1. User has active trial (isTrialing = true) "free-editor",
// 2. Trial has not expired (daysRemaining > 0) ...(showUsageSlide ? (["usage"] as const) : []),
// 3. User is not paid Pro (or Pro is from trial) ...(showTeamSlide ? (["team"] as const) : []),
const hasActiveTrial = "desktop-install",
trialStatus?.isTrialing === true && trialStatus.daysRemaining > 0; ];
if (hasActiveTrial) {
return {
type: "saas-trial",
ids: FLOW_SEQUENCES.saasTrialUser,
};
}
// For paid users, expired trials, or no trial info
return {
type: "saas-paid",
ids: FLOW_SEQUENCES.saasPaidUser,
};
} }
@@ -1,12 +1,12 @@
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide"; import FreeEditorSlide from "@app/components/onboarding/slides/FreeEditorSlide";
import UsageSnapshotSlide from "@app/components/onboarding/slides/UsageSnapshotSlide";
import TeamSlide from "@app/components/onboarding/slides/TeamSlide";
import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide"; import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide";
import FreeTrialSlide from "@app/components/onboarding/slides/FreeTrialSlide";
import { SlideConfig } from "@app/types/types"; import { SlideConfig } from "@app/types/types";
import { TrialStatus } from "@app/auth/UseSession";
export type SlideId = "welcome" | "free-trial" | "desktop-install"; export type SlideId = "free-editor" | "usage" | "team" | "desktop-install";
export type HeroType = "rocket" | "dual-icon" | "diamond"; export type HeroType = "logo" | "bolt" | "team" | "dual-icon";
export type ButtonAction = "next" | "prev" | "close" | "download-selected"; export type ButtonAction = "next" | "prev" | "close" | "download-selected";
@@ -23,7 +23,6 @@ export interface SlideFactoryParams {
osUrl: string; osUrl: string;
osOptions?: OSOption[]; osOptions?: OSOption[];
onDownloadUrlChange?: (url: string) => void; onDownloadUrlChange?: (url: string) => void;
trialStatus?: TrialStatus | null;
} }
export interface HeroDefinition { export interface HeroDefinition {
@@ -48,47 +47,46 @@ export interface SlideDefinition {
buttons: ButtonDefinition[]; buttons: ButtonDefinition[];
} }
const BACK_BUTTON: ButtonDefinition = {
key: "back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
};
const NEXT_BUTTON: ButtonDefinition = {
key: "next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "next",
};
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = { export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
welcome: { "free-editor": {
id: "welcome", id: "free-editor",
createSlide: () => WelcomeSlide(), createSlide: () => FreeEditorSlide(),
hero: { type: "rocket" }, hero: { type: "logo" },
buttons: [{ ...NEXT_BUTTON, key: "free-editor-next" }],
},
usage: {
id: "usage",
createSlide: () => UsageSnapshotSlide(),
hero: { type: "bolt" },
buttons: [ buttons: [
{ { ...BACK_BUTTON, key: "usage-back" },
key: "welcome-next", { ...NEXT_BUTTON, key: "usage-next" },
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "next",
},
], ],
}, },
"free-trial": { team: {
id: "free-trial", id: "team",
createSlide: ({ trialStatus }) => { createSlide: () => TeamSlide(),
if (!trialStatus) { hero: { type: "team" },
throw new Error("Trial status is required for free-trial slide");
}
return FreeTrialSlide({ trialStatus });
},
hero: { type: "diamond" },
buttons: [ buttons: [
{ { ...BACK_BUTTON, key: "team-back" },
key: "trial-back", { ...NEXT_BUTTON, key: "team-next" },
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: "trial-next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "next",
},
], ],
}, },
"desktop-install": { "desktop-install": {
@@ -97,13 +95,7 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }), DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: "dual-icon" }, hero: { type: "dual-icon" },
buttons: [ buttons: [
{ { ...BACK_BUTTON, key: "desktop-back" },
key: "desktop-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{ {
key: "desktop-skip", key: "desktop-skip",
type: "button", type: "button",
@@ -123,8 +115,3 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
], ],
}, },
}; };
export const FLOW_SEQUENCES = {
saasTrialUser: ["welcome", "free-trial", "desktop-install"] as SlideId[],
saasPaidUser: ["welcome", "desktop-install"] as SlideId[],
};
@@ -0,0 +1,44 @@
import React from "react";
import { Trans } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import styles from "@app/components/onboarding/slides/SaasOnboardingSlides.module.css";
// Stirling logo red (sampled from modern-logo/logo512.png)
const FREE_EDITOR_BACKGROUND = createLightSlideBackground(
[142, 49, 49],
"#F8E0E0",
);
const FreeEditorBody = () => (
<span>
<Trans
i18nKey="onboarding.saas.freeEditor.premium"
components={{ strong: <strong /> }}
defaults="We've added a whole host of new features, including <strong>Policies</strong> and <strong>Agent Chat</strong>."
/>
<span className={styles.freeLine}>
<Trans
i18nKey="onboarding.saas.freeEditor.freeLine"
components={{ free: <strong className={styles.freeHighlight} /> }}
defaults="And the best part? The editor is now <free>completely free</free>."
/>
</span>
</span>
);
export default function FreeEditorSlide(): SlideConfig {
const title = (
<Trans
i18nKey="onboarding.saas.freeEditor.title"
defaults="Welcome to Stirling"
/>
);
return {
key: "free-editor",
title,
body: <FreeEditorBody />,
background: FREE_EDITOR_BACKGROUND,
};
}
@@ -1,108 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { TrialStatus } from "@app/auth/UseSession";
interface FreeTrialSlideProps {
trialStatus: TrialStatus;
}
function FreeTrialSlideTitle() {
const { t } = useTranslation();
return (
<span>{t("onboarding.freeTrial.title", "Your 30-Day Pro Trial")}</span>
);
}
const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => {
const { t } = useTranslation();
// Format the trial end date
const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString(
undefined,
{
year: "numeric",
month: "long",
day: "numeric",
},
);
// Determine which message to show based on payment method
const afterTrialMessage = trialStatus.hasScheduledSub
? t(
"onboarding.freeTrial.afterTrialWithPayment",
"Your Pro subscription will start automatically when the trial ends.",
)
: trialStatus.hasPaymentMethod
? t(
"onboarding.freeTrial.afterTrialWithPayment",
"Your Pro subscription will start automatically when the trial ends.",
)
: t(
"onboarding.freeTrial.afterTrialWithoutPayment",
"After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access.",
);
// Pluralize days remaining
const daysText =
trialStatus.daysRemaining === 1
? t(
"onboarding.freeTrial.daysRemainingSingular",
"{{days}} day remaining",
{ days: trialStatus.daysRemaining },
)
: t("onboarding.freeTrial.daysRemaining", "{{days}} days remaining", {
days: trialStatus.daysRemaining,
});
return (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<p>
{t(
"onboarding.freeTrial.body",
"You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing.",
)}
</p>
<div
style={{
background: "rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
padding: "1rem",
textAlign: "center",
}}
>
<div
style={{
fontSize: "1.25rem",
fontWeight: "bold",
marginBottom: "0.5rem",
}}
>
{daysText}
</div>
<div style={{ fontSize: "0.9rem", opacity: 0.9 }}>
{t("onboarding.freeTrial.trialEnds", "Trial ends {{date}}", {
date: trialEndDate,
})}
</div>
</div>
<p style={{ fontSize: "0.9rem", opacity: 0.9 }}>{afterTrialMessage}</p>
</div>
);
};
export default function FreeTrialSlide({
trialStatus,
}: FreeTrialSlideProps): SlideConfig {
return {
key: "free-trial",
title: <FreeTrialSlideTitle />,
body: <FreeTrialSlideBody trialStatus={trialStatus} />,
background: {
gradientStops: ["#10B981", "#06B6D4"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,91 @@
/* Shared styles for the SaaS onboarding slides */
/* ── Slide 1: free editor pitch ─────────────────────────────────────────── */
.freeLine {
display: block;
margin-top: 10px;
}
.freeHighlight {
background: linear-gradient(135deg, #6366f1, #ec4899);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 700;
}
/* ── Slide 2: usage meter snapshot ──────────────────────────────────────── */
.usageMeterWrap {
max-width: 420px;
margin: 16px auto 0;
text-align: left;
}
/* ── Slide 3: team ──────────────────────────────────────────────────────── */
.teamCard {
max-width: 460px;
margin: 12px auto 0;
text-align: left;
}
.memberList {
display: flex;
flex-direction: column;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-default, #d1d5db);
border-radius: 12px;
margin-bottom: 12px;
}
.memberRow {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
}
.memberRow + .memberRow {
border-top: 1px solid var(--border-subtle, #e5e7eb);
}
.memberIdentity {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.memberName {
font-size: 14px;
font-weight: 600;
color: var(--onboarding-title, #111827);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.memberEmail {
font-size: 12px;
color: var(--onboarding-body, #6b7280);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inviteRow {
display: flex;
gap: 8px;
}
.inviteRow > :first-child {
flex: 1;
}
.inviteFeedback {
font-size: 13px;
margin-top: 8px;
}
@@ -0,0 +1,191 @@
import React, { useEffect, useState } from "react";
import { Badge, Button, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import styles from "@app/components/onboarding/slides/SaasOnboardingSlides.module.css";
const TEAM_BACKGROUND = createLightSlideBackground([79, 70, 229], "#E0E7FF");
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function useHasTeamMembers(): boolean {
const { teamMembers, teamInvitations } = useSaaSTeam();
const pendingInvitations = teamInvitations.filter(
(invitation) => invitation.status === "PENDING",
);
// The leader themselves is always in teamMembers, so "has a team" means
// anyone beyond them — or an invite already on its way.
return teamMembers.length > 1 || pendingInvitations.length > 0;
}
function TeamSlideTitle() {
const { t } = useTranslation();
const hasTeam = useHasTeamMembers();
return hasTeam
? t("onboarding.saas.team.inviteTitle", "Invite members to your team")
: t("onboarding.saas.team.createTitle", "Create your team");
}
function InviteForm() {
const { t } = useTranslation();
const { inviteUser } = useSaaSTeam();
const [email, setEmail] = useState("");
const [inviting, setInviting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const emailValid = EMAIL_PATTERN.test(email);
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!emailValid) return;
setInviting(true);
setError(null);
setSuccess(null);
try {
await inviteUser(email);
setSuccess(
t("team.inviteSent", "Invitation sent to {{email}}", { email }),
);
setEmail("");
} catch (err) {
const inviteError = err as { response?: { data?: { error?: string } } };
setError(
inviteError.response?.data?.error ||
t("team.inviteError", "Failed to send invitation"),
);
} finally {
setInviting(false);
}
};
return (
<form onSubmit={handleInvite}>
<span className={styles.inviteRow}>
<TextInput
type="email"
placeholder={t("team.invite.placeholder", "[email protected]")}
value={email}
onChange={(e) => setEmail(e.target.value)}
error={
email && !emailValid
? t("team.invite.invalidEmail", "Invalid email format")
: undefined
}
/>
<Button type="submit" loading={inviting} disabled={!emailValid}>
{t("onboarding.saas.team.addButton", "Add")}
</Button>
</span>
{error && (
<span
className={styles.inviteFeedback}
style={{ color: "var(--mantine-color-red-6)", display: "block" }}
>
{error}
</span>
)}
{success && (
<span
className={styles.inviteFeedback}
style={{ color: "var(--mantine-color-green-7)", display: "block" }}
>
{success}
</span>
)}
</form>
);
}
const TeamSlideBody = () => {
const { t } = useTranslation();
const { teamMembers, teamInvitations, refreshTeams } = useSaaSTeam();
const hasTeam = useHasTeamMembers();
const pendingInvitations = teamInvitations.filter(
(invitation) => invitation.status === "PENDING",
);
// Onboarding shows right after first login — make sure team data is fresh.
useEffect(() => {
refreshTeams();
}, []);
return (
<span>
{hasTeam
? t(
"onboarding.saas.team.inviteBody",
"Everyone on your team shares files, automations and your plan. Add teammates by email — they'll get an invite right away.",
)
: t(
"onboarding.saas.team.createBody",
"Work on documents together — teammates share files, automations and your plan. Add the first member by email to create your team.",
)}
<span className={styles.teamCard} style={{ display: "block" }}>
{hasTeam && (
<span className={styles.memberList} style={{ display: "flex" }}>
{teamMembers.map((member) => (
<span
key={`member-${member.id}`}
className={styles.memberRow}
style={{ display: "flex" }}
>
<span
className={styles.memberIdentity}
style={{ display: "flex" }}
>
<span className={styles.memberName}>{member.username}</span>
<span className={styles.memberEmail}>{member.email}</span>
</span>
<Badge
size="sm"
color={member.role === "LEADER" ? "blue" : "gray"}
variant="light"
>
{member.role}
</Badge>
</span>
))}
{pendingInvitations.map((invitation) => (
<span
key={`invitation-${invitation.invitationId}`}
className={styles.memberRow}
style={{ display: "flex" }}
>
<span
className={styles.memberIdentity}
style={{ display: "flex" }}
>
<span className={styles.memberName}>
{invitation.inviteeEmail.split("@")[0]}
</span>
<span className={styles.memberEmail}>
{invitation.inviteeEmail}
</span>
</span>
<Badge size="sm" color="yellow" variant="light">
{t("team.members.pending", "PENDING")}
</Badge>
</span>
))}
</span>
)}
<InviteForm />
</span>
</span>
);
};
export default function TeamSlide(): SlideConfig {
return {
key: "team",
title: <TeamSlideTitle />,
body: <TeamSlideBody />,
background: TEAM_BACKGROUND,
};
}
@@ -0,0 +1,45 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import {
FreeMeterPanel,
useFreeSnapshot,
} from "@app/components/shared/config/configSections/PaygFree";
import i18n from "@app/i18n";
import styles from "@app/components/onboarding/slides/SaasOnboardingSlides.module.css";
const USAGE_BACKGROUND = createLightSlideBackground([249, 115, 22], "#FFEDD5");
const UsageSnapshotBody = () => {
const { t } = useTranslation();
const snap = useFreeSnapshot();
return (
<span>
{t(
"onboarding.saas.usage.body",
"Automations, AI and API requests draw from your one-time free allowance. Here's where you stand - manual editing never counts against it.",
)}
{/* .payg provides the CSS variables the meter styles are scoped to */}
<span
className={`payg ${styles.usageMeterWrap}`}
style={{ display: "block" }}
>
<FreeMeterPanel snap={snap} />
</span>
</span>
);
};
export default function UsageSnapshotSlide(): SlideConfig {
return {
key: "usage-snapshot",
title: i18n.t(
"onboarding.saas.usage.title",
"Your free Processor allowance",
),
body: <UsageSnapshotBody />,
background: USAGE_BACKGROUND,
};
}
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useAuth } from "@app/auth/UseSession"; import { useAuth } from "@app/auth/UseSession";
import { useOs } from "@app/hooks/useOs"; import { useOs } from "@app/hooks/useOs";
import { useWallet } from "@app/hooks/useWallet";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import { import {
SLIDE_DEFINITIONS, SLIDE_DEFINITIONS,
type ButtonAction, type ButtonAction,
@@ -28,7 +30,9 @@ export function useSaasOnboardingState({
opened, opened,
onClose, onClose,
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null { }: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
const { trialStatus, isPro, loading } = useAuth(); const { loading } = useAuth();
const { wallet } = useWallet();
const { isTeamLeader } = useSaaSTeam();
const osType = useOs(); const osType = useOs();
const selectedDownloadUrlRef = useRef<string>(""); const selectedDownloadUrlRef = useRef<string>("");
@@ -70,13 +74,15 @@ export function useSaasOnboardingState({
selectedDownloadUrlRef.current = url; selectedDownloadUrlRef.current = url;
}, []); }, []);
// Resolve flow based on trial status // Usage meter only makes sense for free-tier wallets with allowance left;
const resolvedFlow = useMemo( // the team slide is for leaders (anonymous guests are never leaders).
() => resolveSaasFlow(trialStatus, isPro), const showUsageSlide = wallet?.status === "free" && wallet.freeRemaining > 0;
[trialStatus, isPro], const showTeamSlide = isTeamLeader;
);
const flowSlideIds = resolvedFlow.ids; const flowSlideIds = useMemo(
() => resolveSaasFlow({ showUsageSlide, showTeamSlide }),
[showUsageSlide, showTeamSlide],
);
const totalSteps = flowSlideIds.length; const totalSteps = flowSlideIds.length;
const maxIndex = Math.max(totalSteps - 1, 0); const maxIndex = Math.max(totalSteps - 1, 0);
@@ -99,16 +105,8 @@ export function useSaasOnboardingState({
osUrl: os.url, osUrl: os.url,
osOptions, osOptions,
onDownloadUrlChange: handleDownloadUrlChange, onDownloadUrlChange: handleDownloadUrlChange,
trialStatus: trialStatus ?? undefined,
}); });
}, [ }, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange]);
slideDefinition,
os.label,
os.url,
osOptions,
handleDownloadUrlChange,
trialStatus,
]);
// Navigation functions // Navigation functions
const goNext = useCallback(() => { const goNext = useCallback(() => {
@@ -209,9 +209,7 @@ function UsageHero({ wallet }: { wallet: Wallet }) {
const limit = wallet.billableLimit; const limit = wallet.billableLimit;
const hasLimit = limit != null && limit > 0; const hasLimit = limit != null && limit > 0;
const pct = hasLimit const pct = hasLimit ? Math.min(100, (wallet.billableUsed / limit) * 100) : 0;
? Math.min(100, (wallet.billableUsed / limit) * 100)
: 0;
const state = hasLimit const state = hasLimit
? pct >= 100 ? pct >= 100
? "DEGRADED" ? "DEGRADED"
@@ -387,7 +385,13 @@ function CapEditor({
); );
} }
function CapReadOnly({ capUsd, noCap }: { capUsd: number | null; noCap: boolean }) { function CapReadOnly({
capUsd,
noCap,
}: {
capUsd: number | null;
noCap: boolean;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const hasCap = !noCap && capUsd != null; const hasCap = !noCap && capUsd != null;
return ( return (
@@ -567,10 +571,7 @@ function ActivityFeed({ recent }: { recent: Wallet["recent"] }) {
</div> </div>
{recent.length === 0 ? ( {recent.length === 0 ? (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t( {t("payg.activity.empty", "No billable activity yet this period.")}
"payg.activity.empty",
"No billable activity yet this period.",
)}
</Text> </Text>
) : ( ) : (
<div> <div>
@@ -603,7 +604,11 @@ function ActivityFeed({ recent }: { recent: Wallet["recent"] }) {
// ─── Stripe CTA ────────────────────────────────────────────────────────────── // ─── Stripe CTA ──────────────────────────────────────────────────────────────
function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise<void> }) { function StripePortalLink({
onOpenPortal,
}: {
onOpenPortal: () => Promise<void>;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -662,7 +667,12 @@ function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise<void>
// ─── Main component ─────────────────────────────────────────────────────── // ─── Main component ───────────────────────────────────────────────────────
const Payg: React.FC<PaygProps> = ({ role, wallet, onSaveCap, onOpenPortal }) => { const Payg: React.FC<PaygProps> = ({
role,
wallet,
onSaveCap,
onOpenPortal,
}) => {
useRenderCount(role === "LEADER" ? "PaygLeader" : "PaygMember"); useRenderCount(role === "LEADER" ? "PaygLeader" : "PaygMember");
const { t } = useTranslation(); const { t } = useTranslation();
const isLeader = role === "LEADER"; const isLeader = role === "LEADER";
@@ -681,10 +691,14 @@ const Payg: React.FC<PaygProps> = ({ role, wallet, onSaveCap, onOpenPortal }) =>
<div className="payg-planhead"> <div className="payg-planhead">
<div className="payg-planhead__top"> <div className="payg-planhead__top">
<span className="payg-planhead__eyebrow"> <span className="payg-planhead__eyebrow">
{t("payg.header.eyebrow", "Processor plan · {{start}} {{end}}", { {t(
start: fmt(wallet.billingPeriodStart), "payg.header.eyebrow",
end: fmt(wallet.billingPeriodEnd), "Processor plan · {{start}} {{end}}",
})} {
start: fmt(wallet.billingPeriodStart),
end: fmt(wallet.billingPeriodEnd),
},
)}
</span> </span>
<span className="payg-role-pill" data-leader={isLeader}> <span className="payg-role-pill" data-leader={isLeader}>
{isLeader {isLeader
@@ -715,7 +729,10 @@ const Payg: React.FC<PaygProps> = ({ role, wallet, onSaveCap, onOpenPortal }) =>
<div className="payg-planhead__col payg-planhead__col--meter"> <div className="payg-planhead__col payg-planhead__col--meter">
<div className="payg-planhead__lbl payg-planhead__lbl--meter"> <div className="payg-planhead__lbl payg-planhead__lbl--meter">
<BoltIcon className="payg-planhead__lbl-icon" fontSize="small" /> <BoltIcon
className="payg-planhead__lbl-icon"
fontSize="small"
/>
{t("payg.header.meterLabel", "Metered")} {t("payg.header.meterLabel", "Metered")}
</div> </div>
<p className="payg-planhead__title"> <p className="payg-planhead__title">
@@ -227,7 +227,9 @@
font-size: 0.95rem; font-size: 0.95rem;
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
transition: transform 120ms ease, box-shadow 120ms ease; transition:
transform 120ms ease,
box-shadow 120ms ease;
white-space: nowrap; white-space: nowrap;
} }
.paygf-cta__button:hover { .paygf-cta__button:hover {
@@ -43,7 +43,7 @@ import { DocHelp } from "./Payg";
// ─── Shared free-tier snapshot ──────────────────────────── // ─── Shared free-tier snapshot ────────────────────────────
interface FreeSnapshot { export interface FreeSnapshot {
/** One-time free documents used so far (grant remaining). */ /** One-time free documents used so far (grant remaining). */
billableUsed: number; billableUsed: number;
/** /**
@@ -61,7 +61,7 @@ interface FreeSnapshot {
* numbers. Earlier versions returned a mock "62 of 500" sentinel which leaked * numbers. Earlier versions returned a mock "62 of 500" sentinel which leaked
* into the rendered UI and made the page look like nothing was wired up. * into the rendered UI and made the page look like nothing was wired up.
*/ */
function useFreeSnapshot(): FreeSnapshot { export function useFreeSnapshot(): FreeSnapshot {
const { wallet } = useWallet(); const { wallet } = useWallet();
return useMemo(() => { return useMemo(() => {
if (wallet) { if (wallet) {
@@ -80,7 +80,10 @@ function useFreeSnapshot(): FreeSnapshot {
type MeterState = "FULL" | "WARNED" | "DEGRADED"; type MeterState = "FULL" | "WARNED" | "DEGRADED";
/** Warn/degrade band for the one-time grant meter (mirrors the BE thresholds). */ /** Warn/degrade band for the one-time grant meter (mirrors the BE thresholds). */
function meterState(used: number, limit: number): { state: MeterState; pct: number } { function meterState(
used: number,
limit: number,
): { state: MeterState; pct: number } {
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100; const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100;
const state: MeterState = const state: MeterState =
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL"; pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
@@ -113,7 +116,10 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
/> />
{t("payg.free.editor.eyebrow", "Editor plan · Always free")} {t("payg.free.editor.eyebrow", "Editor plan · Always free")}
</span> </span>
<span className="payg-role-pill" data-leader={leader ? "true" : "false"}> <span
className="payg-role-pill"
data-leader={leader ? "true" : "false"}
>
{pill} {pill}
</span> </span>
</div> </div>
@@ -132,7 +138,7 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
// ─── Compact one-time free meter (right column of the Processor card) ────── // ─── Compact one-time free meter (right column of the Processor card) ──────
function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit); const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
const stateLabel = const stateLabel =
@@ -174,7 +180,9 @@ function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")} {t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
</span> </span>
<span className="payg-hero__meta-dot"></span> <span className="payg-hero__meta-dot"></span>
<span>{t("payg.free.hero.neverResets", "One-time, never resets")}</span> <span>
{t("payg.free.hero.neverResets", "One-time — never resets")}
</span>
</div> </div>
</div> </div>
); );
@@ -240,8 +248,10 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
<li> <li>
<CheckIcon className="paygf-cta__check" fontSize="small" /> <CheckIcon className="paygf-cta__check" fontSize="small" />
<span> <span>
<strong>{t("payg.free.cta.benefit3Title", "API access")}</strong> <strong>
{": "} {t("payg.free.cta.benefit3Title", "API access")}
</strong>
{" — "}
{t( {t(
"payg.free.cta.benefit3Body", "payg.free.cta.benefit3Body",
"call any Stirling endpoint programmatically", "call any Stirling endpoint programmatically",
@@ -59,7 +59,10 @@ const Plan: React.FC = () => {
if (error || !wallet) { if (error || !wallet) {
return ( return (
<Alert color="red" title={t("payg.error.title", "Couldn't load your plan")}> <Alert
color="red"
title={t("payg.error.title", "Couldn't load your plan")}
>
{error ?? {error ??
t( t(
"payg.error.body", "payg.error.body",
@@ -53,7 +53,10 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease; transition:
color 0.12s ease,
border-color 0.12s ease,
background 0.12s ease;
} }
.scc-chip:hover:not(:disabled) { .scc-chip:hover:not(:disabled) {
color: var(--text-primary); color: var(--text-primary);
@@ -82,7 +85,9 @@
border: 1px dashed var(--scc-chip-border); border: 1px dashed var(--scc-chip-border);
background: transparent; background: transparent;
cursor: text; cursor: text;
transition: border-color 0.12s ease, background 0.12s ease; transition:
border-color 0.12s ease,
background 0.12s ease;
} }
.scc-custom:hover { .scc-custom:hover {
border-color: var(--border-strong); border-color: var(--border-strong);
@@ -62,7 +62,10 @@ export interface SpendCapControlProps {
} }
/** Format minor units of an ISO currency ("$2.24", "£0.40"). */ /** Format minor units of an ISO currency ("$2.24", "£0.40"). */
function formatMinor(minor: number, currency: string | null | undefined): string { function formatMinor(
minor: number,
currency: string | null | undefined,
): string {
const code = (currency ?? "usd").toUpperCase(); const code = (currency ?? "usd").toUpperCase();
try { try {
return new Intl.NumberFormat(undefined, { return new Intl.NumberFormat(undefined, {
@@ -212,7 +215,10 @@ const SpendCapControl: React.FC<SpendCapControlProps> = ({
{previewDocs != null && ( {previewDocs != null && (
<div className="scc-estimate"> <div className="scc-estimate">
<DescriptionIcon className="scc-estimate__icon" sx={{ fontSize: 22 }} /> <DescriptionIcon
className="scc-estimate__icon"
sx={{ fontSize: 22 }}
/>
<div> <div>
<div className="scc-estimate__main"> <div className="scc-estimate__main">
{t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", { {t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
@@ -148,8 +148,7 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
const tRef = useRef(t); const tRef = useRef(t);
tRef.current = t; tRef.current = t;
const publishableKey = const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
// Dev preview route has no backend — skip the API call and go straight // Dev preview route has no backend — skip the API call and go straight
// to the mock placeholder so the design + completion path stay testable. // to the mock placeholder so the design + completion path stay testable.
@@ -214,7 +213,9 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
} }
if (cancelled) return; if (cancelled) return;
setClientSecret(data.client_secret); setClientSecret(data.client_secret);
setIsMock(Boolean(data.mock) || data.client_secret.startsWith("cs_mock_")); setIsMock(
Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"),
);
} catch (e: unknown) { } catch (e: unknown) {
if (cancelled) return; if (cancelled) return;
const msg = const msg =
@@ -112,7 +112,9 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: background 120ms ease, color 120ms ease; transition:
background 120ms ease,
color 120ms ease;
} }
.upm-header__close:hover { .upm-header__close:hover {
background: var(--upm-divider); background: var(--upm-divider);
@@ -141,7 +143,9 @@
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
margin-left: -6px; margin-left: -6px;
transition: background 120ms ease, color 120ms ease; transition:
background 120ms ease,
color 120ms ease;
} }
.upm-header__back:hover { .upm-header__back:hover {
background: var(--upm-divider); background: var(--upm-divider);
@@ -177,7 +181,10 @@
justify-content: center; justify-content: center;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 700; font-weight: 700;
transition: background 200ms ease, border-color 200ms ease, color 200ms ease; transition:
background 200ms ease,
border-color 200ms ease,
color 200ms ease;
} }
.upm-step[data-state="active"] { .upm-step[data-state="active"] {
color: var(--upm-text); color: var(--upm-text);
@@ -161,7 +161,10 @@ export default function UpgradeModal({
<h2 className="upm-header__title"> <h2 className="upm-header__title">
{step === "confirm" {step === "confirm"
? t("payg.upgrade.title.confirm", "You're subscribed") ? t("payg.upgrade.title.confirm", "You're subscribed")
: t("payg.upgrade.title.default", "Upgrade to Processor plan")} : t(
"payg.upgrade.title.default",
"Upgrade to Processor plan",
)}
</h2> </h2>
</div> </div>
<button <button
@@ -213,10 +216,7 @@ export default function UpgradeModal({
> >
<span className="upm-step__dot">2</span> <span className="upm-step__dot">2</span>
<span> <span>
{t( {t("payg.upgrade.steps.payment", "Add payment method")}
"payg.upgrade.steps.payment",
"Add payment method",
)}
</span> </span>
</div> </div>
</div> </div>
@@ -342,10 +342,7 @@ function CapStep({
</div> </div>
<h3 className="upm-section-title"> <h3 className="upm-section-title">
{t( {t("payg.upgrade.cap.title", "Set your monthly spend ceiling")}
"payg.upgrade.cap.title",
"Set your monthly spend ceiling",
)}
</h3> </h3>
<p className="upm-section-help"> <p className="upm-section-help">
{t( {t(
@@ -377,18 +374,12 @@ function CapStep({
<div className="upm-help-card" style={{ marginTop: 16 }}> <div className="upm-help-card" style={{ marginTop: 16 }}>
<span className="upm-help-card__title"> <span className="upm-help-card__title">
{t( {t("payg.upgrade.help.title", "What we count toward billing")}
"payg.upgrade.help.title",
"What we count toward billing",
)}
</span> </span>
<ul style={{ margin: "4px 0 0", paddingLeft: 18, lineHeight: 1.55 }}> <ul style={{ margin: "4px 0 0", paddingLeft: 18, lineHeight: 1.55 }}>
<li> <li>
<strong> <strong>
{t( {t("payg.upgrade.help.automationTitle", "Automation pipelines")}
"payg.upgrade.help.automationTitle",
"Automation pipelines",
)}
</strong> </strong>
{" — "} {" — "}
{t( {t(
@@ -397,9 +388,7 @@ function CapStep({
)} )}
</li> </li>
<li> <li>
<strong> <strong>{t("payg.upgrade.help.aiTitle", "AI tools")}</strong>
{t("payg.upgrade.help.aiTitle", "AI tools")}
</strong>
{" — "} {" — "}
{t( {t(
"payg.upgrade.help.aiBody", "payg.upgrade.help.aiBody",
@@ -407,9 +396,7 @@ function CapStep({
)} )}
</li> </li>
<li> <li>
<strong> <strong>{t("payg.upgrade.help.apiTitle", "API calls")}</strong>
{t("payg.upgrade.help.apiTitle", "API calls")}
</strong>
{" — "} {" — "}
{t( {t(
"payg.upgrade.help.apiBody", "payg.upgrade.help.apiBody",
@@ -447,10 +434,7 @@ function CheckoutStep({
return ( return (
<> <>
<h3 className="upm-section-title"> <h3 className="upm-section-title">
{t( {t("payg.upgrade.checkout.title", "Add your payment method")}
"payg.upgrade.checkout.title",
"Add your payment method",
)}
</h3> </h3>
<p className="upm-section-help"> <p className="upm-section-help">
{t( {t(
@@ -498,10 +482,7 @@ function ConfirmationStep({
<div className="upm-confirm"> <div className="upm-confirm">
<CheckCircleIcon className="upm-confirm__icon" /> <CheckCircleIcon className="upm-confirm__icon" />
<h3 className="upm-confirm__title"> <h3 className="upm-confirm__title">
{t( {t("payg.confirm.title", "Welcome to the Processor plan")}
"payg.confirm.title",
"Welcome to the Processor plan",
)}
</h3> </h3>
<p className="upm-confirm__body"> <p className="upm-confirm__body">
{t( {t(
@@ -151,11 +151,7 @@ function appendMcpSection(
export function createSaasConfigNavSections( export function createSaasConfigNavSections(
Overview: OverviewComponent, Overview: OverviewComponent,
onLogoutClick: () => void, onLogoutClick: () => void,
{ { isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
isDev = false,
isAnonymous = false,
t,
}: CreateSaasConfigNavSectionsOptions,
): ConfigNavSection[] { ): ConfigNavSection[] {
const baseSections = createCoreConfigNavSections(false, false, false); const baseSections = createCoreConfigNavSections(false, false, false);
@@ -4,7 +4,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; /* 12px */ gap: 0.75rem; /* 12px */
margin-bottom: 0.625rem; /* 10px */
} }
.oauth-button-fullwidth { .oauth-button-fullwidth {
@@ -112,9 +112,7 @@ describe("extractSignupCategory", () => {
expect( expect(
extractSignupCategory({ response: { data: { category: 7 } } }), extractSignupCategory({ response: { data: { category: 7 } } }),
).toBeNull(); ).toBeNull();
expect( expect(extractSignupCategory({ response: { data: {} } })).toBeNull();
extractSignupCategory({ response: { data: {} } }),
).toBeNull();
}); });
}); });