Add SaaS frontend code (#5879)

# Description of Changes
Adds the code for the SaaS frontend as proprietary code to the OSS repo.
This version of the code is adapted from 22/1/2026, which was the last
SaaS version based on the 'V2' design. This will move us closer to being
able to have the OSS products understand whether the user has a SaaS
account, and provide the correct UI in those cases.
This commit is contained in:
James Brunton
2026-03-11 11:53:54 +00:00
committed by GitHub
parent 8bc37bf5ae
commit fa8c52b2be
114 changed files with 12408 additions and 99 deletions
@@ -0,0 +1,7 @@
/**
* SaaS stub — core tour system is suppressed in SaaS.
* SaaS uses SaasOnboardingModal instead.
*/
export default function OnboardingTour() {
return null;
}
@@ -0,0 +1,131 @@
import React from 'react';
import { Modal, Stack } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
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';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import { useSaasOnboardingState } from '@app/components/onboarding/useSaasOnboardingState';
import { BASE_PATH } from '@app/constants/app';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface SaasOnboardingModalProps {
opened: boolean;
onClose: () => void;
}
export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
const { t } = useTranslation();
const flow = useSaasOnboardingState(props);
if (!flow) {
return null;
}
const {
currentStep,
totalSteps,
currentSlide,
slideDefinition,
flowState,
handleButtonAction,
} = flow;
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
</div>
</div>
);
}
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === 'rocket' && (
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
</div>
);
};
return (
<Modal
opened={props.opened}
onClose={props.onClose}
closeOnClickOutside={false}
centered
size="lg"
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: {
overflow: 'hidden',
border: 'none',
background: 'var(--bg-surface)',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
},
}}
>
<Stack gap={0} className={styles.modalContent} style={{ height: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={currentSlide.background.gradientStops}
circles={currentSlide.background.circles}
isActive
slideKey={currentSlide.key}
/>
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
{renderHero()}
</div>
</div>
<div
className={styles.modalBody}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch',
}}
>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{currentSlide.title}
</div>
<div className={styles.bodyText}>
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{currentSlide.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={totalSteps} activeStep={currentStep} />
<div className={styles.buttonContainer}>
{renderButtons({
slideDefinition,
flowState,
onAction: handleButtonAction,
t,
})}
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -0,0 +1,95 @@
import React from 'react';
import { Button, Group, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { TFunction } from 'i18next';
import { ButtonDefinition, type FlowState, type ButtonAction } from '@app/components/onboarding/saasOnboardingFlowConfig';
interface RenderButtonsProps {
slideDefinition: {
buttons: ButtonDefinition[];
id: string;
};
flowState: FlowState;
onAction: (action: ButtonAction) => void;
t: TFunction;
}
export function renderButtons({ slideDefinition, flowState, onAction, t }: RenderButtonsProps) {
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
const buttonStyles = (variant: ButtonDefinition['variant']) =>
variant === 'primary'
? {
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
}
: {
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
};
const resolveButtonLabel = (button: ButtonDefinition) => {
// Translate the label (it's a translation key)
const label = button.label ?? '';
if (!label) return '';
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
const fallback = label.split('.').pop() || label;
return t(label, fallback);
};
const renderButton = (button: ButtonDefinition) => {
const disabled = button.disabledWhen?.(flowState) ?? false;
if (button.type === 'icon') {
return (
<ActionIcon
key={button.key}
onClick={() => onAction(button.action)}
radius="md"
size={40}
disabled={disabled}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
{button.icon === 'chevron-left' && <ChevronLeftIcon fontSize="small" />}
</ActionIcon>
);
}
const variant = button.variant ?? 'secondary';
const label = resolveButtonLabel(button);
return (
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
{label}
</Button>
);
};
if (leftButtons.length === 0) {
return <Group justify="flex-end">{rightButtons.map(renderButton)}</Group>;
}
if (rightButtons.length === 0) {
return <Group justify="flex-start">{leftButtons.map(renderButton)}</Group>;
}
return (
<Group justify="space-between">
<Group gap={12}>{leftButtons.map(renderButton)}</Group>
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group>
);
}
@@ -0,0 +1,40 @@
import { TrialStatus } from '@app/auth/UseSession';
import { FLOW_SEQUENCES, SlideId } from '@app/components/onboarding/saasOnboardingFlowConfig';
export interface FlowConfig {
type: 'saas-trial' | 'saas-paid';
ids: SlideId[];
}
/**
* 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
*/
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,
};
}
@@ -0,0 +1,134 @@
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
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 HeroType = 'rocket' | 'dual-icon' | 'diamond';
export type ButtonAction =
| 'next'
| 'prev'
| 'close'
| 'download-selected';
export type FlowState = Record<string, never>;
export interface OSOption {
label: string;
url: string;
value: string;
}
export interface SlideFactoryParams {
osLabel: string;
osUrl: string;
osOptions?: OSOption[];
onDownloadUrlChange?: (url: string) => void;
trialStatus?: TrialStatus | null;
}
export interface HeroDefinition {
type: HeroType;
}
export interface ButtonDefinition {
key: string;
type: 'button' | 'icon';
label?: string;
icon?: 'chevron-left';
variant?: 'primary' | 'secondary' | 'default';
group: 'left' | 'right';
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
}
export interface SlideDefinition {
id: SlideId;
createSlide: (params: SlideFactoryParams) => SlideConfig;
hero: HeroDefinition;
buttons: ButtonDefinition[];
}
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'welcome': {
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',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'trial-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'next',
},
],
},
'desktop-install': {
id: 'desktop-install',
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: 'dual-icon' },
buttons: [
{
key: 'desktop-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'desktop-skip',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'close',
},
{
key: 'desktop-download',
type: 'button',
label: 'onboarding.buttons.download',
variant: 'primary',
group: 'right',
action: 'download-selected',
},
],
},
};
export const FLOW_SEQUENCES = {
saasTrialUser: ['welcome', 'free-trial', 'desktop-install'] as SlideId[],
saasPaidUser: ['welcome', 'desktop-install'] as SlideId[],
};
@@ -0,0 +1,81 @@
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,172 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useAuth } from '@app/auth/UseSession';
import { useOs } from '@app/hooks/useOs';
import {
SLIDE_DEFINITIONS,
type ButtonAction,
type FlowState,
type SlideId,
} from '@app/components/onboarding/saasOnboardingFlowConfig';
import { resolveSaasFlow } from '@app/components/onboarding/saasFlowResolver';
import { DOWNLOAD_URLS } from '@app/constants/downloads';
interface UseSaasOnboardingStateResult {
currentStep: number;
totalSteps: number;
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
flowState: FlowState;
handleButtonAction: (action: ButtonAction) => void;
}
interface UseSaasOnboardingStateProps {
opened: boolean;
onClose: () => void;
}
export function useSaasOnboardingState({
opened,
onClose,
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
const { trialStatus, isPro, loading } = useAuth();
const osType = useOs();
const selectedDownloadUrlRef = useRef<string>('');
const [currentStep, setCurrentStep] = useState<number>(0);
// Reset state when modal closes
useEffect(() => {
if (!opened) {
setCurrentStep(0);
}
}, [opened]);
// Determine OS details for desktop download
const os = useMemo(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
case 'mac-apple':
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
case 'mac-intel':
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
default:
return { label: '', url: '' };
}
}, [osType]);
const osOptions = useMemo(() => {
const options = [
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
];
return options.filter(opt => opt.url);
}, []);
// Store selected download URL
const handleDownloadUrlChange = useCallback((url: string) => {
selectedDownloadUrlRef.current = url;
}, []);
// Resolve flow based on trial status
const resolvedFlow = useMemo(
() => resolveSaasFlow(trialStatus, isPro),
[trialStatus, isPro]
);
const flowSlideIds = resolvedFlow.ids;
const totalSteps = flowSlideIds.length;
const maxIndex = Math.max(totalSteps - 1, 0);
// Ensure current step is within bounds
useEffect(() => {
if (currentStep >= flowSlideIds.length) {
setCurrentStep(Math.max(flowSlideIds.length - 1, 0));
}
}, [flowSlideIds.length, currentStep]);
const currentSlideId = flowSlideIds[currentStep] ?? flowSlideIds[flowSlideIds.length - 1];
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
// Create slide with appropriate params - must be called before any early returns
const currentSlide = useMemo(() => {
if (!slideDefinition) return null;
return slideDefinition.createSlide({
osLabel: os.label,
osUrl: os.url,
osOptions,
onDownloadUrlChange: handleDownloadUrlChange,
trialStatus: trialStatus ?? undefined,
});
}, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange, trialStatus]);
// Navigation functions
const goNext = useCallback(() => {
setCurrentStep((prev) => Math.min(prev + 1, maxIndex));
}, [maxIndex]);
const goPrev = useCallback(() => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
}, []);
// Handle button actions
const handleButtonAction = useCallback(
(action: ButtonAction) => {
switch (action) {
case 'next':
// If on last slide, close modal
if (currentStep === maxIndex) {
onClose();
} else {
goNext();
}
return;
case 'prev':
goPrev();
return;
case 'close':
onClose();
return;
case 'download-selected': {
// Open download URL in new tab
const downloadUrl = selectedDownloadUrlRef.current || os.url;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener,noreferrer');
}
// Then advance to next slide or close if last
if (currentStep === maxIndex) {
onClose();
} else {
goNext();
}
return;
}
default:
console.warn(`Unhandled button action: ${action}`);
return;
}
},
[currentStep, maxIndex, goNext, goPrev, onClose, os.url]
);
const flowState: FlowState = {};
// Early return after all hooks have been called
if (!slideDefinition || !currentSlide || loading) {
return null;
}
return {
currentStep,
totalSteps,
slideDefinition,
currentSlide,
flowState,
handleButtonAction,
};
}