added the different paths and cleaned up some of the files, broke some of the larger ones into different smaller files, hence the scary 38 files changed

This commit is contained in:
EthanHealy01
2025-11-18 22:04:52 +00:00
parent 9df9401e6a
commit a128b62ad9
38 changed files with 2591 additions and 958 deletions

View File

@@ -17,6 +17,7 @@ import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContex
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import { CookieConsentProvider } from "@app/contexts/CookieConsentContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
@@ -57,35 +58,37 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
<CookieConsentProvider>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</CookieConsentProvider>
</AppConfigProvider>
</OnboardingProvider>
</BannerProvider>

View File

@@ -1,84 +0,0 @@
.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;
}
.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: 52px;
height: 52px;
animation: heroLogoRotate 0.25s ease forwards;
transform-origin: center;
}
.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);
}
}

View File

@@ -1,293 +0,0 @@
import React from 'react';
import { Modal, Button, Group, Stack, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
import { useOs } from '@app/hooks/useOs';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import { SlideConfig } from '@app/components/onboarding/slides/types';
import styles from './InitialOnboardingModal.module.css';
interface InitialOnboardingModalProps {
opened: boolean;
onClose: () => void;
}
export default function InitialOnboardingModal({ opened, onClose }: InitialOnboardingModalProps) {
const { preferences, updatePreference } = usePreferences();
const { startTour, setShowWelcomeModal, setStartAfterToolModeSelection } = useOnboarding();
const [step, setStep] = React.useState(0);
const totalSteps = 3;
const { config } = useAppConfig();
const isAdmin = !!config?.isAdmin;
React.useEffect(() => {
if (!opened) setStep(0);
}, [opened]);
const osType = useOs();
const os = React.useMemo(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: 'https://files.stirlingpdf.com/win-installer.exe' };
case 'mac-apple':
return { label: 'Mac', url: 'https://files.stirlingpdf.com/mac-installer.dmg' };
case 'mac-intel':
return { label: 'Mac (Intel)', url: 'https://files.stirlingpdf.com/mac-x86_64-installer.dmg' };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: 'https://docs.stirlingpdf.com/Installation/Unix%20Installation/' };
// For mobile/unknown, hide OS label and skip opening a URL
default:
return { label: '', url: '' };
}
}, [osType]);
const closeAndMarkSeen = React.useCallback(() => {
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
onClose();
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
const goNext = () => setStep((s) => Math.min(totalSteps - 1, s + 1));
const goPrev = () => setStep((s) => Math.max(0, s - 1));
// Get slide content from the slide components
const slides = React.useMemo<SlideConfig[]>(
() => [
WelcomeSlide(),
DesktopInstallSlide({ osLabel: os.label, osUrl: os.url }),
PlanOverviewSlide({ isAdmin }),
],
[isAdmin, os.label, os.url],
);
const currentSlide = slides[step];
// Buttons per step
const renderButtons = () => {
if (step === 0) {
return (
<Group justify="flex-end">
<Button
onClick={goNext}
styles={{
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
}}
>
Next
</Button>
</Group>
);
}
if (step === 1) {
return (
<Group justify="space-between">
<Group gap={12}>
<ActionIcon
onClick={goPrev}
radius="md"
size={40}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
<ChevronLeftIcon fontSize="small" />
</ActionIcon>
<Button
variant="default"
onClick={goNext}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
Skip for now
</Button>
</Group>
<Button
onClick={() => {
const downloadUrl = currentSlide.downloadUrl;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener');
}
goNext();
}}
styles={{
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
}}
>
Download
</Button>
</Group>
);
}
return (
<Group justify="space-between">
<Group gap={12}>
<ActionIcon
onClick={goPrev}
radius="md"
size={40}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
<ChevronLeftIcon fontSize="small" />
</ActionIcon>
<Button
variant="default"
onClick={() => {
updatePreference('hasCompletedOnboarding', true);
closeAndMarkSeen();
}}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
Skip the tour
</Button>
</Group>
<Button
onClick={() => {
// Close slides first
closeAndMarkSeen();
// Ensure the legacy welcome modal state is controlled by our gating
setShowWelcomeModal(false);
// If the user still needs to choose a tool mode, mark to start the tour after they do
if (!preferences.toolPanelModePromptSeen) {
setStartAfterToolModeSelection(true);
return; // The prompt will show next; tour will be started from there
}
// Otherwise, start immediately (if not completed previously)
if (!preferences.hasCompletedOnboarding) {
if (isAdmin) {
startTour('admin');
} else {
startTour('tools');
}
}
}}
styles={{
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
}}
>
Show me around
</Button>
</Group>
);
};
return (
<Modal
opened={opened}
onClose={closeAndMarkSeen}
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} style={{ background: 'var(--bg-surface)' }}>
<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}`}>
<div className={styles.heroLogoCircle}>
<img src="/branding/StirlingPDFLogoNoTextLight.svg" alt="Stirling logo" />
</div>
</div>
</div>
<div style={{ padding: 24 }}>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
className={styles.title}
style={{
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
fontWeight: 600,
fontSize: 22,
color: 'var(--onboarding-title)',
}}
>
{currentSlide.title}
</div>
<div
style={{
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
fontSize: 16,
color: 'var(--onboarding-body)',
lineHeight: 1.5,
}}
>
{/* strong tags should match the title color */}
<div
key={`body-${currentSlide.key}`}
className={styles.bodyCopy}
style={{ color: 'inherit' }}
>
{currentSlide.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
<div style={{ marginTop: 8 }}>
{renderButtons()}
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,276 @@
.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: 380px;
background: var(--bg-surface, #ffffff);
border-radius: 16px;
padding: 16px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
display: flex;
flex-direction: column;
gap: 12px;
}
.securityAlertRow {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
font-size: 14px;
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;
}

View File

@@ -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,
};
}

View File

@@ -0,0 +1,163 @@
import React from 'react';
import { Modal, Stack } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
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 './renderButtons';
import styles from './InitialOnboardingModal.module.css';
import type { InitialOnboardingModalProps } from './types';
import { useInitialOnboardingState } from './useInitialOnboardingState';
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
const flow = useInitialOnboardingState(props);
if (!flow) {
return null;
}
const {
state,
totalSteps,
currentSlide,
slideDefinition,
licenseNotice,
flowState,
closeAndMarkSeen,
handleButtonAction,
handleDownloadIconSelect,
devButtons,
activeDevScenario,
handleDevScenarioClick,
} = flow;
const showDevButtons = devButtons.length > 0;
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<button
className={`${styles.iconButton} ${state.selectedDownloadIcon === 'new' ? styles.iconButtonSelected : ''}`}
onClick={() => handleDownloadIconSelect('new')}
aria-label="Select new icon version"
>
<img src="/branding/StirlingLogo.svg" alt="Stirling new icon" className={styles.downloadIcon} />
</button>
{state.selectedDownloadIcon === 'new' && <div className={styles.iconLabel}>Modern Icon</div>}
</div>
<div className={styles.iconWrapper}>
<button
className={`${styles.iconButton} ${state.selectedDownloadIcon === 'classic' ? styles.iconButtonSelected : ''}`}
onClick={() => handleDownloadIconSelect('classic')}
aria-label="Select classic icon version"
>
<img src="/branding/StirlingLogoLegacy.svg" alt="Stirling classic icon" className={styles.downloadIcon} />
</button>
{state.selectedDownloadIcon === 'classic' && <div className={styles.iconLabel}>Classic Icon</div>}
</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="/branding/StirlingPDFLogoNoTextLightHC.svg" alt="Stirling logo" />
)}
</div>
);
};
const renderDevOverlay = () => {
if (!showDevButtons) {
return null;
}
return (
<div className={styles.devOverlay}>
{devButtons.map((btn) => (
<button
key={btn.label}
type="button"
onClick={() => handleDevScenarioClick(btn)}
title={btn.overLimit ? 'Set simulated users to 57' : 'Set simulated users to 3'}
className={`${styles.devButton} ${activeDevScenario === btn.label ? styles.devButtonActive : ''}`}
>
{btn.label}
</button>
))}
</div>
);
};
return (
<Modal
opened={props.opened}
onClose={closeAndMarkSeen}
centered
size="lg"
radius="lg"
withCloseButton={false}
zIndex={1001}
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>
{renderDevOverlay()}
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { Button, Group, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig';
import type { LicenseNotice } from '@app/components/onboarding/slides/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 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) => {
if (
button.type === 'button' &&
slideDefinition.id === 'server-license' &&
button.action === 'see-plans' &&
licenseNotice.isOverLimit
) {
return 'Upgrade now →';
}
return button.label ?? '';
};
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>
);
}

View File

@@ -0,0 +1,23 @@
import type { LicenseNotice } from '@app/components/onboarding/slides/types';
export interface InitialOnboardingModalProps {
opened: boolean;
onClose: () => void;
onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void;
}
export interface OnboardingState {
step: number;
selectedDownloadIcon: 'new' | 'classic';
selectedRole: 'admin' | 'user' | null;
selfReportedAdmin: boolean;
}
export const DEFAULT_STATE: OnboardingState = {
step: 0,
selectedDownloadIcon: 'new',
selectedRole: null,
selfReportedAdmin: false,
};

View File

@@ -0,0 +1,93 @@
import { useCallback, useMemo, useState } from 'react';
export type DevFlow = 'no-login' | 'login-admin' | 'login-user';
export interface DevScenarioButton {
label: string;
flow: DevFlow;
overLimit: boolean;
}
export interface DevOverrides {
enableLogin?: boolean;
isAdmin?: boolean;
licenseUserCount?: number | null;
}
interface UseDevScenariosOptions {
opened: boolean;
isDevMode: boolean;
onApplyScenario: (payload: {
selectedRole: 'admin' | 'user' | null;
selfReportedAdmin: boolean;
}) => void;
}
export function useDevScenarios({ opened, isDevMode, onApplyScenario }: UseDevScenariosOptions) {
const [devOverrides, setDevOverrides] = useState<DevOverrides | null>(null);
const [activeDevScenario, setActiveDevScenario] = useState<string | null>(null);
const devButtons: DevScenarioButton[] = useMemo(() => {
if (!isDevMode || !opened) {
return [];
}
return [
{ label: 'no-login', flow: 'no-login', overLimit: false },
{ label: 'admin-login', flow: 'login-admin', overLimit: false },
{ label: 'admin 57', flow: 'login-admin', overLimit: true },
{ label: 'user-login', flow: 'login-user', overLimit: false },
];
}, [isDevMode, opened]);
const handleDevScenarioClick = useCallback(
(scenario: DevScenarioButton) => {
if (!isDevMode) {
return;
}
const { flow, overLimit, label } = scenario;
const overrides: DevOverrides = {};
let newSelectedRole: 'admin' | 'user' | null = null;
let newSelfReportedAdmin = false;
switch (flow) {
case 'no-login':
overrides.enableLogin = false;
overrides.isAdmin = false;
newSelfReportedAdmin = true;
newSelectedRole = 'admin';
break;
case 'login-admin':
overrides.enableLogin = true;
overrides.isAdmin = true;
newSelectedRole = 'admin';
break;
case 'login-user':
default:
overrides.enableLogin = true;
overrides.isAdmin = false;
newSelectedRole = 'user';
break;
}
overrides.licenseUserCount = overLimit ? 57 : 3;
setDevOverrides(overrides);
setActiveDevScenario(label);
onApplyScenario({
selectedRole: newSelectedRole,
selfReportedAdmin: newSelfReportedAdmin,
});
},
[isDevMode, onApplyScenario],
);
return {
devButtons,
activeDevScenario,
handleDevScenarioClick,
devOverrides,
};
}

View File

@@ -0,0 +1,337 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOs } from '@app/hooks/useOs';
import {
SLIDE_DEFINITIONS,
type ButtonAction,
type FlowState,
type SlideId,
} from '@app/components/onboarding/onboardingFlowConfig';
import type { LicenseNotice } from '@app/components/onboarding/slides/types';
import { resolveFlow } from './flowResolver';
import { useLicenseInfo } from './useLicenseInfo';
import { useDevScenarios } from './useDevScenarios';
import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from './types';
const FREE_TIER_LIMIT = 5;
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;
handleDownloadIconSelect: (icon: 'new' | 'classic') => void;
devButtons: ReturnType<typeof useDevScenarios>['devButtons'];
activeDevScenario: ReturnType<typeof useDevScenarios>['activeDevScenario'];
handleDevScenarioClick: ReturnType<typeof useDevScenarios>['handleDevScenarioClick'];
}
export function useInitialOnboardingState({
opened,
onClose,
onRequestServerLicense,
onLicenseNoticeUpdate,
}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
const { preferences, updatePreference } = usePreferences();
const { startTour } = useOnboarding();
const { config } = useAppConfig();
const osType = useOs();
const isDevMode = import.meta.env.MODE === 'development';
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) => {
setState((prev) => ({
...prev,
selectedRole: role,
selfReportedAdmin: role === 'admin',
}));
if (role === 'admin') {
window?.localStorage?.setItem('stirling-self-reported-admin', 'true');
}
}, []);
const closeAndMarkSeen = useCallback(() => {
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
onClose();
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
const handleDevScenarioApply = useCallback(
({
selectedRole,
selfReportedAdmin,
}: {
selectedRole: 'admin' | 'user' | null;
selfReportedAdmin: boolean;
}) => {
setState({
...DEFAULT_STATE,
selectedRole,
selfReportedAdmin,
});
},
[],
);
const { devButtons, activeDevScenario, handleDevScenarioClick, devOverrides } = useDevScenarios({
opened,
isDevMode,
onApplyScenario: handleDevScenarioApply,
});
const isAdmin = !!config?.isAdmin;
const enableLogin = config?.enableLogin ?? true;
const effectiveEnableLogin = devOverrides?.enableLogin ?? enableLogin;
const effectiveIsAdmin = devOverrides?.isAdmin ?? isAdmin;
const licenseUserCountFromApi = useLicenseInfo({
opened,
shouldFetch: effectiveEnableLogin && effectiveIsAdmin && !devOverrides,
});
const effectiveLicenseUserCount =
devOverrides?.licenseUserCount ?? licenseUserCountFromApi ?? null;
const os = useMemo(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: 'https://files.stirlingpdf.com/win-installer.exe' };
case 'mac-apple':
return { label: 'Mac', url: 'https://files.stirlingpdf.com/mac-installer.dmg' };
case 'mac-intel':
return { label: 'Mac (Intel)', url: 'https://files.stirlingpdf.com/mac-x86_64-installer.dmg' };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: 'https://docs.stirlingpdf.com/Installation/Unix%20Installation/' };
default:
return { label: '', url: '' };
}
}, [osType]);
const { ids: flowSlideIds, type: flowType } = resolveFlow(
effectiveEnableLogin,
effectiveIsAdmin,
state.selfReportedAdmin,
);
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 licenseNotice = useMemo<LicenseNotice>(
() => ({
totalUsers: effectiveLicenseUserCount,
freeTierLimit: FREE_TIER_LIMIT,
isOverLimit:
effectiveLicenseUserCount != null && effectiveLicenseUserCount > FREE_TIER_LIMIT,
}),
[effectiveLicenseUserCount],
);
useEffect(() => {
onLicenseNoticeUpdate?.(licenseNotice);
}, [licenseNotice, onLicenseNoticeUpdate]);
const currentSlide = slideDefinition.createSlide({
osLabel: os.label,
osUrl: os.url,
selectedRole: state.selectedRole,
onRoleSelect: handleRoleSelect,
licenseNotice,
});
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 =
state.selectedDownloadIcon === 'new'
? os.url
: state.selectedDownloadIcon === 'classic'
? 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':
onRequestServerLicense?.({
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') {
onRequestServerLicense?.({
deferUntilTourComplete: true,
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
});
}
launchTour(launchMode, { closeOnboardingSlides: true });
return;
}
case 'skip-to-license':
updatePreference('hasCompletedOnboarding', true);
onRequestServerLicense?.({
deferUntilTourComplete: false,
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
});
closeAndMarkSeen();
return;
case 'see-plans':
closeAndMarkSeen();
return;
default:
return;
}
},
[
closeAndMarkSeen,
currentSlide,
effectiveIsAdmin,
flowType,
goNext,
goPrev,
launchTour,
onRequestServerLicense,
os.url,
state.selectedDownloadIcon,
state.selectedRole,
state.selfReportedAdmin,
updatePreference,
],
);
const handleDownloadIconSelect = useCallback((icon: 'new' | 'classic') => {
setState((prev) => ({
...prev,
selectedDownloadIcon: icon,
}));
}, []);
const flowState: FlowState = { selectedRole: state.selectedRole };
return {
state,
totalSteps,
slideDefinition,
currentSlide,
licenseNotice,
flowState,
closeAndMarkSeen,
handleButtonAction,
handleDownloadIconSelect,
devButtons,
activeDevScenario,
handleDevScenarioClick,
};
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import apiClient from '@app/services/apiClient';
interface UseLicenseInfoOptions {
opened: boolean;
shouldFetch: boolean;
}
export function useLicenseInfo({ opened, shouldFetch }: UseLicenseInfoOptions) {
const [licenseUserCount, setLicenseUserCount] = useState<number | null>(null);
useEffect(() => {
if (!opened) {
return;
}
if (!shouldFetch) {
setLicenseUserCount(null);
return;
}
let cancelled = false;
const fetchLicenseInfo = async () => {
try {
const response = await apiClient.get<{ totalUsers?: number }>(
'/api/v1/proprietary/ui-data/admin-settings',
{
suppressErrorToast: true,
} as any,
);
if (!cancelled) {
const totalUsers = response.data?.totalUsers;
setLicenseUserCount(typeof totalUsers === 'number' ? totalUsers : null);
}
} catch (error) {
console.error('[onboarding] failed to fetch license information', error);
if (!cancelled) {
setLicenseUserCount(null);
}
}
};
fetchLicenseInfo();
return () => {
cancelled = true;
};
}, [opened, shouldFetch]);
return licenseUserCount;
}

View File

@@ -1,117 +1,26 @@
import React from "react";
import { TourProvider, useTour, type StepType } from '@reactour/tour';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import React, { useEffect, useMemo } from "react";
import { TourProvider, type StepType } from '@reactour/tour';
import { useTranslation } from 'react-i18next';
import { CloseButton, ActionIcon } from '@mantine/core';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import CheckIcon from '@mui/icons-material/Check';
import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal';
import ServerLicenseModal from './ServerLicenseModal';
import '@app/components/onboarding/OnboardingTour.css';
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import CheckIcon from '@mui/icons-material/Check';
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useAuth } from '@app/auth/UseSession';
import '@app/components/onboarding/OnboardingTour.css';
// Enum case order defines order steps will appear
enum TourStep {
ALL_TOOLS,
SELECT_CROP_TOOL,
TOOL_INTERFACE,
FILES_BUTTON,
FILE_SOURCES,
WORKBENCH,
VIEW_SWITCHER,
VIEWER,
PAGE_EDITOR,
ACTIVE_FILES,
FILE_CHECKBOX,
SELECT_CONTROLS,
CROP_SETTINGS,
RUN_BUTTON,
RESULTS,
FILE_REPLACEMENT,
PIN_BUTTON,
WRAP_UP,
}
enum AdminTourStep {
WELCOME,
CONFIG_BUTTON,
SETTINGS_OVERVIEW,
TEAMS_AND_USERS,
SYSTEM_CUSTOMIZATION,
DATABASE_SECTION,
CONNECTIONS_SECTION,
ADMIN_TOOLS,
WRAP_UP,
}
function TourContent() {
const { isOpen } = useOnboarding();
const { setIsOpen, setCurrentStep } = useTour();
const previousIsOpenRef = React.useRef(isOpen);
// Sync tour open state with context and reset to step 0 when reopening
React.useEffect(() => {
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
previousIsOpenRef.current = isOpen;
if (wasClosedNowOpen) {
// Tour is being opened (Help button pressed), reset to first step
setCurrentStep(0);
}
setIsOpen(isOpen);
}, [isOpen, setIsOpen, setCurrentStep]);
return null;
}
import { useOnboardingFlow } from './hooks/useOnboardingFlow';
import { createUserStepsConfig } from './userStepsConfig';
import { createAdminStepsConfig } from './adminStepsConfig';
import { removeAllGlows } from './tourGlow';
import TourContent from './TourContent';
export default function OnboardingTour() {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig();
// useAuth is provided in proprietary/desktop builds; in environments without Auth, this will be no-op
let session: any = null;
try {
// eslint-disable-next-line react-hooks/rules-of-hooks
session = useAuth()?.session ?? null;
} catch {
// Auth provider not available in this build; treat as always authenticated
session = {} as any;
}
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding();
const flow = useOnboardingFlow();
const { openFilesModal, closeFilesModal } = useFilesModalContext();
// Helper to add glow to multiple elements
const addGlowToElements = (selectors: string[]) => {
selectors.forEach(selector => {
const element = document.querySelector(selector);
if (element) {
if (selector === '[data-tour="settings-content-area"]') {
element.classList.add('tour-content-glow');
} else {
element.classList.add('tour-nav-glow');
}
}
});
};
// Helper to remove all glows
const removeAllGlows = () => {
document.querySelectorAll('.tour-content-glow').forEach(el => el.classList.remove('tour-content-glow'));
document.querySelectorAll('.tour-nav-glow').forEach(el => el.classList.remove('tour-nav-glow'));
};
// Cleanup glows when tour closes
React.useEffect(() => {
if (!isOpen) {
removeAllGlows();
}
return () => removeAllGlows();
}, [isOpen]);
const {
saveWorkbenchState,
restoreWorkbenchState,
@@ -134,258 +43,76 @@ export default function OnboardingTour() {
scrollNavToSection,
} = useAdminTourOrchestration();
// Define steps as object keyed by enum - TypeScript ensures all keys are present
const stepsConfig: Record<TourStep, StepType> = {
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
position: 'center',
padding: 0,
action: () => {
saveWorkbenchState();
closeFilesModal();
backToAllTools();
},
},
[TourStep.SELECT_CROP_TOOL]: {
selector: '[data-tour="tool-button-crop"]',
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
position: 'right',
padding: 0,
actionAfter: () => selectCropTool(),
},
[TourStep.TOOL_INTERFACE]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
position: 'center',
padding: 0,
},
[TourStep.FILES_BUTTON]: {
selector: '[data-tour="files-button"]',
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
position: 'right',
padding: 10,
action: () => openFilesModal(),
},
[TourStep.FILE_SOURCES]: {
selector: '[data-tour="file-sources"]',
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
position: 'right',
padding: 0,
actionAfter: () => {
loadSampleFile();
closeFilesModal();
}
},
[TourStep.WORKBENCH]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
position: 'center',
padding: 0,
},
[TourStep.VIEW_SWITCHER]: {
selector: '[data-tour="view-switcher"]',
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
position: 'bottom',
padding: 0,
},
[TourStep.VIEWER]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
position: 'center',
padding: 0,
action: () => switchToViewer(),
},
[TourStep.PAGE_EDITOR]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
position: 'center',
padding: 0,
action: () => switchToPageEditor(),
},
[TourStep.ACTIVE_FILES]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
position: 'center',
padding: 0,
action: () => switchToActiveFiles(),
},
[TourStep.FILE_CHECKBOX]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
position: 'top',
padding: 10,
},
[TourStep.SELECT_CONTROLS]: {
selector: '[data-tour="right-rail-controls"]',
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
position: 'left',
padding: 5,
action: () => selectFirstFile(),
},
[TourStep.CROP_SETTINGS]: {
selector: '[data-tour="crop-settings"]',
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
position: 'left',
padding: 10,
action: () => modifyCropSettings(),
},
[TourStep.RUN_BUTTON]: {
selector: '[data-tour="run-button"]',
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
position: 'top',
padding: 10,
actionAfter: () => executeTool(),
},
[TourStep.RESULTS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
position: 'center',
padding: 0,
},
[TourStep.FILE_REPLACEMENT]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
position: 'left',
padding: 10,
},
[TourStep.PIN_BUTTON]: {
selector: '[data-tour="file-card-pin"]',
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
position: 'left',
padding: 10,
action: () => pinFile(),
},
[TourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
position: 'right',
padding: 10,
},
};
useEffect(() => {
if (!flow.isTourOpen) {
removeAllGlows();
}
return () => removeAllGlows();
}, [flow.isTourOpen]);
// Define admin tour steps
const adminStepsConfig: Record<AdminTourStep, StepType> = {
[AdminTourStep.WELCOME]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
position: 'right',
padding: 10,
action: () => {
saveAdminState();
},
},
[AdminTourStep.CONFIG_BUTTON]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
position: 'right',
padding: 10,
actionAfter: () => {
openConfigModal();
},
},
[AdminTourStep.SETTINGS_OVERVIEW]: {
selector: '.modal-nav',
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
position: 'right',
padding: 0,
action: () => {
removeAllGlows();
},
},
[AdminTourStep.TEAMS_AND_USERS]: {
selector: '[data-tour="admin-people-nav"]',
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('people');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
selector: '[data-tour="admin-adminGeneral-nav"]',
highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminGeneral');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.DATABASE_SECTION]: {
selector: '[data-tour="admin-adminDatabase-nav"]',
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminDatabase');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.CONNECTIONS_SECTION]: {
selector: '[data-tour="admin-adminConnections-nav"]',
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminConnections');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
actionAfter: async () => {
// Scroll for the NEXT step before it shows
await scrollNavToSection('adminAudit');
},
},
[AdminTourStep.ADMIN_TOOLS]: {
selector: '[data-tour="admin-adminAudit-nav"]',
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
position: 'right',
padding: 10,
action: () => {
// Just navigate, scroll already happened in previous step
removeAllGlows();
navigateToSection('adminAudit');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
},
},
};
const userStepsConfig = useMemo(
() =>
createUserStepsConfig({
t,
actions: {
saveWorkbenchState,
closeFilesModal,
backToAllTools,
selectCropTool,
loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles,
selectFirstFile,
pinFile,
modifyCropSettings,
executeTool,
openFilesModal,
},
}),
[
t,
backToAllTools,
closeFilesModal,
executeTool,
loadSampleFile,
modifyCropSettings,
openFilesModal,
pinFile,
saveWorkbenchState,
selectCropTool,
selectFirstFile,
switchToActiveFiles,
switchToPageEditor,
switchToViewer,
],
);
// Select steps based on tour type
const steps = tourType === 'admin'
? Object.values(adminStepsConfig)
: Object.values(stepsConfig);
const adminStepsConfig = useMemo(
() =>
createAdminStepsConfig({
t,
actions: {
saveAdminState,
openConfigModal,
navigateToSection,
scrollNavToSection,
},
}),
[navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
);
const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: {
const steps = useMemo<StepType[]>(() => {
const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
return Object.values(config);
}, [adminStepsConfig, flow.tourType, userStepsConfig]);
const advanceTour = ({
setCurrentStep,
currentStep,
steps,
setIsOpen,
}: {
setCurrentStep: (value: number | ((prev: number) => number)) => void;
currentStep: number;
steps?: StepType[];
@@ -393,12 +120,12 @@ export default function OnboardingTour() {
}) => {
if (steps && currentStep === steps.length - 1) {
setIsOpen(false);
if (tourType === 'admin') {
if (flow.tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
completeTour();
flow.handleTourCompletion();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
@@ -406,46 +133,22 @@ export default function OnboardingTour() {
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
setIsOpen(false);
if (tourType === 'admin') {
if (flow.tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
completeTour();
flow.handleTourCompletion();
};
const loginEnabled = !!config?.enableLogin;
const isAuthenticated = !!session;
const shouldShowIntro = !preferences.hasSeenIntroOnboarding && (!loginEnabled || isAuthenticated);
return (
<>
<InitialOnboardingModal
opened={shouldShowIntro}
onClose={() => {
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
}}
/>
<TourWelcomeModal
opened={showWelcomeModal}
onStartTour={() => {
setShowWelcomeModal(false);
startTour();
}}
onMaybeLater={() => {
setShowWelcomeModal(false);
}}
onDontShowAgain={() => {
setShowWelcomeModal(false);
completeTour();
}}
/>
<InitialOnboardingModal {...flow.initialModalProps} />
<ToolPanelModePrompt onComplete={flow.handleToolPromptComplete} />
<TourProvider
key={tourType}
key={flow.tourType}
steps={steps}
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
maskClassName={flow.maskClassName}
onClickClose={handleCloseTour}
onClickMask={advanceTour}
onClickHighlighted={(e, clickProps) => {
@@ -453,13 +156,10 @@ export default function OnboardingTour() {
advanceTour(clickProps);
}}
keyboardHandler={(e, clickProps, status) => {
// Handle right arrow key to advance tour
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
e.preventDefault();
advanceTour(clickProps);
}
// Handle escape key to close tour
else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
e.preventDefault();
handleCloseTour(clickProps);
}
@@ -512,22 +212,16 @@ export default function OnboardingTour() {
}}
components={{
Close: ({ onClick }) => (
<CloseButton
onClick={onClick}
size="md"
style={{ position: 'absolute', top: '8px', right: '8px' }}
/>
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
),
Content: ({ content } : {content: string}) => (
<div
style={{ paddingRight: '16px' /* Ensure text doesn't overlap with close button */ }}
dangerouslySetInnerHTML={{ __html: content }}
/>
Content: ({ content }: { content: string }) => (
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
),
}}
>
<TourContent />
</TourProvider>
<ServerLicenseModal {...flow.serverLicenseModalProps} />
</>
);
}

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { Modal, Button, Group, Stack } from '@mantine/core';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import { LicenseNotice } from '@app/components/onboarding/slides/types';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
interface ServerLicenseModalProps {
opened: boolean;
onClose: () => void;
onSeePlans?: () => void;
licenseNotice: LicenseNotice;
}
export default function ServerLicenseModal({
opened,
onClose,
onSeePlans,
licenseNotice,
}: ServerLicenseModalProps) {
const slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]);
const primaryLabel = licenseNotice.isOverLimit ? 'Upgrade now →' : 'See Plans →';
const handleSeePlans = () => {
onSeePlans?.();
onClose();
};
const secondaryStyles = {
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
};
const primaryStyles = {
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
};
return (
<Modal
opened={opened}
onClose={onClose}
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}>
<div className={styles.heroWrapper}>
<AnimatedSlideBackground
gradientStops={slide.background.gradientStops}
circles={slide.background.circles}
isActive
slideKey={slide.key}
/>
<div className={styles.heroLogo}>
<div className={styles.heroLogoCircle}>
<img src="/branding/StirlingPDFLogoNoTextLightHC.svg" alt="Stirling logo" />
</div>
</div>
</div>
<div style={{ padding: 24 }}>
<Stack gap={16}>
<div
className={styles.title}
style={{
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
fontWeight: 600,
fontSize: 22,
color: 'var(--onboarding-title)',
}}
>
{slide.title}
</div>
<div
className={styles.bodyCopy}
style={{
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
fontSize: 16,
color: 'var(--onboarding-body)',
lineHeight: 1.5,
}}
>
{slide.body}
</div>
<Group justify="space-between">
<Button styles={secondaryStyles} onClick={onClose}>
Skip for now
</Button>
<Button styles={primaryStyles} onClick={handleSeePlans}>
{primaryLabel}
</Button>
</Group>
</Stack>
</div>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { useTour } from '@reactour/tour';
import { useOnboarding } from '@app/contexts/OnboardingContext';
export default function TourContent() {
const { isOpen } = useOnboarding();
const { setIsOpen, setCurrentStep } = useTour();
const previousIsOpenRef = React.useRef(isOpen);
React.useEffect(() => {
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
previousIsOpenRef.current = isOpen;
if (wasClosedNowOpen) {
setCurrentStep(0);
}
setIsOpen(isOpen);
}, [isOpen, setIsOpen, setCurrentStep]);
return null;
}

View File

@@ -0,0 +1,133 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import { AdminTourStep } from './tourSteps';
import { addGlowToElements, removeAllGlows } from './tourGlow';
interface AdminStepActions {
saveAdminState: () => void;
openConfigModal: () => void;
navigateToSection: (section: string) => void;
scrollNavToSection: (section: string) => Promise<void> | void;
}
interface CreateAdminStepsConfigArgs {
t: TFunction;
actions: AdminStepActions;
}
export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions;
return {
[AdminTourStep.WELCOME]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
position: 'right',
padding: 10,
action: () => {
saveAdminState();
},
},
[AdminTourStep.CONFIG_BUTTON]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
position: 'right',
padding: 10,
actionAfter: () => {
openConfigModal();
},
},
[AdminTourStep.SETTINGS_OVERVIEW]: {
selector: '.modal-nav',
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
position: 'right',
padding: 0,
action: () => {
removeAllGlows();
},
},
[AdminTourStep.TEAMS_AND_USERS]: {
selector: '[data-tour="admin-people-nav"]',
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('people');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
selector: '[data-tour="admin-adminGeneral-nav"]',
highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminGeneral');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.DATABASE_SECTION]: {
selector: '[data-tour="admin-adminDatabase-nav"]',
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminDatabase');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.CONNECTIONS_SECTION]: {
selector: '[data-tour="admin-adminConnections-nav"]',
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminConnections');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
actionAfter: async () => {
await scrollNavToSection('adminAudit');
},
},
[AdminTourStep.ADMIN_TOOLS]: {
selector: '[data-tour="admin-adminAudit-nav"]',
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminAudit');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
},
},
};
}

View File

@@ -0,0 +1,237 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useAuth } from '@app/auth/UseSession';
import type { LicenseNotice } from '@app/components/onboarding/slides/types';
interface InitialModalHandlers {
opened: boolean;
onLicenseNoticeUpdate: (notice: LicenseNotice) => void;
onRequestServerLicense: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
onClose: () => void;
}
interface ServerLicenseModalHandlers {
opened: boolean;
licenseNotice: LicenseNotice;
onClose: () => void;
}
export function useOnboardingFlow() {
const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig();
const { showCookieConsent, isReady: isCookieConsentReady } = useCookieConsentContext();
const { completeTour, tourType, isOpen } = useOnboarding();
let session: any = null;
try {
// eslint-disable-next-line react-hooks/rules-of-hooks
session = useAuth()?.session ?? null;
} catch {
session = {} as any;
}
const loginEnabled = !!config?.enableLogin;
const isAuthenticated = !!session;
const shouldShowIntro = !preferences.hasSeenIntroOnboarding && (!loginEnabled || isAuthenticated);
const isAdminUser = !!config?.isAdmin;
const [licenseNotice, setLicenseNotice] = useState<LicenseNotice>({
totalUsers: null,
freeTierLimit: 5,
isOverLimit: false,
});
const [cookieBannerPending, setCookieBannerPending] = useState(false);
const [serverLicenseIntent, setServerLicenseIntent] = useState<'idle' | 'pending' | 'deferred'>('idle');
const [serverLicenseSource, setServerLicenseSource] = useState<'config' | 'self-reported' | null>(null);
const [isServerLicenseOpen, setIsServerLicenseOpen] = useState(false);
const [hasShownServerLicense, setHasShownServerLicense] = useState(false);
const [toolPromptCompleted, setToolPromptCompleted] = useState(
preferences.toolPanelModePromptSeen || preferences.hasSelectedToolPanelMode,
);
const introWasOpenRef = useRef(false);
const handleInitialModalClose = useCallback(() => {
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
}, [preferences.hasSeenIntroOnboarding, updatePreference]);
const handleLicenseNoticeUpdate = useCallback((notice: LicenseNotice) => {
setLicenseNotice(notice);
}, []);
const handleToolPromptComplete = useCallback(() => {
setToolPromptCompleted(true);
}, []);
const maybeShowCookieBanner = useCallback(() => {
if (preferences.hasSeenCookieBanner) {
return;
}
if (!isCookieConsentReady || isServerLicenseOpen || serverLicenseIntent !== 'idle' || !toolPromptCompleted) {
setCookieBannerPending(true);
return;
}
setCookieBannerPending(false);
showCookieConsent();
updatePreference('hasSeenCookieBanner', true);
}, [
isCookieConsentReady,
isServerLicenseOpen,
preferences.hasSeenCookieBanner,
serverLicenseIntent,
showCookieConsent,
toolPromptCompleted,
updatePreference,
]);
const requestServerLicense = useCallback(
({
deferUntilTourComplete = false,
selfReportedAdmin = false,
}: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean } = {}) => {
const qualifies = isAdminUser || selfReportedAdmin;
if (!qualifies) {
return;
}
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
setServerLicenseIntent((prev) => {
if (prev === 'pending') {
return prev;
}
if (prev === 'deferred') {
return deferUntilTourComplete ? prev : 'pending';
}
if (prev === 'idle') {
return deferUntilTourComplete ? 'deferred' : 'pending';
}
return prev;
});
},
[isAdminUser],
);
useEffect(() => {
if (
cookieBannerPending &&
isCookieConsentReady &&
serverLicenseIntent === 'idle' &&
!isServerLicenseOpen &&
toolPromptCompleted
) {
maybeShowCookieBanner();
}
}, [
cookieBannerPending,
isCookieConsentReady,
isServerLicenseOpen,
serverLicenseIntent,
toolPromptCompleted,
maybeShowCookieBanner,
]);
useEffect(() => {
const isEligibleAdmin = isAdminUser || serverLicenseSource === 'self-reported';
if (
introWasOpenRef.current &&
!shouldShowIntro &&
isEligibleAdmin &&
toolPromptCompleted &&
!hasShownServerLicense &&
serverLicenseIntent === 'idle'
) {
setServerLicenseIntent('pending');
}
introWasOpenRef.current = shouldShowIntro;
}, [
hasShownServerLicense,
isAdminUser,
serverLicenseIntent,
shouldShowIntro,
serverLicenseSource,
toolPromptCompleted,
]);
useEffect(() => {
const isEligibleAdmin = isAdminUser || serverLicenseSource === 'self-reported';
if (
serverLicenseIntent !== 'idle' &&
!shouldShowIntro &&
!isOpen &&
!isServerLicenseOpen &&
isEligibleAdmin &&
toolPromptCompleted
) {
setIsServerLicenseOpen(true);
setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle');
}
}, [
isAdminUser,
isOpen,
isServerLicenseOpen,
serverLicenseIntent,
shouldShowIntro,
serverLicenseSource,
toolPromptCompleted,
]);
const handleServerLicenseClose = useCallback(() => {
setIsServerLicenseOpen(false);
setHasShownServerLicense(true);
setServerLicenseIntent('idle');
setServerLicenseSource(null);
maybeShowCookieBanner();
}, [maybeShowCookieBanner]);
const handleTourCompletion = useCallback(() => {
completeTour();
if (serverLicenseIntent === 'deferred') {
setServerLicenseIntent('pending');
} else if (tourType === 'admin' && (isAdminUser || serverLicenseSource === 'self-reported')) {
setServerLicenseSource((prev) => prev ?? (isAdminUser ? 'config' : 'self-reported'));
setServerLicenseIntent((prev) => (prev === 'pending' ? prev : 'pending'));
}
maybeShowCookieBanner();
}, [
completeTour,
isAdminUser,
maybeShowCookieBanner,
serverLicenseIntent,
serverLicenseSource,
tourType,
]);
const initialModalProps: InitialModalHandlers = useMemo(
() => ({
opened: shouldShowIntro,
onLicenseNoticeUpdate: handleLicenseNoticeUpdate,
onRequestServerLicense: requestServerLicense,
onClose: handleInitialModalClose,
}),
[handleInitialModalClose, handleLicenseNoticeUpdate, requestServerLicense, shouldShowIntro],
);
const serverLicenseModalProps: ServerLicenseModalHandlers = useMemo(
() => ({
opened: isServerLicenseOpen,
licenseNotice,
onClose: handleServerLicenseClose,
}),
[handleServerLicenseClose, isServerLicenseOpen, licenseNotice],
);
return {
tourType,
isTourOpen: isOpen,
maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined,
initialModalProps,
handleToolPromptComplete,
serverLicenseModalProps,
handleTourCompletion,
};
}

View File

@@ -0,0 +1,197 @@
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import { SlideConfig, LicenseNotice } from '@app/components/onboarding/slides/types';
export type SlideId =
| 'welcome'
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'server-license';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo';
export type ButtonAction =
| 'next'
| 'prev'
| 'close'
| 'complete-close'
| 'download-selected'
| 'security-next'
| 'launch-admin'
| 'launch-tools'
| 'launch-auto'
| 'see-plans'
| 'skip-to-license';
export interface FlowState {
selectedRole: 'admin' | 'user' | null;
}
export interface SlideFactoryParams {
osLabel: string;
osUrl: string;
selectedRole: 'admin' | 'user' | null;
onRoleSelect: (role: 'admin' | 'user' | null) => void;
licenseNotice?: LicenseNotice;
}
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: 'Next →',
variant: 'primary',
group: 'right',
action: 'next',
},
],
},
'desktop-install': {
id: 'desktop-install',
createSlide: ({ osLabel, osUrl }) => DesktopInstallSlide({ osLabel, osUrl }),
hero: { type: 'dual-icon' },
buttons: [
{
key: 'desktop-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'desktop-skip',
type: 'button',
label: 'Skip for now',
variant: 'secondary',
group: 'left',
action: 'next',
},
{
key: 'desktop-download',
type: 'button',
label: 'Download →',
variant: 'primary',
group: 'right',
action: 'download-selected',
},
],
},
'security-check': {
id: 'security-check',
createSlide: ({ selectedRole, onRoleSelect }) =>
SecurityCheckSlide({ selectedRole, onRoleSelect }),
hero: { type: 'shield' },
buttons: [
{
key: 'security-back',
type: 'button',
label: 'Back',
variant: 'secondary',
group: 'left',
action: 'prev',
},
{
key: 'security-next',
type: 'button',
label: 'Next →',
variant: 'primary',
group: 'right',
action: 'security-next',
disabledWhen: (state) => !state.selectedRole,
},
],
},
'admin-overview': {
id: 'admin-overview',
createSlide: ({ licenseNotice }) => PlanOverviewSlide({ isAdmin: true, licenseNotice }),
hero: { type: 'diamond' },
buttons: [
{
key: 'admin-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'admin-show',
type: 'button',
label: 'Show me around',
variant: 'primary',
group: 'right',
action: 'launch-admin',
},
{
key: 'admin-skip',
type: 'button',
label: 'Skip the tour',
variant: 'secondary',
group: 'left',
action: 'skip-to-license',
},
],
},
'server-license': {
id: 'server-license',
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
hero: { type: 'logo' },
buttons: [
{
key: 'license-close',
type: 'button',
label: 'Skip for now',
variant: 'secondary',
group: 'left',
action: 'close',
},
{
key: 'license-see-plans',
type: 'button',
label: 'See Plans →',
variant: 'primary',
group: 'right',
action: 'see-plans',
},
],
},
};
export const FLOW_SEQUENCES = {
loginAdmin: ['welcome', 'desktop-install', 'admin-overview'] as SlideId[],
loginUser: ['welcome', 'desktop-install'] as SlideId[],
noLoginBase: ['welcome', 'desktop-install', 'security-check'] as SlideId[],
noLoginAdmin: ['admin-overview'] as SlideId[],
};

View File

@@ -4,11 +4,37 @@
height: 220px;
overflow: hidden;
border-radius: 0;
background-size: 180% 180%;
animation: gradientShift 18s ease-in-out infinite alternate;
}
.heroActive {}
.gradientLayer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: 180% 180%;
opacity: 0;
transition: opacity 0.8s ease-in-out;
z-index: 1;
}
.gradientLayerActive {
opacity: 1;
animation: gradientShift 18s ease-in-out infinite alternate;
z-index: 2;
}
.gradientLayerPrev {
opacity: 1;
z-index: 3;
transition: opacity 0.8s ease-in-out;
}
.gradientLayerPrevFadeOut {
opacity: 0;
z-index: 3;
transition: opacity 0.8s ease-in-out;
}
.circle {
position: absolute;
@@ -22,6 +48,7 @@
animation-duration: var(--circle-duration, 15s);
animation-delay: var(--circle-delay, 0s);
will-change: transform;
z-index: 10;
}
@keyframes gradientShift {

View File

@@ -20,19 +20,59 @@ export default function AnimatedSlideBackground({
isActive,
slideKey,
}: AnimatedSlideBackgroundComponentProps) {
const gradientStyle = React.useMemo(
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
const [isTransitioning, setIsTransitioning] = React.useState(false);
const isFirstMount = React.useRef(true);
React.useEffect(() => {
// Skip transition on first mount
if (isFirstMount.current) {
isFirstMount.current = false;
setCurrentGradient(gradientStops);
return;
}
// Only transition if gradient actually changed
if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) {
// Store previous gradient and start transition
setPrevGradient(currentGradient);
setIsTransitioning(true);
// Update to new gradient (will fade in)
setCurrentGradient(gradientStops);
}
}, [gradientStops]);
const currentGradientStyle = React.useMemo(
() => ({
backgroundImage: `linear-gradient(135deg, ${gradientStops[0]}, ${gradientStops[1]})`,
backgroundImage: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
}),
[gradientStops],
[currentGradient],
);
const prevGradientStyle = prevGradient
? {
backgroundImage: `linear-gradient(135deg, ${prevGradient[0]}, ${prevGradient[1]})`,
}
: null;
return (
<div
className={`${styles.hero} ${isActive ? styles.heroActive : ''}`.trim()}
style={gradientStyle}
key={slideKey}
>
<div className={styles.hero} key="animated-background">
{prevGradientStyle && isTransitioning && (
<div
className={`${styles.gradientLayer} ${styles.gradientLayerPrevFadeOut}`}
style={prevGradientStyle}
onTransitionEnd={() => {
setPrevGradient(null);
setIsTransitioning(false);
}}
/>
)}
<div
className={`${styles.gradientLayer} ${isActive ? styles.gradientLayerActive : ''}`.trim()}
style={currentGradientStyle}
/>
{circles.map((circle, index) => {
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
@@ -65,7 +105,7 @@ export default function AnimatedSlideBackground({
return (
<div
key={`${slideKey}-circle-${index}`}
key={`circle-${index}-${position}`}
className={styles.circle}
style={circleStyle}
/>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { SlideConfig } from './types';
import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig';
interface DesktopInstallSlideProps {
osLabel: string;
@@ -20,29 +21,7 @@ export default function DesktopInstallSlide({ osLabel, osUrl }: DesktopInstallSl
downloadUrl: osUrl,
background: {
gradientStops: ['#2563EB', '#0EA5E9'],
circles: [
{
position: 'bottom-left',
size: 260,
color: 'rgba(255, 255, 255, 0.2)',
opacity: 0.88,
amplitude: 24,
duration: 11,
offsetX: 16,
offsetY: 12,
},
{
position: 'top-right',
size: 300,
color: 'rgba(28, 155, 235, 0.34)',
opacity: 0.86,
amplitude: 28,
duration: 12,
delay: 1,
offsetX: 20,
offsetY: 16,
},
],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}

View File

@@ -1,48 +1,35 @@
import React from 'react';
import { SlideConfig } from './types';
import { SlideConfig, LicenseNotice } from './types';
import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig';
interface PlanOverviewSlideProps {
isAdmin: boolean;
licenseNotice?: LicenseNotice;
}
export default function PlanOverviewSlide({ isAdmin }: PlanOverviewSlideProps): SlideConfig {
const DEFAULT_FREE_TIER_LIMIT = 5;
export default function PlanOverviewSlide({ isAdmin, licenseNotice }: PlanOverviewSlideProps): SlideConfig {
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
const adminBody = (
<span>
As an admin, you can manage users, configure settings, and monitor server health. The first{' '}
<strong>{freeTierLimit}</strong> people on your server get to use Stirling free of charge.
</span>
);
return {
key: isAdmin ? 'admin-overview' : 'plan-overview',
title: isAdmin ? 'Admin Overview' : 'Plan Overview',
body: isAdmin ? (
body: isAdmin ? adminBody : (
<span>
As an admin, you can manage users, configure settings, and monitor server health. The first 5 people on your server get to use Stirling free of charge.
</span>
) : (
<span>
For the next <strong>30 days</strong>, you'll enjoy <strong>unlimited Pro access</strong> to the Reader and the Editor. Afterwards, you can continue with the Reader for free or upgrade to keep the Editor too.
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.
</span>
),
background: {
gradientStops: ['#F97316', '#EF4444'],
circles: [
{
position: 'bottom-left',
size: 260,
color: 'rgba(255, 255, 255, 0.25)',
opacity: 0.9,
amplitude: 26,
duration: 11,
offsetX: 18,
offsetY: 12,
},
{
position: 'top-right',
size: 300,
color: 'rgba(251, 191, 36, 0.4)',
opacity: 0.9,
amplitude: 30,
duration: 12,
delay: 1.4,
offsetX: 24,
offsetY: 18,
},
],
gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Select } from '@mantine/core';
import styles from '../InitialOnboardingModal/InitialOnboardingModal.module.css';
import { SlideConfig } from './types';
import LocalIcon from '@app/components/shared/LocalIcon';
import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig';
interface SecurityCheckSlideProps {
selectedRole: 'admin' | 'user' | null;
onRoleSelect: (role: 'admin' | 'user' | null) => void;
}
export default function SecurityCheckSlide({
selectedRole,
onRoleSelect,
}: SecurityCheckSlideProps): SlideConfig {
return {
key: 'security-check',
title: 'Security Check',
body: (
<div className={styles.securitySlideContent}>
<div className={styles.securityCard}>
<div className={styles.securityAlertRow}>
<LocalIcon icon="error" width={20} height={20} style={{ color: '#F04438', flexShrink: 0 }} />
<span>Oops! Your Stirling server doesn't have an admin yet.</span>
</div>
<Select
placeholder="Confirm your role"
value={selectedRole}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
]}
onChange={(value) => onRoleSelect((value as 'admin' | 'user') ?? null)}
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
styles={{
input: {
height: 48,
fontSize: 15,
},
}}
/>
</div>
</div>
),
background: {
gradientStops: ['#5B21B6', '#2563EB'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { SlideConfig, LicenseNotice } from './types';
import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig';
interface ServerLicenseSlideProps {
licenseNotice?: LicenseNotice;
}
const DEFAULT_FREE_TIER_LIMIT = 5;
export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlideProps = {}): SlideConfig {
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
const totalUsers = licenseNotice?.totalUsers ?? null;
const isOverLimit = licenseNotice?.isOverLimit ?? false;
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
const title = isOverLimit ? 'Server License Needed' : 'Server License';
const key = isOverLimit ? 'server-license-over-limit' : 'server-license';
const body = isOverLimit ? (
<span>
Our licensing permits up to <strong>{freeTierLimit}</strong> users for free per server. You have{' '}
<strong>{overLimitUserCopy}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Server
plan - unlimited seats, PDF text editing, and full admin control for $99/server/mo.
</span>
) : (
<span>
Our licensing permits up to <strong>{freeTierLimit}</strong> users for free per server. To scale uninterrupted
and access our new PDF text editing tool, we recommend the Stirling Server plan - full editing and unlimited
seats for $99/server/mo.
</span>
);
return {
key,
title,
body,
background: {
gradientStops: isOverLimit ? ['#F472B6', '#8B5CF6'] : ['#F97316', '#F59E0B'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}

View File

@@ -1,22 +1,15 @@
import React from 'react';
import { SlideConfig } from './types';
import styles from '../InitialOnboardingModal/InitialOnboardingModal.module.css';
import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig';
export default function WelcomeSlide(): SlideConfig {
return {
key: 'welcome',
title: (
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<span className={styles.welcomeTitleContainer}>
Welcome to Stirling
<span
style={{
background: '#DBEFFF',
color: '#2A4BFF',
padding: '4px 12px',
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
}}
>
<span className={styles.v2Badge}>
V2
</span>
</span>
@@ -28,29 +21,7 @@ export default function WelcomeSlide(): SlideConfig {
),
background: {
gradientStops: ['#7C3AED', '#EC4899'],
circles: [
{
position: 'bottom-left',
size: 260,
color: 'rgba(255, 255, 255, 0.25)',
opacity: 0.9,
amplitude: 24,
duration: 11,
offsetX: 18,
offsetY: 14,
},
{
position: 'top-right',
size: 300,
color: 'rgba(196, 181, 253, 0.4)',
opacity: 0.9,
amplitude: 28,
duration: 12,
delay: 1.2,
offsetX: 24,
offsetY: 18,
},
],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}

View File

@@ -25,3 +25,9 @@ export interface SlideConfig {
background: AnimatedSlideBackgroundProps;
downloadUrl?: string;
}
export interface LicenseNotice {
totalUsers: number | null;
freeTierLimit: number;
isOverLimit: boolean;
}

View File

@@ -0,0 +1,30 @@
import { AnimatedCircleConfig } from './types';
/**
* Unified circle background configuration used across all onboarding slides.
* Only gradient colors change between slides, creating smooth transitions.
*/
export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
{
position: 'bottom-left',
size: 270,
color: 'rgba(255, 255, 255, 0.25)',
opacity: 0.9,
amplitude: 24,
duration: 4.5,
offsetX: 18,
offsetY: 14,
},
{
position: 'top-right',
size: 300,
color: 'rgba(255, 255, 255, 0.2)',
opacity: 0.9,
amplitude: 28,
duration: 4.5,
delay: 0.5,
offsetX: 24,
offsetY: 18,
},
];

View File

@@ -0,0 +1,18 @@
export const addGlowToElements = (selectors: string[]) => {
selectors.forEach((selector) => {
const element = document.querySelector(selector);
if (element) {
if (selector === '[data-tour="settings-content-area"]') {
element.classList.add('tour-content-glow');
} else {
element.classList.add('tour-nav-glow');
}
}
});
};
export const removeAllGlows = () => {
document.querySelectorAll('.tour-content-glow').forEach((el) => el.classList.remove('tour-content-glow'));
document.querySelectorAll('.tour-nav-glow').forEach((el) => el.classList.remove('tour-nav-glow'));
};

View File

@@ -0,0 +1,33 @@
export enum TourStep {
ALL_TOOLS,
SELECT_CROP_TOOL,
TOOL_INTERFACE,
FILES_BUTTON,
FILE_SOURCES,
WORKBENCH,
VIEW_SWITCHER,
VIEWER,
PAGE_EDITOR,
ACTIVE_FILES,
FILE_CHECKBOX,
SELECT_CONTROLS,
CROP_SETTINGS,
RUN_BUTTON,
RESULTS,
FILE_REPLACEMENT,
PIN_BUTTON,
WRAP_UP,
}
export enum AdminTourStep {
WELCOME,
CONFIG_BUTTON,
SETTINGS_OVERVIEW,
TEAMS_AND_USERS,
SYSTEM_CUSTOMIZATION,
DATABASE_SECTION,
CONNECTIONS_SECTION,
ADMIN_TOOLS,
WRAP_UP,
}

View File

@@ -0,0 +1,173 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import { TourStep } from './tourSteps';
interface UserStepActions {
saveWorkbenchState: () => void;
closeFilesModal: () => void;
backToAllTools: () => void;
selectCropTool: () => void;
loadSampleFile: () => void;
switchToViewer: () => void;
switchToPageEditor: () => void;
switchToActiveFiles: () => void;
selectFirstFile: () => void;
pinFile: () => void;
modifyCropSettings: () => void;
executeTool: () => void;
openFilesModal: () => void;
}
interface CreateUserStepsConfigArgs {
t: TFunction;
actions: UserStepActions;
}
export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
const {
saveWorkbenchState,
closeFilesModal,
backToAllTools,
selectCropTool,
loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles,
selectFirstFile,
pinFile,
modifyCropSettings,
executeTool,
openFilesModal,
} = actions;
return {
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
position: 'center',
padding: 0,
action: () => {
saveWorkbenchState();
closeFilesModal();
backToAllTools();
},
},
[TourStep.SELECT_CROP_TOOL]: {
selector: '[data-tour="tool-button-crop"]',
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
position: 'right',
padding: 0,
actionAfter: () => selectCropTool(),
},
[TourStep.TOOL_INTERFACE]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
position: 'center',
padding: 0,
},
[TourStep.FILES_BUTTON]: {
selector: '[data-tour="files-button"]',
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
position: 'right',
padding: 10,
action: () => openFilesModal(),
},
[TourStep.FILE_SOURCES]: {
selector: '[data-tour="file-sources"]',
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
position: 'right',
padding: 0,
actionAfter: () => {
loadSampleFile();
closeFilesModal();
},
},
[TourStep.WORKBENCH]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
position: 'center',
padding: 0,
},
[TourStep.VIEW_SWITCHER]: {
selector: '[data-tour="view-switcher"]',
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
position: 'bottom',
padding: 0,
},
[TourStep.VIEWER]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
position: 'center',
padding: 0,
action: () => switchToViewer(),
},
[TourStep.PAGE_EDITOR]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
position: 'center',
padding: 0,
action: () => switchToPageEditor(),
},
[TourStep.ACTIVE_FILES]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
position: 'center',
padding: 0,
action: () => switchToActiveFiles(),
},
[TourStep.FILE_CHECKBOX]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
position: 'top',
padding: 10,
},
[TourStep.SELECT_CONTROLS]: {
selector: '[data-tour="right-rail-controls"]',
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
position: 'left',
padding: 5,
action: () => selectFirstFile(),
},
[TourStep.CROP_SETTINGS]: {
selector: '[data-tour="crop-settings"]',
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
position: 'left',
padding: 10,
action: () => modifyCropSettings(),
},
[TourStep.RUN_BUTTON]: {
selector: '[data-tour="run-button"]',
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
position: 'top',
padding: 10,
actionAfter: () => executeTool(),
},
[TourStep.RESULTS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
position: 'center',
padding: 0,
},
[TourStep.FILE_REPLACEMENT]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
position: 'left',
padding: 10,
},
[TourStep.PIN_BUTTON]: {
selector: '[data-tour="file-card-pin"]',
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
position: 'left',
padding: 10,
action: () => pinFile(),
},
[TourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
position: 'right',
padding: 10,
},
};
}

View File

@@ -1,6 +1,6 @@
import { Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useCookieConsent } from '@app/hooks/useCookieConsent';
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
interface FooterProps {
privacyPolicy?: string;
@@ -20,7 +20,7 @@ export default function Footer({
analyticsEnabled = false
}: FooterProps) {
const { t } = useTranslation();
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
const { showCookiePreferences } = useCookieConsentContext();
// Helper to check if a value is valid (not null/undefined/empty string)
const isValidLink = (link?: string) => link && link.trim().length > 0;

View File

@@ -268,7 +268,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div
key={buttonConfig.id}
data-tour="help-button"
onClick={() => startTour('tools')}
onClick={() =>
startTour('tools', {
source: 'quick-access-help-button',
metadata: { entry: 'direct' },
})
}
>
{renderNavButton(buttonConfig, index)}
</div>
@@ -285,7 +290,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<Menu.Dropdown>
<Menu.Item
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => startTour('tools')}
onClick={() =>
startTour('tools', {
source: 'quick-access-help-menu',
metadata: { entry: 'menu-tools' },
})
}
>
<div>
<div style={{ fontWeight: 500 }}>
@@ -298,7 +308,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => startTour('admin')}
onClick={() =>
startTour('admin', {
source: 'quick-access-help-menu',
metadata: { entry: 'menu-admin' },
})
}
>
<div>
<div style={{ fontWeight: 500 }}>

View File

@@ -107,7 +107,10 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
onChange={(val: string) => {
updatePreference('defaultToolPanelMode', val as ToolPanelMode);
updatePreference('hasSelectedToolPanelMode', true);
}}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },

View File

@@ -5,14 +5,23 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import '@app/components/tools/ToolPanelModePrompt.css';
import type { ToolPanelMode } from '@app/constants/toolPanel';
import { DEFAULT_TOOL_PANEL_MODE, type ToolPanelMode } from '@app/constants/toolPanel';
import { useAppConfig } from '@app/contexts/AppConfigContext';
const ToolPanelModePrompt = () => {
interface ToolPanelModePromptProps {
onComplete?: () => void;
}
const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
const { t } = useTranslation();
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
const { preferences, updatePreference } = usePreferences();
const { startTour, startAfterToolModeSelection, setStartAfterToolModeSelection, setShowWelcomeModal } = useOnboarding();
const {
startTour,
startAfterToolModeSelection,
setStartAfterToolModeSelection,
pendingTourRequest,
} = useOnboarding();
const [opened, setOpened] = useState(false);
const { config } = useAppConfig();
const isAdmin = !!config?.isAdmin;
@@ -26,27 +35,56 @@ const ToolPanelModePrompt = () => {
}
}, [shouldShowPrompt]);
const resolveRequestedTourType = (): 'admin' | 'tools' => {
if (pendingTourRequest?.type) {
return pendingTourRequest.type;
}
if (pendingTourRequest?.metadata && 'selfReportedAdmin' in pendingTourRequest.metadata) {
return pendingTourRequest.metadata.selfReportedAdmin ? 'admin' : 'tools';
}
return isAdmin ? 'admin' : 'tools';
};
const resumeDeferredTour = (context?: { selection?: ToolPanelMode; dismissed?: boolean }) => {
if (!startAfterToolModeSelection) {
return;
}
setStartAfterToolModeSelection(false);
const targetType = resolveRequestedTourType();
startTour(targetType, {
skipToolPromptRequirement: true,
source: 'tool-panel-mode-prompt',
metadata: {
...pendingTourRequest?.metadata,
resumedFromToolPrompt: true,
...(context?.selection ? { selection: context.selection } : {}),
...(context?.dismissed ? { dismissed: true } : {}),
},
});
};
const handleSelect = (mode: ToolPanelMode) => {
setToolPanelMode(mode);
updatePreference('defaultToolPanelMode', mode);
updatePreference('toolPanelModePromptSeen', true);
updatePreference('hasSelectedToolPanelMode', true);
setOpened(false);
// If the user requested the tour after completing this prompt, start it now
if (startAfterToolModeSelection && !preferences.hasCompletedOnboarding) {
setShowWelcomeModal(false);
setStartAfterToolModeSelection(false);
if (isAdmin) {
startTour('admin');
} else {
startTour('tools');
}
}
resumeDeferredTour({ selection: mode });
onComplete?.();
};
const handleDismiss = () => {
const defaultMode: ToolPanelMode = 'sidebar';
if (toolPanelMode !== defaultMode) {
setToolPanelMode(defaultMode);
updatePreference('defaultToolPanelMode', defaultMode);
}
updatePreference('hasSelectedToolPanelMode', true);
updatePreference('toolPanelModePromptSeen', true);
setOpened(false);
resumeDeferredTour({ dismissed: true });
onComplete?.();
};
return (

View File

@@ -0,0 +1,42 @@
import React, { createContext, useContext, useMemo } from 'react';
import { useCookieConsent } from '@app/hooks/useCookieConsent';
import { useAppConfig } from '@app/contexts/AppConfigContext';
interface CookieConsentContextValue {
isReady: boolean;
showCookieConsent: () => void;
showCookiePreferences: () => void;
}
const CookieConsentContext = createContext<CookieConsentContextValue | undefined>(undefined);
export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { config } = useAppConfig();
const analyticsEnabled = config?.enableAnalytics === true;
const {
showCookieConsent,
showCookiePreferences,
isInitialized,
} = useCookieConsent({ analyticsEnabled });
const value = useMemo<CookieConsentContextValue>(() => ({
isReady: analyticsEnabled && isInitialized,
showCookieConsent,
showCookiePreferences,
}), [analyticsEnabled, isInitialized, showCookieConsent, showCookiePreferences]);
return (
<CookieConsentContext.Provider value={value}>
{children}
</CookieConsentContext.Provider>
);
};
export const useCookieConsentContext = (): CookieConsentContextValue => {
const context = useContext(CookieConsentContext);
if (!context) {
throw new Error('useCookieConsentContext must be used within a CookieConsentProvider');
}
return context;
};

View File

@@ -1,48 +1,107 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import React, { createContext, useContext, useState, useCallback } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useShouldShowWelcomeModal } from '@app/hooks/useShouldShowWelcomeModal';
export type TourType = 'tools' | 'admin';
export interface StartTourOptions {
source?: string;
skipToolPromptRequirement?: boolean;
metadata?: Record<string, unknown>;
}
interface PendingTourRequest {
type: TourType;
source?: string;
metadata?: Record<string, unknown>;
requestedAt: number;
}
interface OnboardingContextValue {
isOpen: boolean;
currentStep: number;
tourType: TourType;
setCurrentStep: (step: number) => void;
startTour: (type?: TourType) => void;
startTour: (type?: TourType, options?: StartTourOptions) => void;
closeTour: () => void;
completeTour: () => void;
resetTour: (type?: TourType) => void;
showWelcomeModal: boolean;
setShowWelcomeModal: (show: boolean) => void;
startAfterToolModeSelection: boolean;
setStartAfterToolModeSelection: (value: boolean) => void;
pendingTourRequest: PendingTourRequest | null;
clearPendingTourRequest: () => void;
}
const OnboardingContext = createContext<OnboardingContextValue | undefined>(undefined);
export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { updatePreference } = usePreferences();
const { preferences, updatePreference } = usePreferences();
const [isOpen, setIsOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [tourType, setTourType] = useState<TourType>('tools');
const [showWelcomeModal, setShowWelcomeModal] = useState(false);
const [startAfterToolModeSelection, setStartAfterToolModeSelection] = useState(false);
const shouldShow = useShouldShowWelcomeModal();
const [pendingTourRequest, setPendingTourRequest] = useState<PendingTourRequest | null>(null);
// Auto-show welcome modal for first-time users
useEffect(() => {
if (shouldShow) {
setShowWelcomeModal(true);
}
}, [shouldShow]);
const startTour = useCallback((type: TourType = 'tools') => {
const openTour = useCallback((type: TourType = 'tools') => {
setTourType(type);
setCurrentStep(0);
setIsOpen(true);
}, []);
const startTour = useCallback(
(type: TourType = 'tools', options?: StartTourOptions) => {
const requestedType = type ?? 'tools';
const source = options?.source ?? 'unspecified';
const metadata = options?.metadata;
const skipToolPromptRequirement = options?.skipToolPromptRequirement ?? false;
const toolPromptSeen = preferences.toolPanelModePromptSeen;
const hasSelectedToolPanelMode = preferences.hasSelectedToolPanelMode;
const hasToolPreference = toolPromptSeen || hasSelectedToolPanelMode;
const shouldDefer = !skipToolPromptRequirement && !hasToolPreference;
console.log('[onboarding] startTour invoked', {
requestedType,
source,
toolPromptSeen,
hasSelectedToolPanelMode,
shouldDefer,
hasPendingTourRequest: !!pendingTourRequest,
metadata,
});
if (shouldDefer) {
setPendingTourRequest({
type: requestedType,
source,
metadata,
requestedAt: Date.now(),
});
setStartAfterToolModeSelection(true);
console.log('[onboarding] deferring tour launch until tool panel mode selection completes', {
requestedType,
source,
});
return;
}
if (pendingTourRequest) {
console.log('[onboarding] clearing previous pending tour request before starting new tour', {
previousRequest: pendingTourRequest,
newType: requestedType,
source,
});
}
setPendingTourRequest(null);
setStartAfterToolModeSelection(false);
console.log('[onboarding] starting tour', {
requestedType,
source,
});
openTour(requestedType);
},
[openTour, pendingTourRequest, preferences.toolPanelModePromptSeen, preferences.hasSelectedToolPanelMode],
);
const closeTour = useCallback(() => {
setIsOpen(false);
}, []);
@@ -59,6 +118,16 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch
setIsOpen(true);
}, [updatePreference]);
const clearPendingTourRequest = useCallback(() => {
if (pendingTourRequest) {
console.log('[onboarding] clearing pending tour request manually', {
pendingTourRequest,
});
}
setPendingTourRequest(null);
setStartAfterToolModeSelection(false);
}, [pendingTourRequest]);
return (
<OnboardingContext.Provider
value={{
@@ -70,10 +139,10 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch
closeTour,
completeTour,
resetTour,
showWelcomeModal,
setShowWelcomeModal,
startAfterToolModeSelection,
setStartAfterToolModeSelection,
pendingTourRequest,
clearPendingTourRequest,
}}
>
{children}

View File

@@ -145,6 +145,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const setToolPanelMode = useCallback((mode: ToolPanelMode) => {
dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: mode });
updatePreference('defaultToolPanelMode', mode);
updatePreference('hasSelectedToolPanelMode', true);
}, [updatePreference]);

View File

@@ -19,7 +19,7 @@ interface CookieConsentConfig {
}
export const useCookieConsent = ({
analyticsEnabled = false
analyticsEnabled = false,
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
@@ -34,10 +34,6 @@ export const useCookieConsent = ({
// Prevent double initialization
if (window.CookieConsent) {
setIsInitialized(true);
// Force show the modal if it exists but isn't visible
setTimeout(() => {
window.CookieConsent?.show();
}, 100);
return;
}
@@ -116,7 +112,7 @@ export const useCookieConsent = ({
// Initialize cookie consent with full configuration
try {
window.CookieConsent.run({
autoShow: true,
autoShow: false,
hideFromBots: false,
guiOptions: {
consentModal: {
@@ -205,11 +201,6 @@ export const useCookieConsent = ({
}
});
// Force show after initialization
setTimeout(() => {
window.CookieConsent?.show();
}, 200);
} catch (error) {
console.error('Error initializing CookieConsent:', error);
}
@@ -237,11 +228,17 @@ export const useCookieConsent = ({
};
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
const showCookiePreferences = () => {
const showCookieConsent = useCallback(() => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent?.show();
}
}, [isInitialized]);
const showCookiePreferences = useCallback(() => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent?.show(true);
}
};
}, [isInitialized]);
const isServiceAccepted = useCallback((service: string, category: string): boolean => {
if (typeof window === 'undefined' || !window.CookieConsent) {
@@ -251,7 +248,9 @@ export const useCookieConsent = ({
}, []);
return {
showCookieConsent,
showCookiePreferences,
isServiceAccepted
isServiceAccepted,
isInitialized
};
};

View File

@@ -7,9 +7,11 @@ export interface UserPreferences {
defaultToolPanelMode: ToolPanelMode;
theme: ThemeMode;
toolPanelModePromptSeen: boolean;
hasSelectedToolPanelMode: boolean;
showLegacyToolDescriptions: boolean;
hasCompletedOnboarding: boolean;
hasSeenIntroOnboarding: boolean;
hasSeenCookieBanner: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
@@ -18,9 +20,11 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
defaultToolPanelMode: DEFAULT_TOOL_PANEL_MODE,
theme: getSystemTheme(),
toolPanelModePromptSeen: false,
hasSelectedToolPanelMode: false,
showLegacyToolDescriptions: false,
hasCompletedOnboarding: false,
hasSeenIntroOnboarding: false,
hasSeenCookieBanner: false,
};
const STORAGE_KEY = 'stirlingpdf_preferences';