From d74856f67584da3711c2440c2033f96e36db57d0 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Tue, 25 Nov 2025 19:56:09 +0000 Subject: [PATCH] Remove "Download for Desktop" and "Security Check" slides from desktop app onboarding (#5012) # Description of Changes - Title^ + I changed the text for set as default to make it more visible --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Co-authored-by: Connor Yoh --- .../onboarding/ServerLicenseModal.tsx | 6 +- .../onboarding/onboardingFlowConfig.ts | 2 +- .../src/core/components/shared/InfoBanner.tsx | 5 + .../useInitialOnboardingState.ts | 390 ++++++++++++++++++ .../onboarding/onboardingFlowConfig.ts | 178 ++++++++ 5 files changed, 578 insertions(+), 3 deletions(-) create mode 100644 frontend/src/desktop/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts create mode 100644 frontend/src/desktop/components/onboarding/onboardingFlowConfig.ts diff --git a/frontend/src/core/components/onboarding/ServerLicenseModal.tsx b/frontend/src/core/components/onboarding/ServerLicenseModal.tsx index 9c53628cb..9a577d04d 100644 --- a/frontend/src/core/components/onboarding/ServerLicenseModal.tsx +++ b/frontend/src/core/components/onboarding/ServerLicenseModal.tsx @@ -71,8 +71,10 @@ export default function ServerLicenseModal({ slideKey={slide.key} />
-
- Stirling logo +
+
+ Stirling icon +
diff --git a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts index f9c08eb60..7243d79a2 100644 --- a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts +++ b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts @@ -175,7 +175,7 @@ export const SLIDE_DEFINITIONS: Record = { 'server-license': { id: 'server-license', createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }), - hero: { type: 'logo' }, + hero: { type: 'dual-icon' }, buttons: [ { key: 'license-close', diff --git a/frontend/src/core/components/shared/InfoBanner.tsx b/frontend/src/core/components/shared/InfoBanner.tsx index f64cf6dd2..babfb95c2 100644 --- a/frontend/src/core/components/shared/InfoBanner.tsx +++ b/frontend/src/core/components/shared/InfoBanner.tsx @@ -130,6 +130,11 @@ export const InfoBanner: React.FC = ({ onClick={onButtonClick} loading={loading} leftSection={} + styles={{ + label: { + color: textColor ?? toneStyle.text, + }, + }} > {buttonText} diff --git a/frontend/src/desktop/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts b/frontend/src/desktop/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts new file mode 100644 index 000000000..22ed22918 --- /dev/null +++ b/frontend/src/desktop/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts @@ -0,0 +1,390 @@ +/** + * Desktop override of useInitialOnboardingState. + * + * Key difference: Handles the simplified onboarding flow where desktop-install + * and security-check slides are removed. When welcome is the last slide, + * clicking Next will launch the tools tour instead of doing nothing. + */ + +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(''); + + const [state, setState] = useState(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], + ); + // Desktop: No security-check slide, so no need to filter it out + const flowSlideIds = resolvedFlow.ids; + 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( + () => ({ + 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 isOnLastSlide = state.step >= maxIndex; + + // Desktop: For login-user and no-login flows, when on the last slide (welcome), + // clicking Next should launch the tools tour + const shouldAutoLaunchToolsTour = + (flowType === 'login-user' || flowType === 'no-login') && isOnLastSlide; + + switch (action) { + case 'next': + if (shouldAutoLaunchToolsTour) { + 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 (shouldAutoLaunchToolsTour) { + launchTour('tools', { closeOnboardingSlides: true }); + return; + } + goNext(); + return; + } + case 'complete-close': + updatePreference('hasCompletedOnboarding', true); + closeAndMarkSeen(); + return; + case 'security-next': + // Desktop: security-check slide is removed, but keep this for completeness + 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, + currentSlideId, + effectiveIsAdmin, + flowType, + goNext, + goPrev, + launchTour, + maxIndex, + navigate, + requestServerLicenseIfNeeded, + onRequestServerLicense, + os.url, + state.selectedRole, + state.selfReportedAdmin, + state.step, + updatePreference, + ], + ); + + const flowState: FlowState = { selectedRole: state.selectedRole }; + + return { + state, + totalSteps, + slideDefinition, + currentSlide, + licenseNotice, + flowState, + closeAndMarkSeen, + handleButtonAction, + }; +} + diff --git a/frontend/src/desktop/components/onboarding/onboardingFlowConfig.ts b/frontend/src/desktop/components/onboarding/onboardingFlowConfig.ts new file mode 100644 index 000000000..6eaf245a1 --- /dev/null +++ b/frontend/src/desktop/components/onboarding/onboardingFlowConfig.ts @@ -0,0 +1,178 @@ +/** + * Desktop override of onboarding flow config. + * + * This version removes the desktop-install and security-check slides + * since they're not relevant when running as a desktop app. + * + * The SlideId type still includes all values for type compatibility, + * but the actual FLOW_SEQUENCES don't use these slides. + */ + +import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide'; +import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide'; +import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide'; +import { SlideConfig, LicenseNotice } from '@app/types/types'; + +// Keep the full type for compatibility, but these slides won't be used +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 OSOption { + label: string; + url: string; + value: string; +} + +export interface SlideFactoryParams { + osLabel: string; + osUrl: string; + osOptions?: OSOption[]; + onDownloadUrlChange?: (url: string) => void; + selectedRole: 'admin' | 'user' | null; + onRoleSelect: (role: 'admin' | 'user' | null) => void; + licenseNotice?: LicenseNotice; + loginEnabled?: boolean; +} + +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 = { + 'welcome': { + id: 'welcome', + createSlide: () => WelcomeSlide(), + hero: { type: 'rocket' }, + buttons: [ + { + key: 'welcome-next', + type: 'button', + label: 'onboarding.buttons.next', + variant: 'primary', + group: 'right', + action: 'next', + }, + ], + }, + // Stub definitions for desktop-install and security-check - not used on desktop + // but kept for type compatibility with core code + 'desktop-install': { + id: 'desktop-install', + createSlide: () => WelcomeSlide(), // Placeholder - never used + hero: { type: 'dual-icon' }, + buttons: [], + }, + 'security-check': { + id: 'security-check', + createSlide: () => WelcomeSlide(), // Placeholder - never used + hero: { type: 'shield' }, + buttons: [], + }, + 'admin-overview': { + id: 'admin-overview', + createSlide: ({ licenseNotice, loginEnabled }) => + PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }), + hero: { type: 'diamond' }, + buttons: [ + { + key: 'admin-back', + type: 'icon', + icon: 'chevron-left', + group: 'left', + action: 'prev', + }, + { + key: 'admin-show', + type: 'button', + label: 'onboarding.buttons.showMeAround', + variant: 'primary', + group: 'right', + action: 'launch-admin', + }, + { + key: 'admin-skip', + type: 'button', + label: 'onboarding.buttons.skipTheTour', + variant: 'secondary', + group: 'left', + action: 'skip-to-license', + }, + ], + }, + 'server-license': { + id: 'server-license', + createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }), + hero: { type: 'dual-icon' }, + buttons: [ + { + key: 'license-close', + type: 'button', + label: 'onboarding.buttons.skipForNow', + variant: 'secondary', + group: 'left', + action: 'close', + }, + { + key: 'license-see-plans', + type: 'button', + label: 'onboarding.serverLicense.seePlans', + variant: 'primary', + group: 'right', + action: 'see-plans', + }, + ], + }, +}; + +/** + * Desktop flow sequences - simplified without desktop-install and security-check slides + * since users are already on desktop and security check is not needed. + */ +export const FLOW_SEQUENCES = { + loginAdmin: ['welcome', 'admin-overview'] as SlideId[], + loginUser: ['welcome'] as SlideId[], + noLoginBase: ['welcome'] as SlideId[], + noLoginAdmin: ['admin-overview'] as SlideId[], +}; +