mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -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."
|
||||||
|
|||||||
+5
-6
@@ -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",
|
||||||
|
);
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
const BACK_BUTTON: ButtonDefinition = {
|
||||||
welcome: {
|
key: "back",
|
||||||
id: "welcome",
|
|
||||||
createSlide: () => WelcomeSlide(),
|
|
||||||
hero: { type: "rocket" },
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
key: "welcome-next",
|
|
||||||
type: "button",
|
|
||||||
label: "onboarding.buttons.next",
|
|
||||||
variant: "primary",
|
|
||||||
group: "right",
|
|
||||||
action: "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" },
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
key: "trial-back",
|
|
||||||
type: "icon",
|
type: "icon",
|
||||||
icon: "chevron-left",
|
icon: "chevron-left",
|
||||||
group: "left",
|
group: "left",
|
||||||
action: "prev",
|
action: "prev",
|
||||||
},
|
};
|
||||||
{
|
|
||||||
key: "trial-next",
|
const NEXT_BUTTON: ButtonDefinition = {
|
||||||
|
key: "next",
|
||||||
type: "button",
|
type: "button",
|
||||||
label: "onboarding.buttons.next",
|
label: "onboarding.buttons.next",
|
||||||
variant: "primary",
|
variant: "primary",
|
||||||
group: "right",
|
group: "right",
|
||||||
action: "next",
|
action: "next",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||||
|
"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: [
|
||||||
|
{ ...BACK_BUTTON, key: "usage-back" },
|
||||||
|
{ ...NEXT_BUTTON, key: "usage-next" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
id: "team",
|
||||||
|
createSlide: () => TeamSlide(),
|
||||||
|
hero: { type: "team" },
|
||||||
|
buttons: [
|
||||||
|
{ ...BACK_BUTTON, key: "team-back" },
|
||||||
|
{ ...NEXT_BUTTON, key: "team-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(
|
||||||
|
"payg.header.eyebrow",
|
||||||
|
"Processor plan · {{start}} – {{end}}",
|
||||||
|
{
|
||||||
start: fmt(wallet.billingPeriodStart),
|
start: fmt(wallet.billingPeriodStart),
|
||||||
end: fmt(wallet.billingPeriodEnd),
|
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",
|
||||||
|
|||||||
+7
-2
@@ -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);
|
||||||
|
|||||||
+8
-2
@@ -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", {
|
||||||
|
|||||||
+4
-3
@@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user