Chore/v2/onboarding flow cleanup (#5065)

This commit is contained in:
EthanHealy01
2025-12-02 12:40:20 +00:00
committed by GitHub
parent 341adaa07d
commit 179b569769
44 changed files with 1698 additions and 2275 deletions
@@ -1,33 +0,0 @@
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,
};
}
@@ -6,7 +6,7 @@ import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onb
import type { LicenseNotice } from '@app/types/types';
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
interface RenderButtonsProps {
interface SlideButtonsProps {
slideDefinition: {
buttons: ButtonDefinition[];
id: string;
@@ -16,7 +16,7 @@ interface RenderButtonsProps {
onAction: (action: ButtonAction) => void;
}
export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) {
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
const { t } = useTranslation();
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
@@ -105,5 +105,4 @@ export function renderButtons({ slideDefinition, licenseNotice, flowState, onAct
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group>
);
}
}
@@ -1,21 +0,0 @@
import type { LicenseNotice } from '@app/types/types';
export interface InitialOnboardingModalProps {
opened: boolean;
onClose: () => void;
onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void;
}
export interface OnboardingState {
step: number;
selectedRole: 'admin' | 'user' | null;
selfReportedAdmin: boolean;
}
export const DEFAULT_STATE: OnboardingState = {
step: 0,
selectedRole: null,
selfReportedAdmin: false,
};
@@ -1,381 +0,0 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useOs } from '@app/hooks/useOs';
import { useNavigate } from 'react-router-dom';
import {
SLIDE_DEFINITIONS,
type ButtonAction,
type FlowState,
type SlideId,
} from '@app/components/onboarding/onboardingFlowConfig';
import type { LicenseNotice } from '@app/types/types';
import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver';
import { useServerExperience } from '@app/hooks/useServerExperience';
import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types';
import { DOWNLOAD_URLS } from '@app/constants/downloads';
interface UseInitialOnboardingStateResult {
state: OnboardingState;
totalSteps: number;
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
licenseNotice: LicenseNotice;
flowState: FlowState;
closeAndMarkSeen: () => void;
handleButtonAction: (action: ButtonAction) => void;
}
export function useInitialOnboardingState({
opened,
onClose,
onRequestServerLicense,
onLicenseNoticeUpdate,
}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
const { preferences, updatePreference } = usePreferences();
const { startTour } = useOnboarding();
const {
loginEnabled: loginEnabledFromServer,
configIsAdmin,
totalUsers: serverTotalUsers,
userCountResolved: serverUserCountResolved,
freeTierLimit,
hasPaidLicense,
scenarioKey,
setSelfReportedAdmin,
isNewServer,
} = useServerExperience();
const osType = useOs();
const navigate = useNavigate();
const selectedDownloadUrlRef = useRef<string>('');
const [state, setState] = useState<OnboardingState>(DEFAULT_STATE);
const resetState = useCallback(() => {
setState(DEFAULT_STATE);
}, []);
useEffect(() => {
if (!opened) {
resetState();
}
}, [opened, resetState]);
const handleRoleSelect = useCallback(
(role: 'admin' | 'user' | null) => {
const isAdminSelection = role === 'admin';
setState((prev) => ({
...prev,
selectedRole: role,
selfReportedAdmin: isAdminSelection,
}));
if (typeof window !== 'undefined') {
if (isAdminSelection) {
window.localStorage.setItem('stirling-self-reported-admin', 'true');
} else {
window.localStorage.removeItem('stirling-self-reported-admin');
}
}
setSelfReportedAdmin(isAdminSelection);
},
[setSelfReportedAdmin],
);
const closeAndMarkSeen = useCallback(() => {
if (!preferences.hasSeenIntroOnboarding) {
updatePreference('hasSeenIntroOnboarding', true);
}
onClose();
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
const isAdmin = configIsAdmin;
const enableLogin = loginEnabledFromServer;
const effectiveEnableLogin = enableLogin;
const effectiveIsAdmin = isAdmin;
const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin;
useEffect(() => {
if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) {
handleRoleSelect('admin');
}
}, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]);
const shouldUseServerCount =
(effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin;
const licenseUserCountFromServer =
shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null;
const effectiveLicenseUserCount = licenseUserCountFromServer ?? null;
const os = useMemo(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
case 'mac-apple':
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
case 'mac-intel':
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
default:
return { label: '', url: '' };
}
}, [osType]);
const osOptions = useMemo(() => {
const options = [
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
];
return options.filter(opt => opt.url);
}, []);
const resolvedFlow = useMemo(
() => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin),
[effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin],
);
const shouldSkipSecurityCheck = shouldAssumeAdminForNewServer;
const flowSlideIds = useMemo(
() =>
shouldSkipSecurityCheck
? resolvedFlow.ids.filter((id) => id !== 'security-check')
: resolvedFlow.ids,
[resolvedFlow.ids, shouldSkipSecurityCheck],
);
const flowType = resolvedFlow.type;
const totalSteps = flowSlideIds.length;
const maxIndex = Math.max(totalSteps - 1, 0);
useEffect(() => {
if (state.step >= flowSlideIds.length) {
setState((prev) => ({
...prev,
step: Math.max(flowSlideIds.length - 1, 0),
}));
}
}, [flowSlideIds.length, state.step]);
const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1];
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
if (!slideDefinition) {
return null;
}
const scenarioProvidesInfo =
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
const scenarioIndicatesAdmin = scenarioProvidesInfo
? scenarioKey!.includes('admin')
: state.selfReportedAdmin || effectiveIsAdmin;
const scenarioIndicatesOverLimit = scenarioProvidesInfo
? scenarioKey!.includes('over-limit')
: effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit;
const scenarioRequiresLicense =
scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true;
const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense;
const licenseNotice = useMemo<LicenseNotice>(
() => ({
totalUsers: effectiveLicenseUserCount,
freeTierLimit,
isOverLimit: scenarioIndicatesOverLimit,
requiresLicense: shouldShowServerLicenseInfo,
}),
[
effectiveLicenseUserCount,
freeTierLimit,
scenarioIndicatesOverLimit,
shouldShowServerLicenseInfo,
],
);
const requestServerLicenseIfNeeded = useCallback(
(options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => {
if (!shouldShowServerLicenseInfo) {
return;
}
onRequestServerLicense?.(options);
},
[onRequestServerLicense, shouldShowServerLicenseInfo],
);
useEffect(() => {
onLicenseNoticeUpdate?.(licenseNotice);
}, [licenseNotice, onLicenseNoticeUpdate]);
// Initialize ref with default URL
useEffect(() => {
if (!selectedDownloadUrlRef.current && os.url) {
selectedDownloadUrlRef.current = os.url;
}
}, [os.url]);
const handleDownloadUrlChange = useCallback((url: string) => {
selectedDownloadUrlRef.current = url;
}, []);
const currentSlide = slideDefinition.createSlide({
osLabel: os.label,
osUrl: os.url,
osOptions,
onDownloadUrlChange: handleDownloadUrlChange,
selectedRole: state.selectedRole,
onRoleSelect: handleRoleSelect,
licenseNotice,
loginEnabled: effectiveEnableLogin,
});
const goNext = useCallback(() => {
setState((prev) => ({
...prev,
step: Math.min(prev.step + 1, maxIndex),
}));
}, [maxIndex]);
const goPrev = useCallback(() => {
setState((prev) => ({
...prev,
step: Math.max(prev.step - 1, 0),
}));
}, []);
const launchTour = useCallback(
(mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => {
if (options?.closeOnboardingSlides) {
closeAndMarkSeen();
}
startTour(mode, {
source: 'initial-onboarding-modal',
metadata: {
hasCompletedOnboarding: preferences.hasCompletedOnboarding,
toolPanelModePromptSeen: preferences.toolPanelModePromptSeen,
selfReportedAdmin: state.selfReportedAdmin,
},
});
},
[closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin],
);
const handleButtonAction = useCallback(
(action: ButtonAction) => {
const currentSlideIdLocal = currentSlideId;
const shouldAutoLaunchLoginUserTour =
flowType === 'login-user' && currentSlideIdLocal === 'desktop-install';
switch (action) {
case 'next':
if (shouldAutoLaunchLoginUserTour) {
launchTour('tools', { closeOnboardingSlides: true });
return;
}
goNext();
return;
case 'prev':
goPrev();
return;
case 'close':
closeAndMarkSeen();
return;
case 'download-selected': {
const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener');
}
if (shouldAutoLaunchLoginUserTour) {
launchTour('tools', { closeOnboardingSlides: true });
return;
}
goNext();
return;
}
case 'complete-close':
updatePreference('hasCompletedOnboarding', true);
closeAndMarkSeen();
return;
case 'security-next':
if (!state.selectedRole) {
return;
}
if (state.selectedRole === 'admin') {
goNext();
} else {
launchTour('tools', { closeOnboardingSlides: true });
}
return;
case 'launch-admin':
requestServerLicenseIfNeeded({
deferUntilTourComplete: true,
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
});
launchTour('admin', { closeOnboardingSlides: true });
return;
case 'launch-tools':
launchTour('tools', { closeOnboardingSlides: true });
return;
case 'launch-auto': {
const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools';
if (launchMode === 'admin') {
requestServerLicenseIfNeeded({
deferUntilTourComplete: true,
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
});
}
launchTour(launchMode, { closeOnboardingSlides: true });
return;
}
case 'skip-to-license':
updatePreference('hasCompletedOnboarding', true);
requestServerLicenseIfNeeded({
deferUntilTourComplete: false,
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
});
closeAndMarkSeen();
return;
case 'see-plans':
closeAndMarkSeen();
navigate('/settings/adminPlan');
return;
default:
return;
}
},
[
closeAndMarkSeen,
currentSlide,
effectiveIsAdmin,
flowType,
goNext,
goPrev,
launchTour,
navigate,
requestServerLicenseIfNeeded,
onRequestServerLicense,
os.url,
state.selectedRole,
state.selfReportedAdmin,
updatePreference,
],
);
const flowState: FlowState = { selectedRole: state.selectedRole };
return {
state,
totalSteps,
slideDefinition,
currentSlide,
licenseNotice,
flowState,
closeAndMarkSeen,
handleButtonAction,
};
}
@@ -0,0 +1,319 @@
import { useEffect, useMemo, useCallback, useState } from 'react';
import { type StepType } from '@reactour/tour';
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import { isAuthRoute } from '@app/constants/routes';
import { dispatchTourState } from '@app/constants/events';
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
import {
useServerLicenseRequest,
useTourRequest,
} from '@app/components/onboarding/useOnboardingEffects';
import { useOnboardingDownload } from '@app/components/onboarding/useOnboardingDownload';
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useServerExperience } from '@app/hooks/useServerExperience';
import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal';
import '@app/components/onboarding/OnboardingTour.css';
export default function Onboarding() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { state, actions } = useOnboardingOrchestrator();
const serverExperience = useServerExperience();
const onAuthRoute = isAuthRoute(location.pathname);
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
actions.updateRuntimeState({ selectedRole: role });
serverExperience.setSelfReportedAdmin(role === 'admin');
}, [actions, serverExperience]);
const handlePasswordChanged = useCallback(() => {
actions.updateRuntimeState({ requiresPasswordChange: false });
window.location.href = '/login';
}, [actions]);
const handleButtonAction = useCallback((action: ButtonAction) => {
switch (action) {
case 'next':
case 'complete-close':
actions.complete();
break;
case 'prev':
actions.prev();
break;
case 'close':
actions.skip();
break;
case 'download-selected':
handleDownloadSelected();
actions.complete();
break;
case 'security-next':
if (!runtimeState.selectedRole) return;
if (runtimeState.selectedRole !== 'admin') {
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
}
actions.complete();
break;
case 'launch-admin':
actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
actions.complete();
break;
case 'launch-tools':
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
actions.complete();
break;
case 'launch-auto': {
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
actions.updateRuntimeState({ tourRequested: true, tourType });
actions.complete();
break;
}
case 'skip-to-license':
markStepSeen('tour');
actions.updateRuntimeState({ tourRequested: false });
actions.complete();
break;
case 'see-plans':
actions.complete();
navigate('/settings/adminPlan');
break;
}
}, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
const [isTourOpen, setIsTourOpen] = useState(false);
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
const { openFilesModal, closeFilesModal } = useFilesModalContext();
const tourOrch = useTourOrchestration();
const adminTourOrch = useAdminTourOrchestration();
const userStepsConfig = useMemo(
() => createUserStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
selectCropTool: tourOrch.selectCropTool,
loadSampleFile: tourOrch.loadSampleFile,
switchToViewer: tourOrch.switchToViewer,
switchToPageEditor: tourOrch.switchToPageEditor,
switchToActiveFiles: tourOrch.switchToActiveFiles,
selectFirstFile: tourOrch.selectFirstFile,
pinFile: tourOrch.pinFile,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
openFilesModal,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal]
);
const adminStepsConfig = useMemo(
() => createAdminStepsConfig({
t,
actions: {
saveAdminState: adminTourOrch.saveAdminState,
openConfigModal: adminTourOrch.openConfigModal,
navigateToSection: adminTourOrch.navigateToSection,
scrollNavToSection: adminTourOrch.scrollNavToSection,
},
}),
[t, adminTourOrch]
);
const tourSteps = useMemo<StepType[]>(() => {
const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
return Object.values(config);
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
useEffect(() => {
if (currentStep?.id === 'tour' && !isTourOpen) {
markStepSeen('tour');
setIsTourOpen(true);
}
}, [currentStep, isTourOpen, activeFlow]);
useEffect(() => {
if (externalTourRequested) {
actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
markStepSeen('tour');
setIsTourOpen(true);
clearTourRequest();
}
}, [externalTourRequested, requestedTourType, actions, clearTourRequest]);
useEffect(() => {
if (!isTourOpen) removeAllGlows();
return () => removeAllGlows();
}, [isTourOpen]);
const finishTour = useCallback(() => {
setIsTourOpen(false);
if (runtimeState.tourType === 'admin') {
adminTourOrch.restoreAdminState();
} else {
tourOrch.restoreWorkbenchState();
}
markStepSeen('tour');
if (currentStep?.id === 'tour') actions.complete();
}, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]);
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
if (steps && tourCurrentStep === steps.length - 1) {
setIsOpen(false);
finishTour();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
}, [finishTour]);
const handleCloseTour = useCallback((args: CloseArgs) => {
args.setIsOpen(false);
finishTour();
}, [finishTour]);
const currentSlideDefinition = useMemo(() => {
if (!currentStep || currentStep.type !== 'modal-slide' || !currentStep.slideId) {
return null;
}
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
}, [currentStep]);
const currentSlideContent = useMemo(() => {
if (!currentSlideDefinition) return null;
return currentSlideDefinition.createSlide({
osLabel: osInfo.label,
osUrl: osInfo.url,
osOptions,
onDownloadUrlChange: setSelectedDownloadUrl,
selectedRole: runtimeState.selectedRole,
onRoleSelect: handleRoleSelect,
licenseNotice: runtimeState.licenseNotice,
loginEnabled: serverExperience.loginEnabled,
firstLoginUsername: runtimeState.firstLoginUsername,
onPasswordChanged: handlePasswordChanged,
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
});
}, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
const modalSlideCount = useMemo(() => {
return activeFlow.filter((step) => step.type === 'modal-slide').length;
}, [activeFlow]);
const currentModalSlideIndex = useMemo(() => {
if (!currentStep || currentStep.type !== 'modal-slide') return 0;
const modalSlides = activeFlow.filter((step) => step.type === 'modal-slide');
return modalSlides.findIndex((step) => step.id === currentStep.id);
}, [activeFlow, currentStep]);
if (onAuthRoute) {
return null;
}
if (showLicenseSlide) {
const slideDefinition = SLIDE_DEFINITIONS['server-license'];
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
const slideContent = slideDefinition.createSlide({
osLabel: '',
osUrl: '',
osOptions: [],
onDownloadUrlChange: () => {},
selectedRole: null,
onRoleSelect: () => {},
licenseNotice: effectiveLicenseNotice,
loginEnabled: serverExperience.loginEnabled,
});
return (
<OnboardingModalSlide
slideDefinition={slideDefinition}
slideContent={slideContent}
runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice }}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={closeLicenseSlide}
onAction={(action) => {
if (action === 'see-plans') {
closeLicenseSlide();
navigate('/settings/adminPlan');
} else {
closeLicenseSlide();
}
}}
/>
);
}
if (isLoading || !isActive || !currentStep) {
return (
<OnboardingTour
isOpen={isTourOpen}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
}
switch (currentStep.type) {
case 'tool-prompt':
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
case 'tour':
return (
<OnboardingTour
isOpen={true}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
case 'analytics-modal':
return <AdminAnalyticsChoiceModal opened={true} onClose={actions.complete} />;
case 'modal-slide':
if (!currentSlideDefinition || !currentSlideContent) return null;
return (
<OnboardingModalSlide
slideDefinition={currentSlideDefinition}
slideContent={currentSlideContent}
runtimeState={runtimeState}
modalSlideCount={modalSlideCount}
currentModalSlideIndex={currentModalSlideIndex}
onSkip={actions.skip}
onAction={handleButtonAction}
/>
);
default:
return null;
}
}
@@ -1,33 +1,44 @@
/**
* OnboardingModalSlide Component
*
* Renders a single modal slide in the onboarding flow.
* Handles the hero image, content, stepper, and button actions.
*/
import React from 'react';
import { Modal, Stack } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import LocalIcon from '@app/components/shared/LocalIcon';
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
import type { SlideConfig } from '@app/types/types';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
import { renderButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import type { InitialOnboardingModalProps } from '@app/components/onboarding/InitialOnboardingModal/types';
import { useInitialOnboardingState } from '@app/components/onboarding/InitialOnboardingModal/useInitialOnboardingState';
import { SlideButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
import LocalIcon from '@app/components/shared/LocalIcon';
import { BASE_PATH } from '@app/constants/app';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
const flow = useInitialOnboardingState(props);
interface OnboardingModalSlideProps {
slideDefinition: SlideDefinition;
slideContent: SlideConfig;
runtimeState: OnboardingRuntimeState;
modalSlideCount: number;
currentModalSlideIndex: number;
onSkip: () => void;
onAction: (action: ButtonAction) => void;
}
if (!flow) {
return null;
}
const {
state,
totalSteps,
currentSlide,
slideDefinition,
licenseNotice,
flowState,
closeAndMarkSeen,
handleButtonAction,
} = flow;
export default function OnboardingModalSlide({
slideDefinition,
slideContent,
runtimeState,
modalSlideCount,
currentModalSlideIndex,
onSkip,
onAction,
}: OnboardingModalSlideProps) {
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
@@ -48,6 +59,9 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
{slideDefinition.hero.type === 'shield' && (
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'lock' && (
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
{slideDefinition.hero.type === 'logo' && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
@@ -58,8 +72,8 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
return (
<Modal
opened={props.opened}
onClose={closeAndMarkSeen}
opened={true}
onClose={onSkip}
closeOnClickOutside={false}
centered
size="lg"
@@ -67,48 +81,48 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
body: { padding: 0, maxHeight: '90vh', overflow: 'hidden' },
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)', maxHeight: '90vh' },
}}
>
<Stack gap={0} className={styles.modalContent}>
<div className={styles.heroWrapper}>
<AnimatedSlideBackground
gradientStops={currentSlide.background.gradientStops}
circles={currentSlide.background.circles}
gradientStops={slideContent.background.gradientStops}
circles={slideContent.background.circles}
isActive
slideKey={currentSlide.key}
slideKey={slideContent.key}
/>
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
{renderHero()}
</div>
</div>
<div className={styles.modalBody}>
<div className={styles.modalBody} style={{ overflowY: 'auto', maxHeight: 'calc(90vh - 220px)' }}>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
key={`title-${slideContent.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{currentSlide.title}
{slideContent.title}
</div>
<div className={styles.bodyText}>
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{currentSlide.body}
<div key={`body-${slideContent.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{slideContent.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={totalSteps} activeStep={state.step} />
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
<div className={styles.buttonContainer}>
{renderButtons({
slideDefinition,
licenseNotice,
flowState,
onAction: handleButtonAction,
})}
<SlideButtons
slideDefinition={slideDefinition}
licenseNotice={runtimeState.licenseNotice}
flowState={{ selectedRole: runtimeState.selectedRole }}
onAction={onAction}
/>
</div>
</Stack>
</div>
@@ -1,231 +1,151 @@
import React, { useEffect, useMemo } from "react";
import { TourProvider, type StepType } from '@reactour/tour';
import { useTranslation } from 'react-i18next';
/**
* OnboardingTour Component
*
* Reusable tour wrapper that encapsulates all Reactour configuration.
* Used by the main Onboarding component for both the 'tour' step and
* when the tour is open but onboarding is inactive.
*/
import React from 'react';
import { TourProvider, useTour, type StepType } from '@reactour/tour';
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 '@app/components/onboarding/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 { useOnboardingFlow } from '@app/components/onboarding/hooks/useOnboardingFlow';
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
import TourContent from '@app/components/onboarding/TourContent';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import '@app/components/onboarding/OnboardingTour.css';
import i18n from "@app/i18n";
import CheckIcon from '@mui/icons-material/Check';
import type { TFunction } from 'i18next';
import i18n from '@app/i18n';
export default function OnboardingTour() {
const { t } = useTranslation();
const flow = useOnboardingFlow();
const { openFilesModal, closeFilesModal } = useFilesModalContext();
const {
saveWorkbenchState,
restoreWorkbenchState,
backToAllTools,
selectCropTool,
loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles,
selectFirstFile,
pinFile,
modifyCropSettings,
executeTool,
} = useTourOrchestration();
const {
saveAdminState,
restoreAdminState,
openConfigModal,
navigateToSection,
scrollNavToSection,
} = useAdminTourOrchestration();
/**
* TourContent - Controls the tour visibility
* Syncs the forceOpen prop with the reactour tour state.
*/
function TourContent({ forceOpen = false }: { forceOpen?: boolean }) {
const { setIsOpen, setCurrentStep } = useTour();
const previousIsOpenRef = React.useRef(forceOpen);
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
React.useEffect(() => {
const wasClosedNowOpen = !previousIsOpenRef.current && forceOpen;
previousIsOpenRef.current = forceOpen;
useEffect(() => {
if (!flow.isTourOpen) {
removeAllGlows();
if (wasClosedNowOpen) {
setCurrentStep(0);
}
return () => removeAllGlows();
}, [flow.isTourOpen]);
setIsOpen(forceOpen);
}, [forceOpen, setIsOpen, setCurrentStep]);
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,
],
);
return null;
}
const adminStepsConfig = useMemo(
() =>
createAdminStepsConfig({
t,
actions: {
saveAdminState,
openConfigModal,
navigateToSection,
scrollNavToSection,
},
}),
[navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
);
interface AdvanceArgs {
setCurrentStep: (value: number | ((prev: number) => number)) => void;
currentStep: number;
steps?: StepType[];
setIsOpen: (value: boolean) => void;
}
const steps = useMemo<StepType[]>(() => {
const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
return Object.values(config);
}, [adminStepsConfig, flow.tourType, userStepsConfig]);
interface CloseArgs {
setIsOpen: (value: boolean) => void;
}
const advanceTour = ({
setCurrentStep,
currentStep,
steps,
setIsOpen,
}: {
setCurrentStep: (value: number | ((prev: number) => number)) => void;
currentStep: number;
steps?: StepType[];
setIsOpen: (value: boolean) => void;
}) => {
if (steps && currentStep === steps.length - 1) {
setIsOpen(false);
if (flow.tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
flow.handleTourCompletion();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
};
interface OnboardingTourProps {
tourSteps: StepType[];
tourType: 'admin' | 'tools';
isRTL: boolean;
t: TFunction;
isOpen: boolean;
onAdvance: (args: AdvanceArgs) => void;
onClose: (args: CloseArgs) => void;
}
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
setIsOpen(false);
if (flow.tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
flow.handleTourCompletion();
};
export default function OnboardingTour({
tourSteps,
tourType,
isRTL,
t,
isOpen,
onAdvance,
onClose,
}: OnboardingTourProps) {
if (!isOpen) return null;
return (
<>
<InitialOnboardingModal {...flow.initialModalProps} />
<ToolPanelModePrompt onComplete={flow.handleToolPromptComplete} />
<TourProvider
key={`${flow.tourType}-${i18n.language}`}
steps={steps}
maskClassName={flow.maskClassName}
onClickClose={handleCloseTour}
onClickMask={advanceTour}
onClickHighlighted={(e, clickProps) => {
e.stopPropagation();
advanceTour(clickProps);
}}
keyboardHandler={(e, clickProps, status) => {
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
e.preventDefault();
advanceTour(clickProps);
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
e.preventDefault();
handleCloseTour(clickProps);
}
}}
rtl={isRTL}
styles={{
popover: (base) => ({
...base,
backgroundColor: 'var(--mantine-color-body)',
color: 'var(--mantine-color-text)',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '400px',
}),
maskArea: (base) => ({
...base,
rx: 8,
}),
badge: (base) => ({
...base,
backgroundColor: 'var(--mantine-primary-color-filled)',
}),
controls: (base) => ({
...base,
justifyContent: 'center',
}),
}}
highlightedMaskClassName="tour-highlight-glow"
showNavigation={true}
showBadge={false}
showCloseButton={true}
disableInteraction={true}
disableDotsNavigation={false}
prevButton={() => null}
nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => {
const isLast = currentStep === stepsLength - 1;
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
return (
<ActionIcon
onClick={() => advanceTour({ setCurrentStep, currentStep, steps, setIsOpen })}
variant="subtle"
size="lg"
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
>
{isLast ? <CheckIcon /> : <ArrowIcon />}
</ActionIcon>
);
}}
components={{
Close: ({ onClick }) => (
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
),
Content: ({ content }: { content: string }) => (
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
),
}}
>
<TourContent />
</TourProvider>
<ServerLicenseModal {...flow.serverLicenseModalProps} />
</>
<TourProvider
key={`${tourType}-${i18n.language}`}
steps={tourSteps}
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
onClickClose={onClose}
onClickMask={onAdvance}
onClickHighlighted={(e, clickProps) => {
e.stopPropagation();
onAdvance(clickProps);
}}
keyboardHandler={(e, clickProps, status) => {
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
e.preventDefault();
onAdvance(clickProps);
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
e.preventDefault();
onClose(clickProps);
}
}}
rtl={isRTL}
styles={{
popover: (base) => ({
...base,
backgroundColor: 'var(--mantine-color-body)',
color: 'var(--mantine-color-text)',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '400px',
}),
maskArea: (base) => ({
...base,
rx: 8,
}),
badge: (base) => ({
...base,
backgroundColor: 'var(--mantine-primary-color-filled)',
}),
controls: (base) => ({
...base,
justifyContent: 'center',
}),
}}
highlightedMaskClassName="tour-highlight-glow"
showNavigation={true}
showBadge={false}
showCloseButton={true}
disableInteraction={true}
disableDotsNavigation={false}
prevButton={() => null}
nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => {
const isLast = tourCurrentStep === stepsLength - 1;
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
return (
<ActionIcon
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
variant="subtle"
size="lg"
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
>
{isLast ? <CheckIcon /> : <ArrowIcon />}
</ActionIcon>
);
}}
components={{
Close: ({ onClick }) => (
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
),
Content: ({ content }: { content: string }) => (
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
),
}}
>
<TourContent forceOpen={true} />
</TourProvider>
);
}
export type { AdvanceArgs, CloseArgs };
@@ -1,120 +0,0 @@
import React from 'react';
import { Modal, Button, Group, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import { LicenseNotice } from '@app/types/types';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import { BASE_PATH } from '@app/constants/app';
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 { t } = useTranslation();
const slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]);
const primaryLabel = licenseNotice.isOverLimit
? t('onboarding.serverLicense.upgrade', 'Upgrade now →')
: t('onboarding.serverLicense.seePlans', 'See Plans →');
const secondaryLabel = t('onboarding.serverLicense.skip', 'Skip for now');
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.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
</div>
</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}>
{secondaryLabel}
</Button>
<Button styles={primaryStyles} onClick={handleSeePlans}>
{primaryLabel}
</Button>
</Group>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -1,22 +0,0 @@
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;
}
@@ -1,82 +0,0 @@
import { Modal, Title, Text, Button, Stack, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface TourWelcomeModalProps {
opened: boolean;
onStartTour: () => void;
onMaybeLater: () => void;
onDontShowAgain: () => void;
}
export default function TourWelcomeModal({
opened,
onStartTour,
onMaybeLater,
onDontShowAgain,
}: TourWelcomeModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={opened}
onClose={onMaybeLater}
centered
size="md"
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
>
<Stack gap="lg">
<Stack gap="xs">
<Title order={2}>
{t('onboarding.welcomeModal.title', 'Welcome to Stirling PDF!')}
</Title>
<Text size="md" c="dimmed">
{t('onboarding.welcomeModal.description',
"Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
)}
</Text>
<Text
size="md"
c="dimmed"
dangerouslySetInnerHTML={{
__html: t('onboarding.welcomeModal.helpHint',
'You can always access this tour later from the <strong>Help</strong> button in the bottom left.'
)
}}
/>
</Stack>
<Stack gap="sm">
<Button
onClick={onStartTour}
size="md"
variant="filled"
fullWidth
>
{t('onboarding.welcomeModal.startTour', 'Start Tour')}
</Button>
<Group grow>
<Button
onClick={onMaybeLater}
size="md"
variant="light"
>
{t('onboarding.welcomeModal.maybeLater', 'Maybe Later')}
</Button>
<Button
onClick={onDontShowAgain}
size="md"
variant="light"
>
{t('onboarding.welcomeModal.dontShowAgain', "Don't Show Again")}
</Button>
</Group>
</Stack>
</Stack>
</Modal>
);
}
@@ -1,8 +1,19 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import { AdminTourStep } from '@app/components/onboarding/tourSteps';
import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
export enum AdminTourStep {
WELCOME,
CONFIG_BUTTON,
SETTINGS_OVERVIEW,
TEAMS_AND_USERS,
SYSTEM_CUSTOMIZATION,
DATABASE_SECTION,
CONNECTIONS_SECTION,
ADMIN_TOOLS,
WRAP_UP,
}
interface AdminStepActions {
saveAdminState: () => void;
openConfigModal: () => void;
@@ -1,304 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import type { LicenseNotice } from '@app/types/types';
import { useNavigate, useLocation } from 'react-router-dom';
import {
ONBOARDING_SESSION_BLOCK_KEY,
ONBOARDING_SESSION_EVENT,
SERVER_LICENSE_REQUEST_EVENT,
type ServerLicenseRequestPayload,
} from '@app/constants/events';
import { useServerExperience } from '@app/hooks/useServerExperience';
// Auth routes where onboarding should NOT show
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
// Check if user has an auth token (to avoid flash before redirect)
function hasAuthToken(): boolean {
if (typeof window === 'undefined') return false;
return !!localStorage.getItem('stirling_jwt');
}
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;
onSeePlans: () => void;
}
export function useOnboardingFlow() {
const { preferences, updatePreference } = usePreferences();
const { config, loading: configLoading } = useAppConfig();
const { completeTour, tourType, isOpen } = useOnboarding();
const location = useLocation();
// Check if we're on an auth route (login, signup, etc.)
const isOnAuthRoute = AUTH_ROUTES.some(route => location.pathname.startsWith(route));
// Check if login is enabled but user doesn't have a token
// This prevents a flash of the modal before redirect to /login
const loginEnabled = config?.enableLogin === true;
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
// Don't show intro onboarding:
// 1. On explicit auth routes (/login, /signup, etc.)
// 2. While config is still loading
// 3. When login is enabled but user isn't authenticated (would redirect to /login)
// This ensures:
// - If login is enabled: user must be logged in before seeing onboarding
// - If login is disabled: homepage must have rendered first
const shouldShowIntro = !preferences.hasSeenIntroOnboarding
&& !isOnAuthRoute
&& !configLoading
&& !isUnauthenticatedWithLoginEnabled;
const isAdminUser = !!config?.isAdmin;
const { hasPaidLicense } = useServerExperience();
const [licenseNotice, setLicenseNotice] = useState<LicenseNotice>({
totalUsers: null,
freeTierLimit: 5,
isOverLimit: false,
requiresLicense: 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 navigate = useNavigate();
const onboardingSessionMarkedRef = 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 requestServerLicense = useCallback(
({
deferUntilTourComplete = false,
selfReportedAdmin = false,
}: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean } = {}) => {
const qualifies = isAdminUser || selfReportedAdmin;
if (!qualifies) {
return;
}
if (hasPaidLicense || !licenseNotice.requiresLicense) {
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;
});
},
[hasPaidLicense, isAdminUser, licenseNotice.requiresLicense],
);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleServerLicenseRequested = (event: Event) => {
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
if (detail?.licenseNotice) {
setLicenseNotice((prev) => ({
...prev,
...detail.licenseNotice,
totalUsers:
detail.licenseNotice?.totalUsers ?? prev.totalUsers,
freeTierLimit:
detail.licenseNotice?.freeTierLimit ?? prev.freeTierLimit,
isOverLimit:
detail.licenseNotice?.isOverLimit ?? prev.isOverLimit,
requiresLicense:
detail.licenseNotice?.requiresLicense ?? prev.requiresLicense,
}));
}
requestServerLicense({
deferUntilTourComplete: detail?.deferUntilTourComplete ?? false,
selfReportedAdmin: detail?.selfReportedAdmin ?? false,
});
};
window.addEventListener(
SERVER_LICENSE_REQUEST_EVENT,
handleServerLicenseRequested as EventListener,
);
return () => {
window.removeEventListener(
SERVER_LICENSE_REQUEST_EVENT,
handleServerLicenseRequested as EventListener,
);
};
}, [requestServerLicense]);
useEffect(() => {
const isEligibleAdmin =
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
if (
introWasOpenRef.current &&
!shouldShowIntro &&
isEligibleAdmin &&
toolPromptCompleted &&
!hasShownServerLicense &&
licenseNotice.requiresLicense &&
serverLicenseIntent === 'idle'
) {
if (!serverLicenseSource) {
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
}
setServerLicenseIntent('pending');
}
introWasOpenRef.current = shouldShowIntro;
}, [
hasShownServerLicense,
isAdminUser,
serverLicenseIntent,
shouldShowIntro,
serverLicenseSource,
toolPromptCompleted,
licenseNotice.requiresLicense,
]);
useEffect(() => {
const isEligibleAdmin =
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
if (
serverLicenseIntent !== 'idle' &&
!shouldShowIntro &&
!isOpen &&
!isServerLicenseOpen &&
isEligibleAdmin &&
toolPromptCompleted &&
licenseNotice.requiresLicense
) {
setIsServerLicenseOpen(true);
setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle');
}
}, [
isAdminUser,
isOpen,
isServerLicenseOpen,
serverLicenseIntent,
shouldShowIntro,
serverLicenseSource,
toolPromptCompleted,
licenseNotice.requiresLicense,
]);
const handleServerLicenseClose = useCallback(() => {
setIsServerLicenseOpen(false);
setHasShownServerLicense(true);
setServerLicenseIntent('idle');
setServerLicenseSource(null);
}, []);
useEffect(() => {
if (onboardingSessionMarkedRef.current) {
return;
}
if (typeof window === 'undefined') {
return;
}
if (shouldShowIntro || isOpen) {
onboardingSessionMarkedRef.current = true;
window.sessionStorage.setItem(ONBOARDING_SESSION_BLOCK_KEY, 'true');
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
}
}, [isOpen, shouldShowIntro]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
if (!shouldShowIntro && !isOpen) {
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
}
}, [isOpen, shouldShowIntro]);
const handleServerLicenseSeePlans = useCallback(() => {
handleServerLicenseClose();
navigate('/settings/adminPlan');
}, [handleServerLicenseClose, navigate]);
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'));
}
}, [
completeTour,
isAdminUser,
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,
onSeePlans: handleServerLicenseSeePlans,
}),
[handleServerLicenseClose, handleServerLicenseSeePlans, isServerLicenseOpen, licenseNotice],
);
return {
tourType,
isTourOpen: isOpen,
maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined,
initialModalProps,
handleToolPromptComplete,
serverLicenseModalProps,
handleTourCompletion,
};
}
@@ -3,16 +3,18 @@ import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstal
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 FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types';
export type SlideId =
| 'first-login'
| 'welcome'
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'server-license';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
export type ButtonAction =
| 'next'
@@ -46,6 +48,10 @@ export interface SlideFactoryParams {
onRoleSelect: (role: 'admin' | 'user' | null) => void;
licenseNotice?: LicenseNotice;
loginEnabled?: boolean;
// First login params
firstLoginUsername?: string;
onPasswordChanged?: () => void;
usingDefaultCredentials?: boolean;
}
export interface HeroDefinition {
@@ -71,6 +77,17 @@ export interface SlideDefinition {
}
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'first-login': {
id: 'first-login',
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
FirstLoginSlide({
username: firstLoginUsername || '',
onPasswordChanged: onPasswordChanged || (() => {}),
usingDefaultCredentials: usingDefaultCredentials || false,
}),
hero: { type: 'lock' },
buttons: [], // Form has its own submit button
},
'welcome': {
id: 'welcome',
createSlide: () => WelcomeSlide(),
@@ -197,11 +214,3 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
},
};
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[],
};
@@ -0,0 +1,127 @@
export type OnboardingStepId =
| 'first-login'
| 'welcome'
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'tool-layout'
| 'tour'
| 'server-license'
| 'analytics-choice';
export type OnboardingStepType =
| 'modal-slide'
| 'tool-prompt'
| 'tour'
| 'analytics-modal';
export interface OnboardingRuntimeState {
selectedRole: 'admin' | 'user' | null;
tourRequested: boolean;
tourType: 'admin' | 'tools';
isDesktopApp: boolean;
analyticsNotConfigured: boolean;
analyticsEnabled: boolean;
licenseNotice: {
totalUsers: number | null;
freeTierLimit: number;
isOverLimit: boolean;
requiresLicense: boolean;
};
requiresPasswordChange: boolean;
firstLoginUsername: string;
usingDefaultCredentials: boolean;
}
export interface OnboardingConditionContext extends OnboardingRuntimeState {
loginEnabled: boolean;
effectiveIsAdmin: boolean;
}
export interface OnboardingStep {
id: OnboardingStepId;
type: OnboardingStepType;
condition: (ctx: OnboardingConditionContext) => boolean;
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license';
}
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
selectedRole: null,
tourRequested: false,
tourType: 'tools',
isDesktopApp: false,
analyticsNotConfigured: false,
analyticsEnabled: false,
licenseNotice: {
totalUsers: null,
freeTierLimit: 5,
isOverLimit: false,
requiresLicense: false,
},
requiresPasswordChange: false,
firstLoginUsername: '',
usingDefaultCredentials: false,
};
export const ONBOARDING_STEPS: OnboardingStep[] = [
{
id: 'first-login',
type: 'modal-slide',
slideId: 'first-login',
condition: (ctx) => ctx.requiresPasswordChange,
},
{
id: 'welcome',
type: 'modal-slide',
slideId: 'welcome',
condition: () => true,
},
{
id: 'desktop-install',
type: 'modal-slide',
slideId: 'desktop-install',
condition: (ctx) => !ctx.isDesktopApp,
},
{
id: 'security-check',
type: 'modal-slide',
slideId: 'security-check',
condition: (ctx) => !ctx.loginEnabled && !ctx.isDesktopApp,
},
{
id: 'admin-overview',
type: 'modal-slide',
slideId: 'admin-overview',
condition: (ctx) => ctx.effectiveIsAdmin,
},
{
id: 'tool-layout',
type: 'tool-prompt',
condition: () => true,
},
{
id: 'tour',
type: 'tour',
condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin,
},
{
id: 'server-license',
type: 'modal-slide',
slideId: 'server-license',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
},
{
id: 'analytics-choice',
type: 'analytics-modal',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.analyticsNotConfigured,
},
];
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
return ONBOARDING_STEPS.find((step) => step.id === id);
}
export function getStepIndex(id: OnboardingStepId): number {
return ONBOARDING_STEPS.findIndex((step) => step.id === id);
}
@@ -0,0 +1,97 @@
import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
const STORAGE_PREFIX = 'onboarding';
export function getStorageKey(stepId: OnboardingStepId): string {
return `${STORAGE_PREFIX}::${stepId}`;
}
export function hasSeenStep(stepId: OnboardingStepId): boolean {
if (typeof window === 'undefined') return false;
try {
return localStorage.getItem(getStorageKey(stepId)) === 'true';
} catch {
return false;
}
}
export function markStepSeen(stepId: OnboardingStepId): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(getStorageKey(stepId), 'true');
} catch (error) {
console.error('[onboardingStorage] Error marking step as seen:', error);
}
}
export function resetStepSeen(stepId: OnboardingStepId): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(getStorageKey(stepId));
} catch (error) {
console.error('[onboardingStorage] Error resetting step seen:', error);
}
}
export function resetAllOnboardingProgress(): void {
if (typeof window === 'undefined') return;
try {
const prefix = `${STORAGE_PREFIX}::`;
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(prefix)) keysToRemove.push(key);
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch (error) {
console.error('[onboardingStorage] Error resetting all onboarding progress:', error);
}
}
export function getOnboardingStorageState(): Record<string, boolean> {
const state: Record<string, boolean> = {};
ONBOARDING_STEPS.forEach((step) => {
state[step.id] = hasSeenStep(step.id);
});
return state;
}
export function migrateFromLegacyPreferences(): void {
if (typeof window === 'undefined') return;
const migrationKey = `${STORAGE_PREFIX}::migrated`;
try {
// Skip if already migrated
if (localStorage.getItem(migrationKey) === 'true') return;
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
if (prefsRaw) {
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
// Migrate based on legacy flags
if (prefs.hasSeenIntroOnboarding === true) {
markStepSeen('welcome');
markStepSeen('desktop-install');
markStepSeen('security-check');
markStepSeen('admin-overview');
}
if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) {
markStepSeen('tool-layout');
}
if (prefs.hasCompletedOnboarding === true) {
markStepSeen('tour');
markStepSeen('analytics-choice');
markStepSeen('server-license');
}
}
// Mark migration complete
localStorage.setItem(migrationKey, 'true');
} catch {
// If migration fails, onboarding will show again - safer than hiding it
}
}
@@ -0,0 +1,353 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useServerExperience } from '@app/hooks/useServerExperience';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import {
ONBOARDING_STEPS,
type OnboardingStepId,
type OnboardingStep,
type OnboardingRuntimeState,
type OnboardingConditionContext,
DEFAULT_RUNTIME_STATE,
} from '@app/components/onboarding/orchestrator/onboardingConfig';
import {
hasSeenStep,
markStepSeen,
migrateFromLegacyPreferences,
} from '@app/components/onboarding/orchestrator/onboardingStorage';
import { accountService } from '@app/services/accountService';
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
const SESSION_TOUR_REQUESTED = 'onboarding::session::tour-requested';
const SESSION_TOUR_TYPE = 'onboarding::session::tour-type';
const SESSION_SELECTED_ROLE = 'onboarding::session::selected-role';
// Check if user has an auth token (to avoid flash before redirect)
function hasAuthToken(): boolean {
if (typeof window === 'undefined') return false;
return !!localStorage.getItem('stirling_jwt');
}
// Get initial runtime state from session storage (survives remounts)
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
if (typeof window === 'undefined') {
return baseState;
}
try {
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools';
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
return {
...baseState,
tourRequested,
tourType,
selectedRole,
};
} catch {
return baseState;
}
}
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
if (typeof window === 'undefined') return;
try {
if (state.tourRequested !== undefined) {
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
}
if (state.tourType !== undefined) {
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
}
if (state.selectedRole !== undefined) {
if (state.selectedRole) {
sessionStorage.setItem(SESSION_SELECTED_ROLE, state.selectedRole);
} else {
sessionStorage.removeItem(SESSION_SELECTED_ROLE);
}
}
} catch (error) {
console.error('[useOnboardingOrchestrator] Error persisting runtime state:', error);
}
}
function clearRuntimeStateSession(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
sessionStorage.removeItem(SESSION_TOUR_TYPE);
sessionStorage.removeItem(SESSION_SELECTED_ROLE);
} catch {
// Ignore errors
}
}
export interface OnboardingOrchestratorState {
/** Whether onboarding is currently active */
isActive: boolean;
/** The current step being shown (null if no step is active) */
currentStep: OnboardingStep | null;
/** Index of current step in the active flow (for display purposes) */
currentStepIndex: number;
/** Total number of steps in the active flow */
totalSteps: number;
/** Runtime state that affects conditions */
runtimeState: OnboardingRuntimeState;
/** All steps that will be shown in this flow (filtered by conditions) */
activeFlow: OnboardingStep[];
/** Whether all steps have been seen */
isComplete: boolean;
/** Whether we're still initializing */
isLoading: boolean;
}
export interface OnboardingOrchestratorActions {
/** Move to the next step */
next: () => void;
/** Move to the previous step */
prev: () => void;
/** Skip the current step (marks as seen but doesn't complete) */
skip: () => void;
/** Mark current step as seen and move to next */
complete: () => void;
/** Update runtime state (e.g., after role selection) */
updateRuntimeState: (updates: Partial<OnboardingRuntimeState>) => void;
/** Force re-evaluation of the flow (used when conditions change) */
refreshFlow: () => void;
/** Manually start a specific step (for external triggers) */
startStep: (stepId: OnboardingStepId) => void;
/** Close/pause onboarding (can be resumed later) */
pause: () => void;
/** Resume onboarding from where it was paused */
resume: () => void;
}
export interface UseOnboardingOrchestratorResult {
state: OnboardingOrchestratorState;
actions: OnboardingOrchestratorActions;
}
export interface UseOnboardingOrchestratorOptions {
/** Override the default runtime state (used by desktop to set isDesktopApp: true) */
defaultRuntimeState?: OnboardingRuntimeState;
}
export function useOnboardingOrchestrator(
options?: UseOnboardingOrchestratorOptions
): UseOnboardingOrchestratorResult {
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
const serverExperience = useServerExperience();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
getInitialRuntimeState(defaultState)
);
const [isPaused, setIsPaused] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
const migrationDone = useRef(false);
const initialIndexSet = useRef(false);
useEffect(() => {
if (!migrationDone.current) {
migrateFromLegacyPreferences();
migrationDone.current = true;
}
}, []);
useEffect(() => {
setRuntimeState((prev) => ({
...prev,
analyticsEnabled: config?.enableAnalytics === true,
analyticsNotConfigured: config?.enableAnalytics == null,
licenseNotice: {
totalUsers: serverExperience.totalUsers,
freeTierLimit: serverExperience.freeTierLimit,
isOverLimit: serverExperience.overFreeTierLimit ?? false,
requiresLicense: !serverExperience.hasPaidLicense && (
serverExperience.overFreeTierLimit === true ||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)
),
},
}));
}, [
config?.enableAnalytics,
serverExperience.totalUsers,
serverExperience.freeTierLimit,
serverExperience.overFreeTierLimit,
serverExperience.hasPaidLicense,
serverExperience.effectiveIsAdmin,
serverExperience.userCountResolved,
]);
useEffect(() => {
const checkFirstLogin = async () => {
if (config?.enableLogin !== true || !hasAuthToken()) return;
try {
const [accountData, loginPageData] = await Promise.all([
accountService.getAccountData(),
accountService.getLoginPageData(),
]);
setRuntimeState((prev) => ({
...prev,
requiresPasswordChange: accountData.changeCredsFlag,
firstLoginUsername: accountData.username,
usingDefaultCredentials: loginPageData.showDefaultCredentials,
}));
} catch {
// Account endpoint failed - user not logged in or security disabled
}
};
if (!configLoading) {
checkFirstLogin();
}
}, [config?.enableLogin, configLoading]);
const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
const loginEnabled = config?.enableLogin === true;
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
const shouldBlockOnboarding = isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
...serverExperience,
...runtimeState,
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
}), [serverExperience, runtimeState]);
const activeFlow = useMemo(() => {
return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
}, [conditionContext]);
// Wait for config AND admin status before calculating initial step
const adminStatusResolved = !configLoading && (
config?.enableLogin === false ||
config?.enableLogin === undefined ||
config?.isAdmin !== undefined
);
useEffect(() => {
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
let firstUnseenIndex = -1;
for (let i = 0; i < activeFlow.length; i++) {
if (!hasSeenStep(activeFlow[i].id)) {
firstUnseenIndex = i;
break;
}
}
if (firstUnseenIndex === -1) {
setCurrentStepIndex(activeFlow.length);
initialIndexSet.current = true;
} else if (!initialIndexSet.current) {
setCurrentStepIndex(firstUnseenIndex);
initialIndexSet.current = true;
}
}, [activeFlow, configLoading, adminStatusResolved]);
const totalSteps = activeFlow.length;
const allStepsAlreadySeen = useMemo(() => {
if (activeFlow.length === 0) return false;
return activeFlow.every(step => hasSeenStep(step.id));
}, [activeFlow]);
const isComplete = isInitialized && initialIndexSet.current &&
(currentStepIndex >= totalSteps || allStepsAlreadySeen);
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen)
? activeFlow[currentStepIndex]
: null;
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
useEffect(() => {
if (!configLoading && !isInitialized) setIsInitialized(true);
}, [configLoading, isInitialized]);
useEffect(() => {
if (isComplete) clearRuntimeStateSession();
}, [isComplete]);
const next = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
const prev = useCallback(() => {
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
}, []);
const skip = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
const complete = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
useEffect(() => {
if (!currentStep || isLoading) {
return;
}
if (hasSeenStep(currentStep.id)) {
complete();
}
}, [currentStep, isLoading, complete]);
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);
setRuntimeState((prev) => ({ ...prev, ...updates }));
}, []);
const refreshFlow = useCallback(() => {
initialIndexSet.current = false;
setCurrentStepIndex(-1);
}, []);
const startStep = useCallback((stepId: OnboardingStepId) => {
const index = activeFlow.findIndex((step) => step.id === stepId);
if (index !== -1) {
setCurrentStepIndex(index);
setIsPaused(false);
}
}, [activeFlow]);
const pause = useCallback(() => setIsPaused(true), []);
const resume = useCallback(() => setIsPaused(false), []);
const state: OnboardingOrchestratorState = {
isActive,
currentStep,
currentStepIndex,
totalSteps,
runtimeState,
activeFlow,
isComplete,
isLoading,
};
const actions: OnboardingOrchestratorActions = {
next,
prev,
skip,
complete,
updateRuntimeState,
refreshFlow,
startStep,
pause,
resume,
};
return { state, actions };
}
@@ -0,0 +1,184 @@
import React, { useState } from 'react';
import { Stack, PasswordInput, Button, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import LocalIcon from '@app/components/shared/LocalIcon';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import { accountService } from '@app/services/accountService';
import { alert as showToast } from '@app/components/toast';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
interface FirstLoginSlideProps {
username: string;
onPasswordChanged: () => void;
usingDefaultCredentials?: boolean;
}
const DEFAULT_PASSWORD = 'stirling';
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) {
const { t } = useTranslation();
// If using default credentials, pre-fill with "stirling" - user won't see this field
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : '');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async () => {
// Validation
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
setError(t('firstLogin.allFieldsRequired', 'All fields are required'));
return;
}
if (newPassword !== confirmPassword) {
setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match'));
return;
}
if (newPassword.length < 8) {
setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters'));
return;
}
if (newPassword === currentPassword) {
setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password'));
return;
}
try {
setLoading(true);
setError('');
await accountService.changePasswordOnLogin(currentPassword, newPassword);
showToast({
alertType: 'success',
title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.')
});
// Clear form
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
// Wait a moment for the user to see the success message
setTimeout(() => {
onPasswordChanged();
}, 1500);
} catch (err) {
console.error('Failed to change password:', err);
// Extract error message from axios response if available
const axiosError = err as { response?: { data?: { message?: string } } };
setError(
axiosError.response?.data?.message ||
t('firstLogin.passwordChangeFailed', 'Failed to change password. Please check your current password.')
);
} finally {
setLoading(false);
}
};
return (
<div className={styles.securitySlideContent}>
<div className={styles.securityCard}>
<Stack gap="md">
<div className={styles.securityAlertRow}>
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: '#3B82F6', flexShrink: 0 }} />
<span>
{t(
'firstLogin.welcomeMessage',
'For security reasons, you must change your password on your first login.'
)}
</span>
</div>
<Text size="sm" fw={500}>
{t('firstLogin.loggedInAs', 'Logged in as')}: <strong>{username}</strong>
</Text>
{error && (
<Alert
icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />}
color="red"
variant="light"
>
{error}
</Alert>
)}
{/* Only show current password field if not using default credentials */}
{!usingDefaultCredentials && (
<PasswordInput
label={t('firstLogin.currentPassword', 'Current Password')}
placeholder={t('firstLogin.enterCurrentPassword', 'Enter your current password')}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
styles={{
input: { height: 44 },
}}
/>
)}
<PasswordInput
label={t('firstLogin.newPassword', 'New Password')}
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
required
styles={{
input: { height: 44 },
}}
/>
<PasswordInput
label={t('firstLogin.confirmPassword', 'Confirm New Password')}
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
styles={{
input: { height: 44 },
}}
/>
<Button
fullWidth
onClick={handleSubmit}
loading={loading}
disabled={!newPassword || !confirmPassword}
size="md"
mt="xs"
>
{t('firstLogin.changePassword', 'Change Password')}
</Button>
</Stack>
</div>
</div>
);
}
export default function FirstLoginSlide({
username,
onPasswordChanged,
usingDefaultCredentials = false,
}: FirstLoginSlideProps): SlideConfig {
return {
key: 'first-login',
title: 'Set Your Password',
body: (
<FirstLoginForm
username={username}
onPasswordChanged={onPasswordChanged}
usingDefaultCredentials={usingDefaultCredentials}
/>
),
background: {
gradientStops: ['#059669', '#0891B2'], // Green to teal - security/trust colors
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,33 +0,0 @@
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,
}
@@ -0,0 +1,79 @@
/**
* useOnboardingDownload Hook
*
* Encapsulates OS detection and download URL logic for the desktop install slide.
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useOs } from '@app/hooks/useOs';
import { DOWNLOAD_URLS } from '@app/constants/downloads';
interface OsInfo {
label: string;
url: string;
}
interface OsOption {
label: string;
url: string;
value: string;
}
interface UseOnboardingDownloadResult {
osInfo: OsInfo;
osOptions: OsOption[];
selectedDownloadUrl: string;
setSelectedDownloadUrl: (url: string) => void;
handleDownloadSelected: () => void;
}
export function useOnboardingDownload(): UseOnboardingDownloadResult {
const osType = useOs();
const [selectedDownloadUrl, setSelectedDownloadUrl] = useState<string>('');
const osInfo = useMemo<OsInfo>(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
case 'mac-apple':
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
case 'mac-intel':
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
default:
return { label: '', url: '' };
}
}, [osType]);
const osOptions = useMemo<OsOption[]>(() => [
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
].filter((opt) => opt.url), []);
// Initialize selected URL from detected OS
useEffect(() => {
if (!selectedDownloadUrl && osInfo.url) {
setSelectedDownloadUrl(osInfo.url);
}
}, [osInfo.url, selectedDownloadUrl]);
const handleDownloadSelected = useCallback(() => {
const downloadUrl = selectedDownloadUrl || osInfo.url;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener');
}
}, [selectedDownloadUrl, osInfo.url]);
return {
osInfo,
osOptions,
selectedDownloadUrl,
setSelectedDownloadUrl,
handleDownloadSelected,
};
}
@@ -0,0 +1,74 @@
import { useEffect, useCallback, useState } from 'react';
import {
SERVER_LICENSE_REQUEST_EVENT,
START_TOUR_EVENT,
type ServerLicenseRequestPayload,
type TourType,
type StartTourPayload,
} from '@app/constants/events';
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
export function useServerLicenseRequest(): {
showLicenseSlide: boolean;
licenseNotice: OnboardingRuntimeState['licenseNotice'] | null;
closeLicenseSlide: () => void;
} {
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState['licenseNotice'] | null>(null);
useEffect(() => {
if (typeof window === 'undefined') return;
const handleLicenseRequest = (event: Event) => {
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
if (detail?.licenseNotice) {
setLicenseNotice({
totalUsers: detail.licenseNotice.totalUsers ?? null,
freeTierLimit: detail.licenseNotice.freeTierLimit ?? 5,
isOverLimit: detail.licenseNotice.isOverLimit ?? false,
requiresLicense: true,
});
}
setShowLicenseSlide(true);
};
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
return () => window.removeEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
}, []);
const closeLicenseSlide = useCallback(() => {
setShowLicenseSlide(false);
}, []);
return { showLicenseSlide, licenseNotice, closeLicenseSlide };
}
export function useTourRequest(): {
tourRequested: boolean;
requestedTourType: TourType;
clearTourRequest: () => void;
} {
const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
useEffect(() => {
if (typeof window === 'undefined') return;
const handleTourRequest = (event: Event) => {
const { detail } = event as CustomEvent<StartTourPayload>;
setRequestedTourType(detail?.tourType ?? 'tools');
setTourRequested(true);
};
window.addEventListener(START_TOUR_EVENT, handleTourRequest);
return () => window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
}, []);
const clearTourRequest = useCallback(() => {
setTourRequested(false);
}, []);
return { tourRequested, requestedTourType, clearTourRequest };
}
@@ -1,6 +1,26 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import { TourStep } from '@app/components/onboarding/tourSteps';
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,
}
interface UserStepActions {
saveWorkbenchState: () => void;