From 33026e1a82750450b4f4bd8079da687a463a3b52 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:39:02 +0100 Subject: [PATCH] update saas onboarding (#6619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2026-06-11 at 6 20 10 PM --- .../public/locales/en-GB/translation.toml | 149 +++++--------- .../InitialOnboardingModal.module.css | 11 +- .../onboarding/slides/DesktopInstallSlide.tsx | 7 +- .../slides/unifiedBackgroundConfig.ts | 38 +++- frontend/editor/src/core/types/types.ts | 2 + .../policies/PolicyDeleteConfirmModal.tsx | 4 +- .../onboarding/SaasOnboardingModal.tsx | 27 ++- .../components/onboarding/saasFlowResolver.ts | 55 ++--- .../onboarding/saasOnboardingFlowConfig.ts | 95 ++++----- .../onboarding/slides/FreeEditorSlide.tsx | 44 ++++ .../onboarding/slides/FreeTrialSlide.tsx | 108 ---------- .../slides/SaasOnboardingSlides.module.css | 91 +++++++++ .../onboarding/slides/TeamSlide.tsx | 191 ++++++++++++++++++ .../onboarding/slides/UsageSnapshotSlide.tsx | 45 +++++ .../onboarding/useSaasOnboardingState.ts | 30 ++- .../shared/config/configSections/Payg.tsx | 47 +++-- .../shared/config/configSections/PaygFree.css | 4 +- .../shared/config/configSections/PaygFree.tsx | 26 ++- .../shared/config/configSections/Plan.tsx | 5 +- .../config/configSections/SpendCapControl.css | 9 +- .../config/configSections/SpendCapControl.tsx | 10 +- .../configSections/StripeCheckoutPanel.tsx | 7 +- .../config/configSections/UpgradeModal.css | 13 +- .../config/configSections/UpgradeModal.tsx | 43 ++-- .../shared/config/saasConfigNavSections.tsx | 6 +- .../src/saas/routes/authShared/saas-auth.css | 1 - .../services/paygErrorInterceptor.test.ts | 4 +- 27 files changed, 659 insertions(+), 413 deletions(-) create mode 100644 frontend/editor/src/saas/components/onboarding/slides/FreeEditorSlide.tsx delete mode 100644 frontend/editor/src/saas/components/onboarding/slides/FreeTrialSlide.tsx create mode 100644 frontend/editor/src/saas/components/onboarding/slides/SaasOnboardingSlides.module.css create mode 100644 frontend/editor/src/saas/components/onboarding/slides/TeamSlide.tsx create mode 100644 frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 214c05f16..050104088 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -50,12 +50,9 @@ help = "Help" imgPrompt = "Select Image(s)" incorrectPasswordMessage = "Current password is incorrect." info = "Info" -insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}" invalidUndoData = "Cannot undo: invalid operation data" keepWorking = "Keep Working" loading = "Loading..." -loadingCredits = "Checking credits..." -loadingProStatus = "Checking subscription status..." logOut = "Log out" marginTooltip = "Distance between the page number and the edge of the page." 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" noOperationToUndo = "No operation to undo" nothingToUndo = "Nothing to undo" -noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan" noValidFiles = "No valid files to process" oops = "Oops!" openInNewWindow = "Open in new window" @@ -3099,18 +3095,6 @@ integration = "Integration Configuration" security = "Security Configuration" system = "System Configuration" -[config.payg] -label = "Billing & usage" -section = "Pay-as-you-go" - -[connectionMode.status] -localOffline = "Offline mode running" -localOnline = "Offline mode running" -saas = "Connected to Stirling Cloud" -selfhostedChecking = "Connected to self-hosted server (checking...)" -selfhostedOffline = "Self-hosted server unreachable" -selfhostedOnline = "Connected to self-hosted server" - [convert] autoRotate = "Auto Rotate" 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" 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] authenticationCode = "Authentication 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." userTitle = "Plan Overview" +[onboarding.saas.freeEditor] +freeLine = "And the best part? The editor is now completely free." +premium = "We've added a whole host of new features, including Policies and Agent Chat." +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] message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue." roleAdmin = "Admin" @@ -5193,50 +5184,24 @@ title = "Page Ranges" bullet1 = "all → selects all pages" title = "Special Keywords" -[pageSelection.tooltip.syntax] -text = "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported." -title = "Syntax Basics" - -[pageSelection.tooltip.syntax.bullets] -keywords = "Keywords: odd, even" -numbers = "Numbers/ranges: 5, 10-20" -progressions = "Progressions: 3n, 4n+1" - -[pageSelection.tooltip.tips] -bullet1 = "Page numbers start from 1 (not 0)" -bullet2 = "Spaces are automatically removed" -bullet3 = "Invalid expressions are ignored" -text = "Keep these guidelines in mind:" -title = "Tips" - -[payg] -subtitle = "Pay-as-you-go: you only pay for what you process. Billing period {{start}} – {{end}}." - [payg.activity] +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." title = "Recent billable activity" -units = "units" -viewAll = "View all" [payg.cap] amount = "Cap amount" -currency = "Currency" custom = "Custom" -degradeAt = "Limit spend at" -degradeAtDesc = "Set below 100% to leave yourself headroom." docsEstimate = "≈ {{docs}} processed PDFs / month" docsRate = "at {{rate}} / PDF" noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time." noCapLabel = "No cap" +noneShort = "No cap" perMonth = "/ month" -preview = "{{money}} ≈ {{units}} PDFs per month" -previewNote = "At current pricing. Final translation happens server-side on save." -previewShort = "≈ {{units}} PDFs per month at current pricing." save = "Update cap" subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier." title = "Monthly spending cap" -warnAt = "Warn me at" -warnAtDesc = "Notify when usage crosses this threshold." [payg.checkout] connecting = "Connecting to Stripe…" @@ -5259,6 +5224,13 @@ note = "You can change your cap, cancel, or open the Stripe customer portal any summaryLabel = "Monthly ceiling" 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] body = "We couldn't reach the billing service. Refresh the page to try again." title = "Couldn't load your plan" @@ -5283,29 +5255,17 @@ title = "Turn on the Processor plan" [payg.free.editor] eyebrow = "Editor plan · Always free" -[payg.free.explainer] -alwaysFreeBody = "Manual tools: viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, no matter where you trigger them from." -alwaysFreeForYouBody = "Manual tools: viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, never counted." -alwaysFreeForYouLabel = "Always free for you" -alwaysFreeLabel = "Always free" -countsBody = "Automation pipelines (chained tools, scheduled runs), AI tools (summaries, classification, AI-OCR), and API calls (programmatic access). Past your free PDFs you'll need Processor." -countsLabel = "Counts toward your 500 free" -sharedBody = "Automation pipelines, AI tools, and API calls share a single one-time allowance of 500 free PDFs across the whole team. Once it's used up, ask your owner to enable Processor." -sharedLabel = "Shared with the team" - [payg.free.header] -subtitle = "Editor plan: manual tools are always free. Pay only for automation, AI & API. Billing period {{period}}." +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] capSuffix = "/ {{limit}} free PDFs" -eyebrow = "Your free PDFs" metaCategories = "Automation · AI · API requests" neverResets = "One-time, never resets" [payg.free.member] -body = "Your team owner can enable the Processor plan and set a monthly ceiling. Until then, manual tools are free for you to use as much as you like. Automation, AI, and API access share the team's one-time allowance of 500 free PDFs." ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like." -title = "Want to process more than your 500 free PDFs?" [payg.free.proc] eyebrow = "Processor plan · metered" @@ -5321,9 +5281,17 @@ automation = "Automations & pipelines" client = "Browser-only tools (viewer, page editor, file management)" offsite = "Server tools (compress, OCR, convert, watermark…)" pauses = "pauses at cap" -subtitle = "Your everyday tools keep working. Only AI and automation pause until the cap resets or is raised." title = "What happens when the cap is reached" +[payg.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] 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" [payg.upgrade] +backAria = "Back" closeAria = "Close" [payg.upgrade.button] -back = "← Back" cancel = "Cancel" continue = "Continue →" finish = "Finish" [payg.upgrade.cap] -customPlaceholder = "Or enter your own amount ($0 keeps it free)" help = "We'll never charge above this. Set $0 if you want to keep everything free while testing." -noCapHint = "You can still cancel anytime from the customer portal." -noCapLabel = "No cap, I'll manage spend manually" -perMonthSuffix = "/ month" -presetsAria = "Monthly cap preset" title = "Set your monthly spend ceiling" usdNote = "Estimated in USD. You can adjust your cap any time after subscribing, in your own currency." [payg.upgrade.checkout] -capLabel = "Monthly ceiling:" capValue = "{{symbol}}{{amount}} / month" edit = "Edit" help = "Stripe handles your card details. Stirling never sees them." @@ -5391,11 +5353,6 @@ loading = "Loading checkout…" noCap = "No cap" title = "Add your payment method" -[payg.upgrade.footer] -capHint = "You can change your cap any time later." -checkoutHint = "Card details handled by Stripe, never touched by Stirling." -confirmHint = "Your wallet will refresh automatically in a moment." - [payg.upgrade.help] aiBody = "summarise, classify, redact, AI-OCR" aiTitle = "AI tools" @@ -5419,11 +5376,15 @@ confirm = "You're subscribed" default = "Upgrade to Processor plan" [payg.usage] -credit = "{{amount}} account credit" -docs = "documents" +breakdown = "AI {{ai}} • Automation {{automation}} • API {{api}}" +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" resetsTomorrow = "Resets tomorrow" -spent = "{{spend}} of {{cap}} used" thisPeriod = "This billing period" [payment] @@ -5749,28 +5710,17 @@ manage = "Manage" perMonth = "/month" perSeat = "/seat" popular = "Popular" -purchase = "Purchase" -selectCredits = "Select Credit Amount" selectPlan = "Select Plan" showComparison = "Compare All Features" -totalCost = "Total Cost" upgrade = "Upgrade" withServer = "+ Server Plan" -[plan.activePlan] -subtitle = "Your current subscription details" -title = "Active Plan" - [plan.api] large = "5,000 Credits" medium = "1,000 Credits" small = "500 Credits" xsmall = "100 Credits" -[plan.apiPackages] -subtitle = "Purchase API credits for your applications" -title = "API Credit Packages" - [plan.availablePlans] loadError = "Unable to load plan pricing. Using default values." 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" [plan.trial] -badge = "Trial" continueWithFree = "Continue with Free" -daysRemaining = "Your trial ends in {{days}} days" -endDate = "Expires: {{date}}" 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." freeTierLimitations = "Free tier includes basic PDF tools with usage limits." message = "" subscribe = "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] deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." diff --git a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css index 5b3aec2d9..c1d88f46c 100644 --- a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css +++ b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css @@ -34,8 +34,8 @@ } .standaloneIcon { - width: 96px; - height: 96px; + width: 80px; + height: 80px; object-fit: contain; animation: heroLogoScale 0.25s ease forwards; } @@ -124,8 +124,6 @@ align-items: flex-start; justify-content: center; animation: heroLogoEnter 0.25s ease forwards; - position: relative; - top: 1rem; } .iconWrapper { @@ -175,8 +173,8 @@ } .downloadIcon { - width: 96px; - height: 96px; + width: 80px; + height: 80px; object-fit: contain; animation: heroLogoScale 0.25s ease forwards; } @@ -205,6 +203,7 @@ } .bodyCopy { + text-align: center; opacity: 0; transform: translateX(24px); animation: bodySlideIn 0.25s ease forwards; diff --git a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallSlide.tsx index eb3e74352..d8549d0ea 100644 --- a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallSlide.tsx @@ -1,7 +1,7 @@ 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 { UNIFIED_LIGHT_BACKGROUND } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; import { DesktopInstallTitle, type OSOption, @@ -47,9 +47,6 @@ export default function DesktopInstallSlide({ ), body: , downloadUrl: osUrl, - background: { - gradientStops: ["#2563EB", "#0EA5E9"], - circles: UNIFIED_CIRCLE_CONFIG, - }, + background: UNIFIED_LIGHT_BACKGROUND, }; } diff --git a/frontend/editor/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts b/frontend/editor/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts index 33ac2d291..a0a8c77d1 100644 --- a/frontend/editor/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts +++ b/frontend/editor/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts @@ -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. @@ -27,3 +30,36 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [ 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", +); diff --git a/frontend/editor/src/core/types/types.ts b/frontend/editor/src/core/types/types.ts index fd37a20d6..29133ac09 100644 --- a/frontend/editor/src/core/types/types.ts +++ b/frontend/editor/src/core/types/types.ts @@ -16,6 +16,8 @@ export interface AnimatedCircleConfig { export interface AnimatedSlideBackgroundProps { gradientStops: [string, string]; 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 { diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx index 02b46928c..0d40660d1 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx @@ -21,7 +21,9 @@ export function PolicyDeleteConfirmModal({ diff --git a/frontend/editor/src/saas/components/onboarding/SaasOnboardingModal.tsx b/frontend/editor/src/saas/components/onboarding/SaasOnboardingModal.tsx index 4af91fd02..491aad7dc 100644 --- a/frontend/editor/src/saas/components/onboarding/SaasOnboardingModal.tsx +++ b/frontend/editor/src/saas/components/onboarding/SaasOnboardingModal.tsx @@ -1,8 +1,8 @@ import React from "react"; 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 LocalIcon from "@app/components/shared/LocalIcon"; import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; import OnboardingStepper from "@app/components/onboarding/OnboardingStepper"; import { renderButtons } from "@app/components/onboarding/renderButtons"; @@ -48,18 +48,23 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) { ); } + if (slideDefinition.hero.type === "logo") { + return ( + Stirling logo + ); + } + return (
- {slideDefinition.hero.type === "rocket" && ( - + {slideDefinition.hero.type === "bolt" && ( + )} - {slideDefinition.hero.type === "diamond" && ( - + {slideDefinition.hero.type === "team" && ( + )}
); diff --git a/frontend/editor/src/saas/components/onboarding/saasFlowResolver.ts b/frontend/editor/src/saas/components/onboarding/saasFlowResolver.ts index 539418a0c..ed723852c 100644 --- a/frontend/editor/src/saas/components/onboarding/saasFlowResolver.ts +++ b/frontend/editor/src/saas/components/onboarding/saasFlowResolver.ts @@ -1,42 +1,25 @@ -import { TrialStatus } from "@app/auth/UseSession"; -import { - FLOW_SEQUENCES, - SlideId, -} from "@app/components/onboarding/saasOnboardingFlowConfig"; +import { SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig"; -export interface FlowConfig { - type: "saas-trial" | "saas-paid"; - ids: SlideId[]; +export interface SaasFlowInputs { + /** Free-tier wallet with one-time allowance remaining — show the usage meter. */ + 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. - * - * @param trialStatus - User's trial information from Supabase - * @param _isPro - Whether user has Pro subscription - * @returns FlowConfig with the appropriate slide sequence + * Resolves the SaaS onboarding slide sequence. The free-editor pitch and + * desktop install bookend the flow; the usage meter and team slides slot in + * when their conditions hold. */ -export function resolveSaasFlow( - trialStatus: TrialStatus | null, - _isPro: boolean | null, -): FlowConfig { - // Show free trial card if: - // 1. User has active trial (isTrialing = true) - // 2. Trial has not expired (daysRemaining > 0) - // 3. User is not paid Pro (or Pro is from trial) - const hasActiveTrial = - 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, - }; +export function resolveSaasFlow({ + showUsageSlide, + showTeamSlide, +}: SaasFlowInputs): SlideId[] { + return [ + "free-editor", + ...(showUsageSlide ? (["usage"] as const) : []), + ...(showTeamSlide ? (["team"] as const) : []), + "desktop-install", + ]; } diff --git a/frontend/editor/src/saas/components/onboarding/saasOnboardingFlowConfig.ts b/frontend/editor/src/saas/components/onboarding/saasOnboardingFlowConfig.ts index 342e1fe87..91af54611 100644 --- a/frontend/editor/src/saas/components/onboarding/saasOnboardingFlowConfig.ts +++ b/frontend/editor/src/saas/components/onboarding/saasOnboardingFlowConfig.ts @@ -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 FreeTrialSlide from "@app/components/onboarding/slides/FreeTrialSlide"; 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"; @@ -23,7 +23,6 @@ export interface SlideFactoryParams { osUrl: string; osOptions?: OSOption[]; onDownloadUrlChange?: (url: string) => void; - trialStatus?: TrialStatus | null; } export interface HeroDefinition { @@ -48,47 +47,46 @@ export interface SlideDefinition { 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 = { - welcome: { - id: "welcome", - createSlide: () => WelcomeSlide(), - hero: { type: "rocket" }, + "free-editor": { + id: "free-editor", + createSlide: () => FreeEditorSlide(), + hero: { type: "logo" }, + buttons: [{ ...NEXT_BUTTON, key: "free-editor-next" }], + }, + usage: { + id: "usage", + createSlide: () => UsageSnapshotSlide(), + hero: { type: "bolt" }, buttons: [ - { - key: "welcome-next", - type: "button", - label: "onboarding.buttons.next", - variant: "primary", - group: "right", - action: "next", - }, + { ...BACK_BUTTON, key: "usage-back" }, + { ...NEXT_BUTTON, key: "usage-next" }, ], }, - "free-trial": { - id: "free-trial", - createSlide: ({ trialStatus }) => { - if (!trialStatus) { - throw new Error("Trial status is required for free-trial slide"); - } - return FreeTrialSlide({ trialStatus }); - }, - hero: { type: "diamond" }, + team: { + id: "team", + createSlide: () => TeamSlide(), + hero: { type: "team" }, buttons: [ - { - key: "trial-back", - type: "icon", - icon: "chevron-left", - group: "left", - action: "prev", - }, - { - key: "trial-next", - type: "button", - label: "onboarding.buttons.next", - variant: "primary", - group: "right", - action: "next", - }, + { ...BACK_BUTTON, key: "team-back" }, + { ...NEXT_BUTTON, key: "team-next" }, ], }, "desktop-install": { @@ -97,13 +95,7 @@ export const SLIDE_DEFINITIONS: Record = { DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }), hero: { type: "dual-icon" }, buttons: [ - { - key: "desktop-back", - type: "icon", - icon: "chevron-left", - group: "left", - action: "prev", - }, + { ...BACK_BUTTON, key: "desktop-back" }, { key: "desktop-skip", type: "button", @@ -123,8 +115,3 @@ export const SLIDE_DEFINITIONS: Record = { ], }, }; - -export const FLOW_SEQUENCES = { - saasTrialUser: ["welcome", "free-trial", "desktop-install"] as SlideId[], - saasPaidUser: ["welcome", "desktop-install"] as SlideId[], -}; diff --git a/frontend/editor/src/saas/components/onboarding/slides/FreeEditorSlide.tsx b/frontend/editor/src/saas/components/onboarding/slides/FreeEditorSlide.tsx new file mode 100644 index 000000000..1aca39931 --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/slides/FreeEditorSlide.tsx @@ -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 = () => ( + + }} + defaults="We've added a whole host of new features, including Policies and Agent Chat." + /> + + }} + defaults="And the best part? The editor is now completely free." + /> + + +); + +export default function FreeEditorSlide(): SlideConfig { + const title = ( + + ); + + return { + key: "free-editor", + title, + body: , + background: FREE_EDITOR_BACKGROUND, + }; +} diff --git a/frontend/editor/src/saas/components/onboarding/slides/FreeTrialSlide.tsx b/frontend/editor/src/saas/components/onboarding/slides/FreeTrialSlide.tsx deleted file mode 100644 index 70ad08c98..000000000 --- a/frontend/editor/src/saas/components/onboarding/slides/FreeTrialSlide.tsx +++ /dev/null @@ -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 ( - {t("onboarding.freeTrial.title", "Your 30-Day Pro Trial")} - ); -} - -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 ( -
-

- {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.", - )} -

-
-
- {daysText} -
-
- {t("onboarding.freeTrial.trialEnds", "Trial ends {{date}}", { - date: trialEndDate, - })} -
-
-

{afterTrialMessage}

-
- ); -}; - -export default function FreeTrialSlide({ - trialStatus, -}: FreeTrialSlideProps): SlideConfig { - return { - key: "free-trial", - title: , - body: , - background: { - gradientStops: ["#10B981", "#06B6D4"], - circles: UNIFIED_CIRCLE_CONFIG, - }, - }; -} diff --git a/frontend/editor/src/saas/components/onboarding/slides/SaasOnboardingSlides.module.css b/frontend/editor/src/saas/components/onboarding/slides/SaasOnboardingSlides.module.css new file mode 100644 index 000000000..3fcdb9383 --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/slides/SaasOnboardingSlides.module.css @@ -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; +} diff --git a/frontend/editor/src/saas/components/onboarding/slides/TeamSlide.tsx b/frontend/editor/src/saas/components/onboarding/slides/TeamSlide.tsx new file mode 100644 index 000000000..1bc78a837 --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/slides/TeamSlide.tsx @@ -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(null); + const [success, setSuccess] = useState(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 ( +
+ + setEmail(e.target.value)} + error={ + email && !emailValid + ? t("team.invite.invalidEmail", "Invalid email format") + : undefined + } + /> + + + {error && ( + + {error} + + )} + {success && ( + + {success} + + )} +
+ ); +} + +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 ( + + {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.", + )} + + {hasTeam && ( + + {teamMembers.map((member) => ( + + + {member.username} + {member.email} + + + {member.role} + + + ))} + {pendingInvitations.map((invitation) => ( + + + + {invitation.inviteeEmail.split("@")[0]} + + + {invitation.inviteeEmail} + + + + {t("team.members.pending", "PENDING")} + + + ))} + + )} + + + + ); +}; + +export default function TeamSlide(): SlideConfig { + return { + key: "team", + title: , + body: , + background: TEAM_BACKGROUND, + }; +} diff --git a/frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx b/frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx new file mode 100644 index 000000000..4c032fbfd --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx @@ -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 ( + + {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 */} + + + + + ); +}; + +export default function UsageSnapshotSlide(): SlideConfig { + return { + key: "usage-snapshot", + title: i18n.t( + "onboarding.saas.usage.title", + "Your free Processor allowance", + ), + body: , + background: USAGE_BACKGROUND, + }; +} diff --git a/frontend/editor/src/saas/components/onboarding/useSaasOnboardingState.ts b/frontend/editor/src/saas/components/onboarding/useSaasOnboardingState.ts index e0728556f..7ef384e16 100644 --- a/frontend/editor/src/saas/components/onboarding/useSaasOnboardingState.ts +++ b/frontend/editor/src/saas/components/onboarding/useSaasOnboardingState.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useAuth } from "@app/auth/UseSession"; import { useOs } from "@app/hooks/useOs"; +import { useWallet } from "@app/hooks/useWallet"; +import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; import { SLIDE_DEFINITIONS, type ButtonAction, @@ -28,7 +30,9 @@ export function useSaasOnboardingState({ opened, onClose, }: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null { - const { trialStatus, isPro, loading } = useAuth(); + const { loading } = useAuth(); + const { wallet } = useWallet(); + const { isTeamLeader } = useSaaSTeam(); const osType = useOs(); const selectedDownloadUrlRef = useRef(""); @@ -70,13 +74,15 @@ export function useSaasOnboardingState({ selectedDownloadUrlRef.current = url; }, []); - // Resolve flow based on trial status - const resolvedFlow = useMemo( - () => resolveSaasFlow(trialStatus, isPro), - [trialStatus, isPro], - ); + // Usage meter only makes sense for free-tier wallets with allowance left; + // the team slide is for leaders (anonymous guests are never leaders). + const showUsageSlide = wallet?.status === "free" && wallet.freeRemaining > 0; + const showTeamSlide = isTeamLeader; - const flowSlideIds = resolvedFlow.ids; + const flowSlideIds = useMemo( + () => resolveSaasFlow({ showUsageSlide, showTeamSlide }), + [showUsageSlide, showTeamSlide], + ); const totalSteps = flowSlideIds.length; const maxIndex = Math.max(totalSteps - 1, 0); @@ -99,16 +105,8 @@ export function useSaasOnboardingState({ osUrl: os.url, osOptions, onDownloadUrlChange: handleDownloadUrlChange, - trialStatus: trialStatus ?? undefined, }); - }, [ - slideDefinition, - os.label, - os.url, - osOptions, - handleDownloadUrlChange, - trialStatus, - ]); + }, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange]); // Navigation functions const goNext = useCallback(() => { diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx index 164ae9caa..7c5f49945 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx @@ -209,9 +209,7 @@ function UsageHero({ wallet }: { wallet: Wallet }) { const limit = wallet.billableLimit; const hasLimit = limit != null && limit > 0; - const pct = hasLimit - ? Math.min(100, (wallet.billableUsed / limit) * 100) - : 0; + const pct = hasLimit ? Math.min(100, (wallet.billableUsed / limit) * 100) : 0; const state = hasLimit ? pct >= 100 ? "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 hasCap = !noCap && capUsd != null; return ( @@ -567,10 +571,7 @@ function ActivityFeed({ recent }: { recent: Wallet["recent"] }) { {recent.length === 0 ? ( - {t( - "payg.activity.empty", - "No billable activity yet this period.", - )} + {t("payg.activity.empty", "No billable activity yet this period.")} ) : (
@@ -603,7 +604,11 @@ function ActivityFeed({ recent }: { recent: Wallet["recent"] }) { // ─── Stripe CTA ────────────────────────────────────────────────────────────── -function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise }) { +function StripePortalLink({ + onOpenPortal, +}: { + onOpenPortal: () => Promise; +}) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); @@ -662,7 +667,12 @@ function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise // ─── Main component ─────────────────────────────────────────────────────── -const Payg: React.FC = ({ role, wallet, onSaveCap, onOpenPortal }) => { +const Payg: React.FC = ({ + role, + wallet, + onSaveCap, + onOpenPortal, +}) => { useRenderCount(role === "LEADER" ? "PaygLeader" : "PaygMember"); const { t } = useTranslation(); const isLeader = role === "LEADER"; @@ -681,10 +691,14 @@ const Payg: React.FC = ({ role, wallet, onSaveCap, onOpenPortal }) =>
- {t("payg.header.eyebrow", "Processor plan · {{start}} – {{end}}", { - start: fmt(wallet.billingPeriodStart), - end: fmt(wallet.billingPeriodEnd), - })} + {t( + "payg.header.eyebrow", + "Processor plan · {{start}} – {{end}}", + { + start: fmt(wallet.billingPeriodStart), + end: fmt(wallet.billingPeriodEnd), + }, + )} {isLeader @@ -715,7 +729,10 @@ const Payg: React.FC = ({ role, wallet, onSaveCap, onOpenPortal }) =>
- + {t("payg.header.meterLabel", "Metered")}

diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css index 20534849d..17ef96e8b 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css +++ b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css @@ -227,7 +227,9 @@ font-size: 0.95rem; font-family: inherit; cursor: pointer; - transition: transform 120ms ease, box-shadow 120ms ease; + transition: + transform 120ms ease, + box-shadow 120ms ease; white-space: nowrap; } .paygf-cta__button:hover { diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx index 27d77be48..ced4f4c2b 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx @@ -43,7 +43,7 @@ import { DocHelp } from "./Payg"; // ─── Shared free-tier snapshot ──────────────────────────── -interface FreeSnapshot { +export interface FreeSnapshot { /** One-time free documents used so far (grant − remaining). */ billableUsed: number; /** @@ -61,7 +61,7 @@ interface FreeSnapshot { * numbers. Earlier versions returned a mock "62 of 500" sentinel which leaked * into the rendered UI and made the page look like nothing was wired up. */ -function useFreeSnapshot(): FreeSnapshot { +export function useFreeSnapshot(): FreeSnapshot { const { wallet } = useWallet(); return useMemo(() => { if (wallet) { @@ -80,7 +80,10 @@ function useFreeSnapshot(): FreeSnapshot { type MeterState = "FULL" | "WARNED" | "DEGRADED"; /** Warn/degrade band for the one-time grant meter (mirrors the BE thresholds). */ -function meterState(used: number, limit: number): { state: MeterState; pct: number } { +function meterState( + used: number, + limit: number, +): { state: MeterState; pct: number } { const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100; const state: MeterState = pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL"; @@ -113,7 +116,10 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) { /> {t("payg.free.editor.eyebrow", "Editor plan · Always free")} - + {pill}

@@ -132,7 +138,7 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) { // ─── 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 { state, pct } = meterState(snap.billableUsed, snap.billableLimit); const stateLabel = @@ -174,7 +180,9 @@ function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { {t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
- {t("payg.free.hero.neverResets", "One-time, never resets")} + + {t("payg.free.hero.neverResets", "One-time — never resets")} +
); @@ -240,8 +248,10 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
  • - {t("payg.free.cta.benefit3Title", "API access")} - {": "} + + {t("payg.free.cta.benefit3Title", "API access")} + + {" — "} {t( "payg.free.cta.benefit3Body", "call any Stirling endpoint programmatically", diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx index eaeccbc72..ff34d9579 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx @@ -59,7 +59,10 @@ const Plan: React.FC = () => { if (error || !wallet) { return ( - + {error ?? t( "payg.error.body", diff --git a/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css b/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css index d4387e313..90083fb80 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css +++ b/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css @@ -53,7 +53,10 @@ font-weight: 600; cursor: pointer; 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) { color: var(--text-primary); @@ -82,7 +85,9 @@ border: 1px dashed var(--scc-chip-border); background: transparent; 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 { border-color: var(--border-strong); diff --git a/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx b/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx index fea248ca0..bee9d0474 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx @@ -62,7 +62,10 @@ export interface SpendCapControlProps { } /** 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(); try { return new Intl.NumberFormat(undefined, { @@ -212,7 +215,10 @@ const SpendCapControl: React.FC = ({ {previewDocs != null && (
    - +
    {t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", { diff --git a/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx b/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx index 4df290dde..24ad959e7 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx @@ -148,8 +148,7 @@ const StripeCheckoutPanel: React.FC = ({ const tRef = useRef(t); tRef.current = t; - const publishableKey = - import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; + const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; // Dev preview route has no backend — skip the API call and go straight // to the mock placeholder so the design + completion path stay testable. @@ -214,7 +213,9 @@ const StripeCheckoutPanel: React.FC = ({ } if (cancelled) return; 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) { if (cancelled) return; const msg = diff --git a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css index 5840bd8df..2e7aafef5 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css +++ b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css @@ -112,7 +112,9 @@ display: inline-flex; align-items: center; justify-content: center; - transition: background 120ms ease, color 120ms ease; + transition: + background 120ms ease, + color 120ms ease; } .upm-header__close:hover { background: var(--upm-divider); @@ -141,7 +143,9 @@ justify-content: center; flex-shrink: 0; margin-left: -6px; - transition: background 120ms ease, color 120ms ease; + transition: + background 120ms ease, + color 120ms ease; } .upm-header__back:hover { background: var(--upm-divider); @@ -177,7 +181,10 @@ justify-content: center; font-size: 0.7rem; 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"] { color: var(--upm-text); diff --git a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx index ec9f58723..42139f109 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx @@ -161,7 +161,10 @@ export default function UpgradeModal({

    {step === "confirm" ? 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", + )}

    @@ -342,10 +342,7 @@ function CapStep({
  • - {t( - "payg.upgrade.cap.title", - "Set your monthly spend ceiling", - )} + {t("payg.upgrade.cap.title", "Set your monthly spend ceiling")}

    {t( @@ -377,18 +374,12 @@ function CapStep({

    - {t( - "payg.upgrade.help.title", - "What we count toward billing", - )} + {t("payg.upgrade.help.title", "What we count toward billing")}
    • - {t( - "payg.upgrade.help.automationTitle", - "Automation pipelines", - )} + {t("payg.upgrade.help.automationTitle", "Automation pipelines")} {" — "} {t( @@ -397,9 +388,7 @@ function CapStep({ )}
    • - - {t("payg.upgrade.help.aiTitle", "AI tools")} - + {t("payg.upgrade.help.aiTitle", "AI tools")} {" — "} {t( "payg.upgrade.help.aiBody", @@ -407,9 +396,7 @@ function CapStep({ )}
    • - - {t("payg.upgrade.help.apiTitle", "API calls")} - + {t("payg.upgrade.help.apiTitle", "API calls")} {" — "} {t( "payg.upgrade.help.apiBody", @@ -447,10 +434,7 @@ function CheckoutStep({ return ( <>

      - {t( - "payg.upgrade.checkout.title", - "Add your payment method", - )} + {t("payg.upgrade.checkout.title", "Add your payment method")}

      {t( @@ -498,10 +482,7 @@ function ConfirmationStep({

      - {t( - "payg.confirm.title", - "Welcome to the Processor plan", - )} + {t("payg.confirm.title", "Welcome to the Processor plan")}

      {t( diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx index 562c010ad..c0a451a69 100644 --- a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx @@ -151,11 +151,7 @@ function appendMcpSection( export function createSaasConfigNavSections( Overview: OverviewComponent, onLogoutClick: () => void, - { - isDev = false, - isAnonymous = false, - t, - }: CreateSaasConfigNavSectionsOptions, + { isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions, ): ConfigNavSection[] { const baseSections = createCoreConfigNavSections(false, false, false); diff --git a/frontend/editor/src/saas/routes/authShared/saas-auth.css b/frontend/editor/src/saas/routes/authShared/saas-auth.css index 9a8943fc2..cfa8397d4 100644 --- a/frontend/editor/src/saas/routes/authShared/saas-auth.css +++ b/frontend/editor/src/saas/routes/authShared/saas-auth.css @@ -4,7 +4,6 @@ display: flex; flex-direction: column; gap: 0.75rem; /* 12px */ - margin-bottom: 0.625rem; /* 10px */ } .oauth-button-fullwidth { diff --git a/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts b/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts index 84be4ecc9..c4dfac1cd 100644 --- a/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts +++ b/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts @@ -112,9 +112,7 @@ describe("extractSignupCategory", () => { expect( extractSignupCategory({ response: { data: { category: 7 } } }), ).toBeNull(); - expect( - extractSignupCategory({ response: { data: {} } }), - ).toBeNull(); + expect(extractSignupCategory({ response: { data: {} } })).toBeNull(); }); });