mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-09-13 04:08:22 +02:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user