Feature/onboarding slides (#4955)

# Description of Changes

- Added onboarding slides/upgrade banner conditions for all the
following cases
  - 'licensed'
  - 'no-login-user-under-limit-no-license'
  - 'no-login-admin-under-limit-no-license'
  - 'no-login-user-over-limit-no-license'
  - 'no-login-admin-over-limit-no-license'
  - 'login-user-under-limit-no-license'
  - 'login-admin-under-limit-no-license'
  - 'login-user-over-limit-no-license'
  - 'login-admin-over-limit-no-license';


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
EthanHealy01
2025-11-25 13:45:02 +00:00
committed by GitHub
co-authored by Anthony Stirling Connor Yoh
parent 80f2980755
commit a8db2fda18
66 changed files with 5641 additions and 1248 deletions
@@ -0,0 +1,277 @@
.heroWrapper {
position: relative;
width: 100%;
height: 220px;
overflow: hidden;
}
.heroLogo {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: heroLogoEnter 0.25s ease forwards;
z-index: 20;
}
.heroLogoCircle {
width: 96px;
height: 96px;
border-radius: 50%;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18);
animation: heroLogoScale 0.25s ease forwards;
}
.heroLogoCircle img {
width: 60px;
height: 60px;
animation: heroLogoRotate 0.25s ease forwards;
transform-origin: center;
}
.standaloneIcon {
width: 96px;
height: 96px;
object-fit: contain;
animation: heroLogoScale 0.25s ease forwards;
}
.securitySlideContent {
display: flex;
justify-content: center;
margin-top: 12px;
}
.securityCard {
width: 100%;
max-width: 480px;
background: var(--bg-surface, #ffffff);
border-radius: 16px;
padding: 20px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
display: flex;
flex-direction: column;
gap: 16px;
}
.securityAlertRow {
display: flex;
align-items: flex-start;
gap: 10px;
font-weight: 500;
font-size: 15px;
line-height: 1.5;
color: var(--onboarding-body, #1f2937);
}
.heroIconsContainer {
display: flex;
gap: 32px;
align-items: flex-start;
justify-content: center;
animation: heroLogoEnter 0.25s ease forwards;
position: relative;
top: 1rem;
}
.iconWrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.iconButton {
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: transform 0.2s ease, opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative;
border-radius: 12px;
padding: 4px;
outline: none;
}
.iconButton:focus {
outline: none;
}
.iconButton:focus-visible {
outline: none;
}
.iconButton:hover {
transform: scale(1.05);
opacity: 0.9;
}
.iconButton:active {
transform: scale(0.95);
}
.iconButtonSelected {
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.4);
}
.downloadIcon {
width: 96px;
height: 96px;
object-fit: contain;
animation: heroLogoScale 0.25s ease forwards;
}
.iconLabel {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
text-align: center;
animation: heroLogoEnter 0.25s ease forwards;
}
.title {
text-align: center;
opacity: 0;
transform: translateX(24px);
animation: bodySlideIn 0.25s ease forwards;
}
.bodyCopy {
opacity: 0;
transform: translateX(24px);
animation: bodySlideIn 0.25s ease forwards;
}
@keyframes heroLogoEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes heroLogoScale {
from {
transform: scale(0.6);
}
to {
transform: scale(1);
}
}
@keyframes heroLogoRotate {
from {
transform: rotate(-90deg) scale(0.9);
}
to {
transform: rotate(0deg) scale(1);
}
}
@keyframes bodySlideIn {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Dev overlay styles */
.devOverlay {
position: absolute;
bottom: 8px;
left: 10px;
display: flex;
gap: 6px;
z-index: 10;
}
.devButton {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
opacity: 0.8;
border: 1px solid rgba(255, 255, 255, 0.4);
background: rgba(0, 0, 0, 0.35);
color: #fff;
box-shadow: none;
}
.devButtonActive {
opacity: 1;
border: 1px solid rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.9);
color: #1F2933;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.7);
}
/* Modal content styles */
.modalContent {
background: var(--bg-surface);
position: relative;
}
.modalBody {
padding: 24px;
}
/* Title styles */
.titleText {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-weight: 600;
font-size: 22px;
color: var(--onboarding-title);
}
/* Body text styles */
.bodyText {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-size: 16px;
color: var(--onboarding-body);
line-height: 1.5;
}
.bodyCopyInner {
color: inherit;
}
/* Button margin */
.buttonContainer {
margin-top: 8px;
}
/* Welcome slide V2 badge */
.welcomeTitleContainer {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.v2Badge {
background: #DBEFFF;
color: #2A4BFF;
padding: 4px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
}
/* Icon styles */
.heroIcon {
color: #000000;
}
@@ -0,0 +1,33 @@
import { FLOW_SEQUENCES, type SlideId } from '@app/components/onboarding/onboardingFlowConfig';
export type FlowType = 'login-admin' | 'login-user' | 'no-login' | 'no-login-admin';
export interface FlowConfig {
type: FlowType;
ids: SlideId[];
}
export function resolveFlow(enableLogin: boolean, isAdmin: boolean, selfReportedAdmin: boolean): FlowConfig {
if (!enableLogin) {
return selfReportedAdmin
? {
type: 'no-login-admin',
ids: [...FLOW_SEQUENCES.noLoginBase, ...FLOW_SEQUENCES.noLoginAdmin],
}
: {
type: 'no-login',
ids: FLOW_SEQUENCES.noLoginBase,
};
}
return isAdmin
? {
type: 'login-admin',
ids: FLOW_SEQUENCES.loginAdmin,
}
: {
type: 'login-user',
ids: FLOW_SEQUENCES.loginUser,
};
}
@@ -0,0 +1,119 @@
import React from 'react';
import { Modal, Stack } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
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/InitialOnboardingModal/renderButtons';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import type { InitialOnboardingModalProps } from '@app/components/onboarding/InitialOnboardingModal/types';
import { useInitialOnboardingState } from '@app/components/onboarding/InitialOnboardingModal/useInitialOnboardingState';
import { BASE_PATH } from '@app/constants/app';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
const flow = useInitialOnboardingState(props);
if (!flow) {
return null;
}
const {
state,
totalSteps,
currentSlide,
slideDefinition,
licenseNotice,
flowState,
closeAndMarkSeen,
handleButtonAction,
} = flow;
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img src={`${BASE_PATH}/branding/StirlingLogoLegacy.svg`} 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 === 'shield' && (
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
{slideDefinition.hero.type === 'logo' && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
)}
</div>
);
};
return (
<Modal
opened={props.opened}
onClose={closeAndMarkSeen}
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)' },
}}
>
<Stack gap={0} className={styles.modalContent}>
<div className={styles.heroWrapper}>
<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}>
<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={state.step} />
<div className={styles.buttonContainer}>
{renderButtons({
slideDefinition,
licenseNotice,
flowState,
onAction: handleButtonAction,
})}
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -0,0 +1,109 @@
import React from 'react';
import { Button, Group, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { useTranslation } from 'react-i18next';
import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig';
import type { LicenseNotice } from '@app/types/types';
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
interface RenderButtonsProps {
slideDefinition: {
buttons: ButtonDefinition[];
id: string;
};
licenseNotice: LicenseNotice;
flowState: FlowState;
onAction: (action: ButtonAction) => void;
}
export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) {
const { t } = useTranslation();
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) => {
// Special case: override "See Plans" with "Upgrade now" when over limit
if (
button.type === 'button' &&
slideDefinition.id === 'server-license' &&
button.action === 'see-plans' &&
licenseNotice.isOverLimit
) {
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
}
// 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,21 @@
import type { LicenseNotice } from '@app/types/types';
export interface InitialOnboardingModalProps {
opened: boolean;
onClose: () => void;
onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void;
}
export interface OnboardingState {
step: number;
selectedRole: 'admin' | 'user' | null;
selfReportedAdmin: boolean;
}
export const DEFAULT_STATE: OnboardingState = {
step: 0,
selectedRole: null,
selfReportedAdmin: false,
};
@@ -0,0 +1,381 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useOs } from '@app/hooks/useOs';
import { useNavigate } from 'react-router-dom';
import {
SLIDE_DEFINITIONS,
type ButtonAction,
type FlowState,
type SlideId,
} from '@app/components/onboarding/onboardingFlowConfig';
import type { LicenseNotice } from '@app/types/types';
import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver';
import { useServerExperience } from '@app/hooks/useServerExperience';
import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types';
import { DOWNLOAD_URLS } from '@app/constants/downloads';
interface UseInitialOnboardingStateResult {
state: OnboardingState;
totalSteps: number;
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
licenseNotice: LicenseNotice;
flowState: FlowState;
closeAndMarkSeen: () => void;
handleButtonAction: (action: ButtonAction) => void;
}
export function useInitialOnboardingState({
opened,
onClose,
onRequestServerLicense,
onLicenseNoticeUpdate,
}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
const { preferences, updatePreference } = usePreferences();
const { startTour } = useOnboarding();
const {
loginEnabled: loginEnabledFromServer,
configIsAdmin,
totalUsers: serverTotalUsers,
userCountResolved: serverUserCountResolved,
freeTierLimit,
hasPaidLicense,
scenarioKey,
setSelfReportedAdmin,
isNewServer,
} = useServerExperience();
const osType = useOs();
const navigate = useNavigate();
const selectedDownloadUrlRef = useRef<string>('');
const [state, setState] = useState<OnboardingState>(DEFAULT_STATE);
const resetState = useCallback(() => {
setState(DEFAULT_STATE);
}, []);
useEffect(() => {
if (!opened) {
resetState();
}
}, [opened, resetState]);
const handleRoleSelect = useCallback(
(role: 'admin' | 'user' | null) => {
const isAdminSelection = role === 'admin';
setState((prev) => ({
...prev,
selectedRole: role,
selfReportedAdmin: isAdminSelection,
}));
if (typeof window !== 'undefined') {
if (isAdminSelection) {
window.localStorage.setItem('stirling-self-reported-admin', 'true');
} else {
window.localStorage.removeItem('stirling-self-reported-admin');
}
}
setSelfReportedAdmin(isAdminSelection);
},
[setSelfReportedAdmin],
);
const closeAndMarkSeen = useCallback(() => {
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
onClose();
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
const isAdmin = configIsAdmin;
const enableLogin = loginEnabledFromServer;
const effectiveEnableLogin = enableLogin;
const effectiveIsAdmin = isAdmin;
const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin;
useEffect(() => {
if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) {
handleRoleSelect('admin');
}
}, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]);
const shouldUseServerCount =
(effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin;
const licenseUserCountFromServer =
shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null;
const effectiveLicenseUserCount = licenseUserCountFromServer ?? null;
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);
}, []);
const resolvedFlow = useMemo(
() => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin),
[effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin],
);
const shouldSkipSecurityCheck = shouldAssumeAdminForNewServer;
const flowSlideIds = useMemo(
() =>
shouldSkipSecurityCheck
? resolvedFlow.ids.filter((id) => id !== 'security-check')
: resolvedFlow.ids,
[resolvedFlow.ids, shouldSkipSecurityCheck],
);
const flowType = resolvedFlow.type;
const totalSteps = flowSlideIds.length;
const maxIndex = Math.max(totalSteps - 1, 0);
useEffect(() => {
if (state.step >= flowSlideIds.length) {
setState((prev) => ({
...prev,
step: Math.max(flowSlideIds.length - 1, 0),
}));
}
}, [flowSlideIds.length, state.step]);
const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1];
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
if (!slideDefinition) {
return null;
}
const scenarioProvidesInfo =
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
const scenarioIndicatesAdmin = scenarioProvidesInfo
? scenarioKey!.includes('admin')
: state.selfReportedAdmin || effectiveIsAdmin;
const scenarioIndicatesOverLimit = scenarioProvidesInfo
? scenarioKey!.includes('over-limit')
: effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit;
const scenarioRequiresLicense =
scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true;
const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense;
const licenseNotice = useMemo<LicenseNotice>(
() => ({
totalUsers: effectiveLicenseUserCount,
freeTierLimit,
isOverLimit: scenarioIndicatesOverLimit,
requiresLicense: shouldShowServerLicenseInfo,
}),
[
effectiveLicenseUserCount,
freeTierLimit,
scenarioIndicatesOverLimit,
shouldShowServerLicenseInfo,
],
);
const requestServerLicenseIfNeeded = useCallback(
(options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => {
if (!shouldShowServerLicenseInfo) {
return;
}
onRequestServerLicense?.(options);
},
[onRequestServerLicense, shouldShowServerLicenseInfo],
);
useEffect(() => {
onLicenseNoticeUpdate?.(licenseNotice);
}, [licenseNotice, onLicenseNoticeUpdate]);
// Initialize ref with default URL
useEffect(() => {
if (!selectedDownloadUrlRef.current && os.url) {
selectedDownloadUrlRef.current = os.url;
}
}, [os.url]);
const handleDownloadUrlChange = useCallback((url: string) => {
selectedDownloadUrlRef.current = url;
}, []);
const currentSlide = slideDefinition.createSlide({
osLabel: os.label,
osUrl: os.url,
osOptions,
onDownloadUrlChange: handleDownloadUrlChange,
selectedRole: state.selectedRole,
onRoleSelect: handleRoleSelect,
licenseNotice,
loginEnabled: effectiveEnableLogin,
});
const goNext = useCallback(() => {
setState((prev) => ({
...prev,
step: Math.min(prev.step + 1, maxIndex),
}));
}, [maxIndex]);
const goPrev = useCallback(() => {
setState((prev) => ({
...prev,
step: Math.max(prev.step - 1, 0),
}));
}, []);
const launchTour = useCallback(
(mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => {
if (options?.closeOnboardingSlides) {
closeAndMarkSeen();
}
startTour(mode, {
source: 'initial-onboarding-modal',
metadata: {
hasCompletedOnboarding: preferences.hasCompletedOnboarding,
toolPanelModePromptSeen: preferences.toolPanelModePromptSeen,
selfReportedAdmin: state.selfReportedAdmin,
},
});
},
[closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin],
);
const handleButtonAction = useCallback(
(action: ButtonAction) => {
const currentSlideIdLocal = currentSlideId;
const shouldAutoLaunchLoginUserTour =
flowType === 'login-user' && currentSlideIdLocal === 'desktop-install';
switch (action) {
case 'next':
if (shouldAutoLaunchLoginUserTour) {
launchTour('tools', { closeOnboardingSlides: true });
return;
}
goNext();
return;
case 'prev':
goPrev();
return;
case 'close':
closeAndMarkSeen();
return;
case 'download-selected': {
const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener');
}
if (shouldAutoLaunchLoginUserTour) {
launchTour('tools', { closeOnboardingSlides: true });
return;
}
goNext();
return;
}
case 'complete-close':
updatePreference('hasCompletedOnboarding', true);
closeAndMarkSeen();
return;
case 'security-next':
if (!state.selectedRole) {
return;
}
if (state.selectedRole === 'admin') {
goNext();
} else {
launchTour('tools', { closeOnboardingSlides: true });
}
return;
case 'launch-admin':
requestServerLicenseIfNeeded({
deferUntilTourComplete: true,
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
});
launchTour('admin', { closeOnboardingSlides: true });
return;
case 'launch-tools':
launchTour('tools', { closeOnboardingSlides: true });
return;
case 'launch-auto': {
const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools';
if (launchMode === 'admin') {
requestServerLicenseIfNeeded({
deferUntilTourComplete: true,
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
});
}
launchTour(launchMode, { closeOnboardingSlides: true });
return;
}
case 'skip-to-license':
updatePreference('hasCompletedOnboarding', true);
requestServerLicenseIfNeeded({
deferUntilTourComplete: false,
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
});
closeAndMarkSeen();
return;
case 'see-plans':
closeAndMarkSeen();
navigate('/settings/adminPlan');
return;
default:
return;
}
},
[
closeAndMarkSeen,
currentSlide,
effectiveIsAdmin,
flowType,
goNext,
goPrev,
launchTour,
navigate,
requestServerLicenseIfNeeded,
onRequestServerLicense,
os.url,
state.selectedRole,
state.selfReportedAdmin,
updatePreference,
],
);
const flowState: FlowState = { selectedRole: state.selectedRole };
return {
state,
totalSteps,
slideDefinition,
currentSlide,
licenseNotice,
flowState,
closeAndMarkSeen,
handleButtonAction,
};
}