From a128b62ad9118d1032b732ecc8f8e579b1f1e770 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Tue, 18 Nov 2025 22:04:52 +0000 Subject: [PATCH] added the different paths and cleaned up some of the files, broke some of the larger ones into different smaller files, hence the scary 38 files changed --- frontend/src/core/components/AppProviders.tsx | 61 ++- .../InitialOnboardingModal.module.css | 84 --- .../onboarding/InitialOnboardingModal.tsx | 293 ----------- .../InitialOnboardingModal.module.css | 276 ++++++++++ .../InitialOnboardingModal/flowResolver.ts | 33 ++ .../InitialOnboardingModal/index.tsx | 163 ++++++ .../InitialOnboardingModal/renderButtons.tsx | 99 ++++ .../InitialOnboardingModal/types.ts | 23 + .../InitialOnboardingModal/useDevScenarios.ts | 93 ++++ .../useInitialOnboardingState.ts | 337 ++++++++++++ .../InitialOnboardingModal/useLicenseInfo.ts | 54 ++ .../components/onboarding/OnboardingTour.tsx | 494 ++++-------------- .../onboarding/ServerLicenseModal.tsx | 112 ++++ .../components/onboarding/TourContent.tsx | 22 + .../components/onboarding/adminStepsConfig.ts | 133 +++++ .../onboarding/hooks/useOnboardingFlow.ts | 237 +++++++++ .../onboarding/onboardingFlowConfig.ts | 197 +++++++ .../slides/AnimatedSlideBackground.module.css | 33 +- .../slides/AnimatedSlideBackground.tsx | 58 +- .../onboarding/slides/DesktopInstallSlide.tsx | 25 +- .../onboarding/slides/PlanOverviewSlide.tsx | 51 +- .../onboarding/slides/SecurityCheckSlide.tsx | 54 ++ .../onboarding/slides/ServerLicenseSlide.tsx | 45 ++ .../onboarding/slides/WelcomeSlide.tsx | 39 +- .../components/onboarding/slides/types.ts | 6 + .../slides/unifiedBackgroundConfig.ts | 30 ++ .../core/components/onboarding/tourGlow.ts | 18 + .../core/components/onboarding/tourSteps.ts | 33 ++ .../components/onboarding/userStepsConfig.ts | 173 ++++++ .../src/core/components/shared/Footer.tsx | 4 +- .../core/components/shared/QuickAccessBar.tsx | 21 +- .../config/configSections/GeneralSection.tsx | 5 +- .../components/tools/ToolPanelModePrompt.tsx | 64 ++- .../core/contexts/CookieConsentContext.tsx | 42 ++ .../src/core/contexts/OnboardingContext.tsx | 105 +++- .../src/core/contexts/ToolWorkflowContext.tsx | 1 + frontend/src/core/hooks/useCookieConsent.ts | 27 +- .../src/core/services/preferencesService.ts | 4 + 38 files changed, 2591 insertions(+), 958 deletions(-) delete mode 100644 frontend/src/core/components/onboarding/InitialOnboardingModal.module.css delete mode 100644 frontend/src/core/components/onboarding/InitialOnboardingModal.tsx create mode 100644 frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css create mode 100644 frontend/src/core/components/onboarding/InitialOnboardingModal/flowResolver.ts create mode 100644 frontend/src/core/components/onboarding/InitialOnboardingModal/index.tsx create mode 100644 frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx create mode 100644 frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts create mode 100644 frontend/src/core/components/onboarding/InitialOnboardingModal/useDevScenarios.ts create mode 100644 frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts create mode 100644 frontend/src/core/components/onboarding/InitialOnboardingModal/useLicenseInfo.ts create mode 100644 frontend/src/core/components/onboarding/ServerLicenseModal.tsx create mode 100644 frontend/src/core/components/onboarding/TourContent.tsx create mode 100644 frontend/src/core/components/onboarding/adminStepsConfig.ts create mode 100644 frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts create mode 100644 frontend/src/core/components/onboarding/onboardingFlowConfig.ts create mode 100644 frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx create mode 100644 frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx create mode 100644 frontend/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts create mode 100644 frontend/src/core/components/onboarding/tourGlow.ts create mode 100644 frontend/src/core/components/onboarding/tourSteps.ts create mode 100644 frontend/src/core/components/onboarding/userStepsConfig.ts create mode 100644 frontend/src/core/contexts/CookieConsentContext.tsx diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 96c44bfa2..3d313cd68 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -17,6 +17,7 @@ import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContex import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; import { PageEditorProvider } from "@app/contexts/PageEditorContext"; import { BannerProvider } from "@app/contexts/BannerContext"; +import { CookieConsentProvider } from "@app/contexts/CookieConsentContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; @@ -57,35 +58,37 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide retryOptions={appConfigRetryOptions} {...appConfigProviderProps} > - - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal.module.css b/frontend/src/core/components/onboarding/InitialOnboardingModal.module.css deleted file mode 100644 index 6a2967a20..000000000 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal.module.css +++ /dev/null @@ -1,84 +0,0 @@ -.heroWrapper { - position: relative; - width: 100%; - height: 220px; - overflow: hidden; -} - -.heroLogo { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - animation: heroLogoEnter 0.25s ease forwards; -} - -.heroLogoCircle { - width: 96px; - height: 96px; - border-radius: 50%; - background: #ffffff; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18); - animation: heroLogoScale 0.25s ease forwards; -} - -.heroLogoCircle img { - width: 52px; - height: 52px; - animation: heroLogoRotate 0.25s ease forwards; - transform-origin: center; -} - -.title { - text-align: center; - opacity: 0; - transform: translateX(24px); - animation: bodySlideIn 0.25s ease forwards; -} - -.bodyCopy { - opacity: 0; - transform: translateX(24px); - animation: bodySlideIn 0.25s ease forwards; -} - -@keyframes heroLogoEnter { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes heroLogoScale { - from { - transform: scale(0.6); - } - to { - transform: scale(1); - } -} - -@keyframes heroLogoRotate { - from { - transform: rotate(-90deg) scale(0.9); - } - to { - transform: rotate(0deg) scale(1); - } -} - -@keyframes bodySlideIn { - from { - opacity: 0; - transform: translateX(24px); - } - to { - opacity: 1; - transform: translateX(0); - } -} diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal.tsx b/frontend/src/core/components/onboarding/InitialOnboardingModal.tsx deleted file mode 100644 index 9f9a32bae..000000000 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import React from 'react'; -import { Modal, Button, Group, Stack, ActionIcon } from '@mantine/core'; -import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; -import { usePreferences } from '@app/contexts/PreferencesContext'; -import { useOnboarding } from '@app/contexts/OnboardingContext'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; -import OnboardingStepper from '@app/components/onboarding/OnboardingStepper'; -import { useOs } from '@app/hooks/useOs'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide'; -import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide'; -import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide'; -import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground'; -import { SlideConfig } from '@app/components/onboarding/slides/types'; -import styles from './InitialOnboardingModal.module.css'; - -interface InitialOnboardingModalProps { - opened: boolean; - onClose: () => void; -} - - -export default function InitialOnboardingModal({ opened, onClose }: InitialOnboardingModalProps) { - const { preferences, updatePreference } = usePreferences(); - const { startTour, setShowWelcomeModal, setStartAfterToolModeSelection } = useOnboarding(); - const [step, setStep] = React.useState(0); - const totalSteps = 3; - const { config } = useAppConfig(); - const isAdmin = !!config?.isAdmin; - - React.useEffect(() => { - if (!opened) setStep(0); - }, [opened]); - - const osType = useOs(); - const os = React.useMemo(() => { - switch (osType) { - case 'windows': - return { label: 'Windows', url: 'https://files.stirlingpdf.com/win-installer.exe' }; - case 'mac-apple': - return { label: 'Mac', url: 'https://files.stirlingpdf.com/mac-installer.dmg' }; - case 'mac-intel': - return { label: 'Mac (Intel)', url: 'https://files.stirlingpdf.com/mac-x86_64-installer.dmg' }; - case 'linux-x64': - case 'linux-arm64': - return { label: 'Linux', url: 'https://docs.stirlingpdf.com/Installation/Unix%20Installation/' }; - // For mobile/unknown, hide OS label and skip opening a URL - default: - return { label: '', url: '' }; - } - }, [osType]); - - const closeAndMarkSeen = React.useCallback(() => { - if (!preferences.hasSeenIntroOnboarding) { - updatePreference('hasSeenIntroOnboarding', true); - } - onClose(); - }, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]); - - const goNext = () => setStep((s) => Math.min(totalSteps - 1, s + 1)); - const goPrev = () => setStep((s) => Math.max(0, s - 1)); - - // Get slide content from the slide components - const slides = React.useMemo( - () => [ - WelcomeSlide(), - DesktopInstallSlide({ osLabel: os.label, osUrl: os.url }), - PlanOverviewSlide({ isAdmin }), - ], - [isAdmin, os.label, os.url], - ); - - const currentSlide = slides[step]; - - // Buttons per step - const renderButtons = () => { - if (step === 0) { - return ( - - - - ); - } - - if (step === 1) { - return ( - - - - - - - - - - - ); - } - - return ( - - - - - - - - - - - - ); - }; - - return ( - - -
- -
-
- Stirling logo -
-
-
- -
- -
- {currentSlide.title} -
- -
- {/* strong tags should match the title color */} -
- {currentSlide.body} -
- -
- - - -
- {renderButtons()} -
-
-
-
-
- ); -} - - diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css b/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css new file mode 100644 index 000000000..ae5dd6813 --- /dev/null +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css @@ -0,0 +1,276 @@ +.heroWrapper { + position: relative; + width: 100%; + height: 220px; + overflow: hidden; +} + +.heroLogo { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + animation: heroLogoEnter 0.25s ease forwards; + z-index: 20; +} + +.heroLogoCircle { + width: 96px; + height: 96px; + border-radius: 50%; + background: #ffffff; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18); + animation: heroLogoScale 0.25s ease forwards; +} + +.heroLogoCircle img { + width: 60px; + height: 60px; + animation: heroLogoRotate 0.25s ease forwards; + transform-origin: center; +} + +.standaloneIcon { + width: 96px; + height: 96px; + object-fit: contain; + animation: heroLogoScale 0.25s ease forwards; +} + +.securitySlideContent { + display: flex; + justify-content: center; + margin-top: 12px; +} + +.securityCard { + width: 100%; + max-width: 380px; + background: var(--bg-surface, #ffffff); + border-radius: 16px; + padding: 16px; + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12); + display: flex; + flex-direction: column; + gap: 12px; +} + +.securityAlertRow { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + font-size: 14px; + color: var(--onboarding-body, #1f2937); +} + +.heroIconsContainer { + display: flex; + gap: 32px; + align-items: flex-start; + justify-content: center; + animation: heroLogoEnter 0.25s ease forwards; + position: relative; + top: 1rem; +} + +.iconWrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.iconButton { + background: none; + border: none; + padding: 0; + cursor: pointer; + transition: transform 0.2s ease, opacity 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + position: relative; + border-radius: 12px; + padding: 4px; + outline: none; +} + +.iconButton:focus { + outline: none; +} + +.iconButton:focus-visible { + outline: none; +} + +.iconButton:hover { + transform: scale(1.05); + opacity: 0.9; +} + +.iconButton:active { + transform: scale(0.95); +} + +.iconButtonSelected { + background: rgba(255, 255, 255, 0.1); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.4); +} + +.downloadIcon { + width: 96px; + height: 96px; + object-fit: contain; + animation: heroLogoScale 0.25s ease forwards; +} + +.iconLabel { + font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + text-align: center; + animation: heroLogoEnter 0.25s ease forwards; +} + +.title { + text-align: center; + opacity: 0; + transform: translateX(24px); + animation: bodySlideIn 0.25s ease forwards; +} + +.bodyCopy { + opacity: 0; + transform: translateX(24px); + animation: bodySlideIn 0.25s ease forwards; +} + +@keyframes heroLogoEnter { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes heroLogoScale { + from { + transform: scale(0.6); + } + to { + transform: scale(1); + } +} + +@keyframes heroLogoRotate { + from { + transform: rotate(-90deg) scale(0.9); + } + to { + transform: rotate(0deg) scale(1); + } +} + +@keyframes bodySlideIn { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Dev overlay styles */ +.devOverlay { + position: absolute; + bottom: 8px; + left: 10px; + display: flex; + gap: 6px; + z-index: 10; +} + +.devButton { + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + cursor: pointer; + opacity: 0.8; + border: 1px solid rgba(255, 255, 255, 0.4); + background: rgba(0, 0, 0, 0.35); + color: #fff; + box-shadow: none; +} + +.devButtonActive { + opacity: 1; + border: 1px solid rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.9); + color: #1F2933; + box-shadow: 0 0 8px rgba(255, 255, 255, 0.7); +} + +/* Modal content styles */ +.modalContent { + background: var(--bg-surface); + position: relative; +} + +.modalBody { + padding: 24px; +} + +/* Title styles */ +.titleText { + font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; + font-weight: 600; + font-size: 22px; + color: var(--onboarding-title); +} + +/* Body text styles */ +.bodyText { + font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; + font-size: 16px; + color: var(--onboarding-body); + line-height: 1.5; +} + +.bodyCopyInner { + color: inherit; +} + +/* Button margin */ +.buttonContainer { + margin-top: 8px; +} + +/* Welcome slide V2 badge */ +.welcomeTitleContainer { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.v2Badge { + background: #DBEFFF; + color: #2A4BFF; + padding: 4px 12px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; +} + +/* Icon styles */ +.heroIcon { + color: #000000; +} diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/flowResolver.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/flowResolver.ts new file mode 100644 index 000000000..ec742c143 --- /dev/null +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/flowResolver.ts @@ -0,0 +1,33 @@ +import { FLOW_SEQUENCES, type SlideId } from '@app/components/onboarding/onboardingFlowConfig'; + +export type FlowType = 'login-admin' | 'login-user' | 'no-login' | 'no-login-admin'; + +export interface FlowConfig { + type: FlowType; + ids: SlideId[]; +} + +export function resolveFlow(enableLogin: boolean, isAdmin: boolean, selfReportedAdmin: boolean): FlowConfig { + if (!enableLogin) { + return selfReportedAdmin + ? { + type: 'no-login-admin', + ids: [...FLOW_SEQUENCES.noLoginBase, ...FLOW_SEQUENCES.noLoginAdmin], + } + : { + type: 'no-login', + ids: FLOW_SEQUENCES.noLoginBase, + }; + } + + return isAdmin + ? { + type: 'login-admin', + ids: FLOW_SEQUENCES.loginAdmin, + } + : { + type: 'login-user', + ids: FLOW_SEQUENCES.loginUser, + }; +} + diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/index.tsx b/frontend/src/core/components/onboarding/InitialOnboardingModal/index.tsx new file mode 100644 index 000000000..17fe75409 --- /dev/null +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/index.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { Modal, Stack } from '@mantine/core'; +import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined'; +import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground'; +import OnboardingStepper from '@app/components/onboarding/OnboardingStepper'; +import { renderButtons } from './renderButtons'; +import styles from './InitialOnboardingModal.module.css'; +import type { InitialOnboardingModalProps } from './types'; +import { useInitialOnboardingState } from './useInitialOnboardingState'; + +export default function InitialOnboardingModal(props: InitialOnboardingModalProps) { + const flow = useInitialOnboardingState(props); + + if (!flow) { + return null; + } + + const { + state, + totalSteps, + currentSlide, + slideDefinition, + licenseNotice, + flowState, + closeAndMarkSeen, + handleButtonAction, + handleDownloadIconSelect, + devButtons, + activeDevScenario, + handleDevScenarioClick, + } = flow; + + const showDevButtons = devButtons.length > 0; + + const renderHero = () => { + if (slideDefinition.hero.type === 'dual-icon') { + return ( +
+
+ + {state.selectedDownloadIcon === 'new' &&
Modern Icon
} +
+
+ + {state.selectedDownloadIcon === 'classic' &&
Classic Icon
} +
+
+ ); + } + + return ( +
+ {slideDefinition.hero.type === 'rocket' && ( + + )} + {slideDefinition.hero.type === 'shield' && ( + + )} + {slideDefinition.hero.type === 'diamond' && } + {slideDefinition.hero.type === 'logo' && ( + Stirling logo + )} +
+ ); + }; + + const renderDevOverlay = () => { + if (!showDevButtons) { + return null; + } + + return ( +
+ {devButtons.map((btn) => ( + + ))} +
+ ); + }; + + return ( + + +
+ +
+ {renderHero()} +
+
+ +
+ +
+ {currentSlide.title} +
+ +
+
+ {currentSlide.body} +
+ +
+ + + +
+ {renderButtons({ + slideDefinition, + licenseNotice, + flowState, + onAction: handleButtonAction, + })} +
+
+
+ {renderDevOverlay()} +
+
+ ); +} + diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx new file mode 100644 index 000000000..8b41114a3 --- /dev/null +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Button, Group, ActionIcon } from '@mantine/core'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig'; +import type { LicenseNotice } from '@app/components/onboarding/slides/types'; +import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig'; + +interface RenderButtonsProps { + slideDefinition: { + buttons: ButtonDefinition[]; + id: string; + }; + licenseNotice: LicenseNotice; + flowState: FlowState; + onAction: (action: ButtonAction) => void; +} + +export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) { + const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left'); + const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right'); + + const buttonStyles = (variant: ButtonDefinition['variant']) => + variant === 'primary' + ? { + root: { + background: 'var(--onboarding-primary-button-bg)', + color: 'var(--onboarding-primary-button-text)', + }, + } + : { + root: { + background: 'var(--onboarding-secondary-button-bg)', + border: '1px solid var(--onboarding-secondary-button-border)', + color: 'var(--onboarding-secondary-button-text)', + }, + }; + + const resolveButtonLabel = (button: ButtonDefinition) => { + if ( + button.type === 'button' && + slideDefinition.id === 'server-license' && + button.action === 'see-plans' && + licenseNotice.isOverLimit + ) { + return 'Upgrade now →'; + } + return button.label ?? ''; + }; + + const renderButton = (button: ButtonDefinition) => { + const disabled = button.disabledWhen?.(flowState) ?? false; + + if (button.type === 'icon') { + return ( + onAction(button.action)} + radius="md" + size={40} + disabled={disabled} + styles={{ + root: { + background: 'var(--onboarding-secondary-button-bg)', + border: '1px solid var(--onboarding-secondary-button-border)', + color: 'var(--onboarding-secondary-button-text)', + }, + }} + > + {button.icon === 'chevron-left' && } + + ); + } + + const variant = button.variant ?? 'secondary'; + const label = resolveButtonLabel(button); + + return ( + + ); + }; + + if (leftButtons.length === 0) { + return {rightButtons.map(renderButton)}; + } + + if (rightButtons.length === 0) { + return {leftButtons.map(renderButton)}; + } + + return ( + + {leftButtons.map(renderButton)} + {rightButtons.map(renderButton)} + + ); +} + diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts new file mode 100644 index 000000000..af28322c2 --- /dev/null +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts @@ -0,0 +1,23 @@ +import type { LicenseNotice } from '@app/components/onboarding/slides/types'; + +export interface InitialOnboardingModalProps { + opened: boolean; + onClose: () => void; + onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void; + onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void; +} + +export interface OnboardingState { + step: number; + selectedDownloadIcon: 'new' | 'classic'; + selectedRole: 'admin' | 'user' | null; + selfReportedAdmin: boolean; +} + +export const DEFAULT_STATE: OnboardingState = { + step: 0, + selectedDownloadIcon: 'new', + selectedRole: null, + selfReportedAdmin: false, +}; + diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/useDevScenarios.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/useDevScenarios.ts new file mode 100644 index 000000000..32597074e --- /dev/null +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/useDevScenarios.ts @@ -0,0 +1,93 @@ +import { useCallback, useMemo, useState } from 'react'; + +export type DevFlow = 'no-login' | 'login-admin' | 'login-user'; + +export interface DevScenarioButton { + label: string; + flow: DevFlow; + overLimit: boolean; +} + +export interface DevOverrides { + enableLogin?: boolean; + isAdmin?: boolean; + licenseUserCount?: number | null; +} + +interface UseDevScenariosOptions { + opened: boolean; + isDevMode: boolean; + onApplyScenario: (payload: { + selectedRole: 'admin' | 'user' | null; + selfReportedAdmin: boolean; + }) => void; +} + +export function useDevScenarios({ opened, isDevMode, onApplyScenario }: UseDevScenariosOptions) { + const [devOverrides, setDevOverrides] = useState(null); + const [activeDevScenario, setActiveDevScenario] = useState(null); + + const devButtons: DevScenarioButton[] = useMemo(() => { + if (!isDevMode || !opened) { + return []; + } + + return [ + { label: 'no-login', flow: 'no-login', overLimit: false }, + { label: 'admin-login', flow: 'login-admin', overLimit: false }, + { label: 'admin 57', flow: 'login-admin', overLimit: true }, + { label: 'user-login', flow: 'login-user', overLimit: false }, + ]; + }, [isDevMode, opened]); + + const handleDevScenarioClick = useCallback( + (scenario: DevScenarioButton) => { + if (!isDevMode) { + return; + } + + const { flow, overLimit, label } = scenario; + const overrides: DevOverrides = {}; + let newSelectedRole: 'admin' | 'user' | null = null; + let newSelfReportedAdmin = false; + + switch (flow) { + case 'no-login': + overrides.enableLogin = false; + overrides.isAdmin = false; + newSelfReportedAdmin = true; + newSelectedRole = 'admin'; + break; + case 'login-admin': + overrides.enableLogin = true; + overrides.isAdmin = true; + newSelectedRole = 'admin'; + break; + case 'login-user': + default: + overrides.enableLogin = true; + overrides.isAdmin = false; + newSelectedRole = 'user'; + break; + } + + overrides.licenseUserCount = overLimit ? 57 : 3; + + setDevOverrides(overrides); + setActiveDevScenario(label); + onApplyScenario({ + selectedRole: newSelectedRole, + selfReportedAdmin: newSelfReportedAdmin, + }); + }, + [isDevMode, onApplyScenario], + ); + + return { + devButtons, + activeDevScenario, + handleDevScenarioClick, + devOverrides, + }; +} + diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts new file mode 100644 index 000000000..97af3663a --- /dev/null +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts @@ -0,0 +1,337 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { usePreferences } from '@app/contexts/PreferencesContext'; +import { useOnboarding } from '@app/contexts/OnboardingContext'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { useOs } from '@app/hooks/useOs'; +import { + SLIDE_DEFINITIONS, + type ButtonAction, + type FlowState, + type SlideId, +} from '@app/components/onboarding/onboardingFlowConfig'; +import type { LicenseNotice } from '@app/components/onboarding/slides/types'; +import { resolveFlow } from './flowResolver'; +import { useLicenseInfo } from './useLicenseInfo'; +import { useDevScenarios } from './useDevScenarios'; +import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from './types'; + +const FREE_TIER_LIMIT = 5; + +interface UseInitialOnboardingStateResult { + state: OnboardingState; + totalSteps: number; + slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId]; + currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>; + licenseNotice: LicenseNotice; + flowState: FlowState; + closeAndMarkSeen: () => void; + handleButtonAction: (action: ButtonAction) => void; + handleDownloadIconSelect: (icon: 'new' | 'classic') => void; + devButtons: ReturnType['devButtons']; + activeDevScenario: ReturnType['activeDevScenario']; + handleDevScenarioClick: ReturnType['handleDevScenarioClick']; +} + +export function useInitialOnboardingState({ + opened, + onClose, + onRequestServerLicense, + onLicenseNoticeUpdate, +}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null { + const { preferences, updatePreference } = usePreferences(); + const { startTour } = useOnboarding(); + const { config } = useAppConfig(); + const osType = useOs(); + const isDevMode = import.meta.env.MODE === 'development'; + + const [state, setState] = useState(DEFAULT_STATE); + + const resetState = useCallback(() => { + setState(DEFAULT_STATE); + }, []); + + useEffect(() => { + if (!opened) { + resetState(); + } + }, [opened, resetState]); + + const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => { + setState((prev) => ({ + ...prev, + selectedRole: role, + selfReportedAdmin: role === 'admin', + })); + + if (role === 'admin') { + window?.localStorage?.setItem('stirling-self-reported-admin', 'true'); + } + }, []); + + const closeAndMarkSeen = useCallback(() => { + if (!preferences.hasSeenIntroOnboarding) { + updatePreference('hasSeenIntroOnboarding', true); + } + onClose(); + }, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]); + + const handleDevScenarioApply = useCallback( + ({ + selectedRole, + selfReportedAdmin, + }: { + selectedRole: 'admin' | 'user' | null; + selfReportedAdmin: boolean; + }) => { + setState({ + ...DEFAULT_STATE, + selectedRole, + selfReportedAdmin, + }); + }, + [], + ); + + const { devButtons, activeDevScenario, handleDevScenarioClick, devOverrides } = useDevScenarios({ + opened, + isDevMode, + onApplyScenario: handleDevScenarioApply, + }); + + const isAdmin = !!config?.isAdmin; + const enableLogin = config?.enableLogin ?? true; + + const effectiveEnableLogin = devOverrides?.enableLogin ?? enableLogin; + const effectiveIsAdmin = devOverrides?.isAdmin ?? isAdmin; + + const licenseUserCountFromApi = useLicenseInfo({ + opened, + shouldFetch: effectiveEnableLogin && effectiveIsAdmin && !devOverrides, + }); + + const effectiveLicenseUserCount = + devOverrides?.licenseUserCount ?? licenseUserCountFromApi ?? null; + + const os = useMemo(() => { + switch (osType) { + case 'windows': + return { label: 'Windows', url: 'https://files.stirlingpdf.com/win-installer.exe' }; + case 'mac-apple': + return { label: 'Mac', url: 'https://files.stirlingpdf.com/mac-installer.dmg' }; + case 'mac-intel': + return { label: 'Mac (Intel)', url: 'https://files.stirlingpdf.com/mac-x86_64-installer.dmg' }; + case 'linux-x64': + case 'linux-arm64': + return { label: 'Linux', url: 'https://docs.stirlingpdf.com/Installation/Unix%20Installation/' }; + default: + return { label: '', url: '' }; + } + }, [osType]); + + const { ids: flowSlideIds, type: flowType } = resolveFlow( + effectiveEnableLogin, + effectiveIsAdmin, + state.selfReportedAdmin, + ); + const totalSteps = flowSlideIds.length; + const maxIndex = Math.max(totalSteps - 1, 0); + + useEffect(() => { + if (state.step >= flowSlideIds.length) { + setState((prev) => ({ + ...prev, + step: Math.max(flowSlideIds.length - 1, 0), + })); + } + }, [flowSlideIds.length, state.step]); + + const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1]; + const slideDefinition = SLIDE_DEFINITIONS[currentSlideId]; + + if (!slideDefinition) { + return null; + } + + const licenseNotice = useMemo( + () => ({ + totalUsers: effectiveLicenseUserCount, + freeTierLimit: FREE_TIER_LIMIT, + isOverLimit: + effectiveLicenseUserCount != null && effectiveLicenseUserCount > FREE_TIER_LIMIT, + }), + [effectiveLicenseUserCount], + ); + + useEffect(() => { + onLicenseNoticeUpdate?.(licenseNotice); + }, [licenseNotice, onLicenseNoticeUpdate]); + + const currentSlide = slideDefinition.createSlide({ + osLabel: os.label, + osUrl: os.url, + selectedRole: state.selectedRole, + onRoleSelect: handleRoleSelect, + licenseNotice, + }); + + const goNext = useCallback(() => { + setState((prev) => ({ + ...prev, + step: Math.min(prev.step + 1, maxIndex), + })); + }, [maxIndex]); + + const goPrev = useCallback(() => { + setState((prev) => ({ + ...prev, + step: Math.max(prev.step - 1, 0), + })); + }, []); + + const launchTour = useCallback( + (mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => { + if (options?.closeOnboardingSlides) { + closeAndMarkSeen(); + } + + startTour(mode, { + source: 'initial-onboarding-modal', + metadata: { + hasCompletedOnboarding: preferences.hasCompletedOnboarding, + toolPanelModePromptSeen: preferences.toolPanelModePromptSeen, + selfReportedAdmin: state.selfReportedAdmin, + }, + }); + }, + [closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin], + ); + + const handleButtonAction = useCallback( + (action: ButtonAction) => { + const currentSlideIdLocal = currentSlideId; + const shouldAutoLaunchLoginUserTour = + flowType === 'login-user' && currentSlideIdLocal === 'desktop-install'; + + switch (action) { + case 'next': + if (shouldAutoLaunchLoginUserTour) { + launchTour('tools', { closeOnboardingSlides: true }); + return; + } + goNext(); + return; + case 'prev': + goPrev(); + return; + case 'close': + closeAndMarkSeen(); + return; + case 'download-selected': { + const downloadUrl = + state.selectedDownloadIcon === 'new' + ? os.url + : state.selectedDownloadIcon === 'classic' + ? os.url + : currentSlide.downloadUrl; + if (downloadUrl) { + window.open(downloadUrl, '_blank', 'noopener'); + } + if (shouldAutoLaunchLoginUserTour) { + launchTour('tools', { closeOnboardingSlides: true }); + return; + } + goNext(); + return; + } + case 'complete-close': + updatePreference('hasCompletedOnboarding', true); + closeAndMarkSeen(); + return; + case 'security-next': + if (!state.selectedRole) { + return; + } + if (state.selectedRole === 'admin') { + goNext(); + } else { + launchTour('tools', { closeOnboardingSlides: true }); + } + return; + case 'launch-admin': + onRequestServerLicense?.({ + deferUntilTourComplete: true, + selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, + }); + launchTour('admin', { closeOnboardingSlides: true }); + return; + case 'launch-tools': + launchTour('tools', { closeOnboardingSlides: true }); + return; + case 'launch-auto': { + const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools'; + if (launchMode === 'admin') { + onRequestServerLicense?.({ + deferUntilTourComplete: true, + selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, + }); + } + launchTour(launchMode, { closeOnboardingSlides: true }); + return; + } + case 'skip-to-license': + updatePreference('hasCompletedOnboarding', true); + onRequestServerLicense?.({ + deferUntilTourComplete: false, + selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, + }); + closeAndMarkSeen(); + return; + case 'see-plans': + closeAndMarkSeen(); + return; + default: + return; + } + }, + [ + closeAndMarkSeen, + currentSlide, + effectiveIsAdmin, + flowType, + goNext, + goPrev, + launchTour, + onRequestServerLicense, + os.url, + state.selectedDownloadIcon, + state.selectedRole, + state.selfReportedAdmin, + updatePreference, + ], + ); + + const handleDownloadIconSelect = useCallback((icon: 'new' | 'classic') => { + setState((prev) => ({ + ...prev, + selectedDownloadIcon: icon, + })); + }, []); + + const flowState: FlowState = { selectedRole: state.selectedRole }; + + return { + state, + totalSteps, + slideDefinition, + currentSlide, + licenseNotice, + flowState, + closeAndMarkSeen, + handleButtonAction, + handleDownloadIconSelect, + devButtons, + activeDevScenario, + handleDevScenarioClick, + }; +} + diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/useLicenseInfo.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/useLicenseInfo.ts new file mode 100644 index 000000000..2ca04b9c3 --- /dev/null +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/useLicenseInfo.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import apiClient from '@app/services/apiClient'; + +interface UseLicenseInfoOptions { + opened: boolean; + shouldFetch: boolean; +} + +export function useLicenseInfo({ opened, shouldFetch }: UseLicenseInfoOptions) { + const [licenseUserCount, setLicenseUserCount] = useState(null); + + useEffect(() => { + if (!opened) { + return; + } + + if (!shouldFetch) { + setLicenseUserCount(null); + return; + } + + let cancelled = false; + + const fetchLicenseInfo = async () => { + try { + const response = await apiClient.get<{ totalUsers?: number }>( + '/api/v1/proprietary/ui-data/admin-settings', + { + suppressErrorToast: true, + } as any, + ); + + if (!cancelled) { + const totalUsers = response.data?.totalUsers; + setLicenseUserCount(typeof totalUsers === 'number' ? totalUsers : null); + } + } catch (error) { + console.error('[onboarding] failed to fetch license information', error); + if (!cancelled) { + setLicenseUserCount(null); + } + } + }; + + fetchLicenseInfo(); + + return () => { + cancelled = true; + }; + }, [opened, shouldFetch]); + + return licenseUserCount; +} + diff --git a/frontend/src/core/components/onboarding/OnboardingTour.tsx b/frontend/src/core/components/onboarding/OnboardingTour.tsx index 5aedce848..43abf4243 100644 --- a/frontend/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/src/core/components/onboarding/OnboardingTour.tsx @@ -1,117 +1,26 @@ -import React from "react"; -import { TourProvider, useTour, type StepType } from '@reactour/tour'; -import { useOnboarding } from '@app/contexts/OnboardingContext'; +import React, { useEffect, useMemo } from "react"; +import { TourProvider, type StepType } from '@reactour/tour'; import { useTranslation } from 'react-i18next'; import { CloseButton, ActionIcon } from '@mantine/core'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import CheckIcon from '@mui/icons-material/Check'; +import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal'; +import ServerLicenseModal from './ServerLicenseModal'; +import '@app/components/onboarding/OnboardingTour.css'; +import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext'; import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext'; -import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; -import CheckIcon from '@mui/icons-material/Check'; -import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal'; -import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal'; -import { usePreferences } from '@app/contexts/PreferencesContext'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import { useAuth } from '@app/auth/UseSession'; -import '@app/components/onboarding/OnboardingTour.css'; - -// Enum case order defines order steps will appear -enum TourStep { - ALL_TOOLS, - SELECT_CROP_TOOL, - TOOL_INTERFACE, - FILES_BUTTON, - FILE_SOURCES, - WORKBENCH, - VIEW_SWITCHER, - VIEWER, - PAGE_EDITOR, - ACTIVE_FILES, - FILE_CHECKBOX, - SELECT_CONTROLS, - CROP_SETTINGS, - RUN_BUTTON, - RESULTS, - FILE_REPLACEMENT, - PIN_BUTTON, - WRAP_UP, -} - -enum AdminTourStep { - WELCOME, - CONFIG_BUTTON, - SETTINGS_OVERVIEW, - TEAMS_AND_USERS, - SYSTEM_CUSTOMIZATION, - DATABASE_SECTION, - CONNECTIONS_SECTION, - ADMIN_TOOLS, - WRAP_UP, -} - -function TourContent() { - const { isOpen } = useOnboarding(); - const { setIsOpen, setCurrentStep } = useTour(); - const previousIsOpenRef = React.useRef(isOpen); - - // Sync tour open state with context and reset to step 0 when reopening - React.useEffect(() => { - const wasClosedNowOpen = !previousIsOpenRef.current && isOpen; - previousIsOpenRef.current = isOpen; - - if (wasClosedNowOpen) { - // Tour is being opened (Help button pressed), reset to first step - setCurrentStep(0); - } - setIsOpen(isOpen); - }, [isOpen, setIsOpen, setCurrentStep]); - - return null; -} +import { useOnboardingFlow } from './hooks/useOnboardingFlow'; +import { createUserStepsConfig } from './userStepsConfig'; +import { createAdminStepsConfig } from './adminStepsConfig'; +import { removeAllGlows } from './tourGlow'; +import TourContent from './TourContent'; export default function OnboardingTour() { const { t } = useTranslation(); - const { preferences, updatePreference } = usePreferences(); - const { config } = useAppConfig(); - // useAuth is provided in proprietary/desktop builds; in environments without Auth, this will be no-op - let session: any = null; - try { - // eslint-disable-next-line react-hooks/rules-of-hooks - session = useAuth()?.session ?? null; - } catch { - // Auth provider not available in this build; treat as always authenticated - session = {} as any; - } - const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding(); + const flow = useOnboardingFlow(); const { openFilesModal, closeFilesModal } = useFilesModalContext(); - - // Helper to add glow to multiple elements - const addGlowToElements = (selectors: string[]) => { - selectors.forEach(selector => { - const element = document.querySelector(selector); - if (element) { - if (selector === '[data-tour="settings-content-area"]') { - element.classList.add('tour-content-glow'); - } else { - element.classList.add('tour-nav-glow'); - } - } - }); - }; - - // Helper to remove all glows - const removeAllGlows = () => { - document.querySelectorAll('.tour-content-glow').forEach(el => el.classList.remove('tour-content-glow')); - document.querySelectorAll('.tour-nav-glow').forEach(el => el.classList.remove('tour-nav-glow')); - }; - - // Cleanup glows when tour closes - React.useEffect(() => { - if (!isOpen) { - removeAllGlows(); - } - return () => removeAllGlows(); - }, [isOpen]); const { saveWorkbenchState, restoreWorkbenchState, @@ -134,258 +43,76 @@ export default function OnboardingTour() { scrollNavToSection, } = useAdminTourOrchestration(); - // Define steps as object keyed by enum - TypeScript ensures all keys are present - const stepsConfig: Record = { - [TourStep.ALL_TOOLS]: { - selector: '[data-tour="tool-panel"]', - content: t('onboarding.allTools', 'This is the Tools panel, where you can browse and select from all available PDF tools.'), - position: 'center', - padding: 0, - action: () => { - saveWorkbenchState(); - closeFilesModal(); - backToAllTools(); - }, - }, - [TourStep.SELECT_CROP_TOOL]: { - selector: '[data-tour="tool-button-crop"]', - content: t('onboarding.selectCropTool', "Let's select the Crop tool to demonstrate how to use one of the tools."), - position: 'right', - padding: 0, - actionAfter: () => selectCropTool(), - }, - [TourStep.TOOL_INTERFACE]: { - selector: '[data-tour="tool-panel"]', - content: t('onboarding.toolInterface', "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."), - position: 'center', - padding: 0, - }, - [TourStep.FILES_BUTTON]: { - selector: '[data-tour="files-button"]', - content: t('onboarding.filesButton', "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on."), - position: 'right', - padding: 10, - action: () => openFilesModal(), - }, - [TourStep.FILE_SOURCES]: { - selector: '[data-tour="file-sources"]', - content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."), - position: 'right', - padding: 0, - actionAfter: () => { - loadSampleFile(); - closeFilesModal(); - } - }, - [TourStep.WORKBENCH]: { - selector: '[data-tour="workbench"]', - content: t('onboarding.workbench', 'This is the Workbench - the main area where you view and edit your PDFs.'), - position: 'center', - padding: 0, - }, - [TourStep.VIEW_SWITCHER]: { - selector: '[data-tour="view-switcher"]', - content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'), - position: 'bottom', - padding: 0, - }, - [TourStep.VIEWER]: { - selector: '[data-tour="workbench"]', - content: t('onboarding.viewer', "The Viewer lets you read and annotate your PDFs."), - position: 'center', - padding: 0, - action: () => switchToViewer(), - }, - [TourStep.PAGE_EDITOR]: { - selector: '[data-tour="workbench"]', - content: t('onboarding.pageEditor', "The Page Editor allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."), - position: 'center', - padding: 0, - action: () => switchToPageEditor(), - }, - [TourStep.ACTIVE_FILES]: { - selector: '[data-tour="workbench"]', - content: t('onboarding.activeFiles', "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."), - position: 'center', - padding: 0, - action: () => switchToActiveFiles(), - }, - [TourStep.FILE_CHECKBOX]: { - selector: '[data-tour="file-card-checkbox"]', - content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."), - position: 'top', - padding: 10, - }, - [TourStep.SELECT_CONTROLS]: { - selector: '[data-tour="right-rail-controls"]', - highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'], - content: t('onboarding.selectControls', "The Right Rail contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."), - position: 'left', - padding: 5, - action: () => selectFirstFile(), - }, - [TourStep.CROP_SETTINGS]: { - selector: '[data-tour="crop-settings"]', - content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."), - position: 'left', - padding: 10, - action: () => modifyCropSettings(), - }, - [TourStep.RUN_BUTTON]: { - selector: '[data-tour="run-button"]', - content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."), - position: 'top', - padding: 10, - actionAfter: () => executeTool(), - }, - [TourStep.RESULTS]: { - selector: '[data-tour="tool-panel"]', - content: t('onboarding.results', "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "), - position: 'center', - padding: 0, - }, - [TourStep.FILE_REPLACEMENT]: { - selector: '[data-tour="file-card-checkbox"]', - content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."), - position: 'left', - padding: 10, - }, - [TourStep.PIN_BUTTON]: { - selector: '[data-tour="file-card-pin"]', - content: t('onboarding.pinButton', "You can use the Pin button if you'd rather your files stay active after running tools on them."), - position: 'left', - padding: 10, - action: () => pinFile(), - }, - [TourStep.WRAP_UP]: { - selector: '[data-tour="help-button"]', - content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again."), - position: 'right', - padding: 10, - }, - }; + useEffect(() => { + if (!flow.isTourOpen) { + removeAllGlows(); + } + return () => removeAllGlows(); + }, [flow.isTourOpen]); - // Define admin tour steps - const adminStepsConfig: Record = { - [AdminTourStep.WELCOME]: { - selector: '[data-tour="config-button"]', - content: t('adminOnboarding.welcome', "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators."), - position: 'right', - padding: 10, - action: () => { - saveAdminState(); - }, - }, - [AdminTourStep.CONFIG_BUTTON]: { - selector: '[data-tour="config-button"]', - content: t('adminOnboarding.configButton', "Click the Config button to access all system settings and administrative controls."), - position: 'right', - padding: 10, - actionAfter: () => { - openConfigModal(); - }, - }, - [AdminTourStep.SETTINGS_OVERVIEW]: { - selector: '.modal-nav', - content: t('adminOnboarding.settingsOverview', "This is the Settings Panel. Admin settings are organised by category for easy navigation."), - position: 'right', - padding: 0, - action: () => { - removeAllGlows(); - }, - }, - [AdminTourStep.TEAMS_AND_USERS]: { - selector: '[data-tour="admin-people-nav"]', - highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.teamsAndUsers', "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."), - position: 'right', - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection('people'); - setTimeout(() => { - addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']); - }, 100); - }, - }, - [AdminTourStep.SYSTEM_CUSTOMIZATION]: { - selector: '[data-tour="admin-adminGeneral-nav"]', - highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users."), - position: 'right', - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection('adminGeneral'); - setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']); - }, 100); - }, - }, - [AdminTourStep.DATABASE_SECTION]: { - selector: '[data-tour="admin-adminDatabase-nav"]', - highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure."), - position: 'right', - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection('adminDatabase'); - setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']); - }, 100); - }, - }, - [AdminTourStep.CONNECTIONS_SECTION]: { - selector: '[data-tour="admin-adminConnections-nav"]', - highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.connectionsSection', "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."), - position: 'right', - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection('adminConnections'); - setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']); - }, 100); - }, - actionAfter: async () => { - // Scroll for the NEXT step before it shows - await scrollNavToSection('adminAudit'); - }, - }, - [AdminTourStep.ADMIN_TOOLS]: { - selector: '[data-tour="admin-adminAudit-nav"]', - highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform."), - position: 'right', - padding: 10, - action: () => { - // Just navigate, scroll already happened in previous step - removeAllGlows(); - navigateToSection('adminAudit'); - setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']); - }, 100); - }, - }, - [AdminTourStep.WRAP_UP]: { - selector: '[data-tour="help-button"]', - content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu."), - position: 'right', - padding: 10, - action: () => { - removeAllGlows(); - }, - }, - }; + const userStepsConfig = useMemo( + () => + createUserStepsConfig({ + t, + actions: { + saveWorkbenchState, + closeFilesModal, + backToAllTools, + selectCropTool, + loadSampleFile, + switchToViewer, + switchToPageEditor, + switchToActiveFiles, + selectFirstFile, + pinFile, + modifyCropSettings, + executeTool, + openFilesModal, + }, + }), + [ + t, + backToAllTools, + closeFilesModal, + executeTool, + loadSampleFile, + modifyCropSettings, + openFilesModal, + pinFile, + saveWorkbenchState, + selectCropTool, + selectFirstFile, + switchToActiveFiles, + switchToPageEditor, + switchToViewer, + ], + ); - // Select steps based on tour type - const steps = tourType === 'admin' - ? Object.values(adminStepsConfig) - : Object.values(stepsConfig); + const adminStepsConfig = useMemo( + () => + createAdminStepsConfig({ + t, + actions: { + saveAdminState, + openConfigModal, + navigateToSection, + scrollNavToSection, + }, + }), + [navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t], + ); - const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: { + const steps = useMemo(() => { + const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig; + return Object.values(config); + }, [adminStepsConfig, flow.tourType, userStepsConfig]); + + const advanceTour = ({ + setCurrentStep, + currentStep, + steps, + setIsOpen, + }: { setCurrentStep: (value: number | ((prev: number) => number)) => void; currentStep: number; steps?: StepType[]; @@ -393,12 +120,12 @@ export default function OnboardingTour() { }) => { if (steps && currentStep === steps.length - 1) { setIsOpen(false); - if (tourType === 'admin') { + if (flow.tourType === 'admin') { restoreAdminState(); } else { restoreWorkbenchState(); } - completeTour(); + flow.handleTourCompletion(); } else if (steps) { setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); } @@ -406,46 +133,22 @@ export default function OnboardingTour() { const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => { setIsOpen(false); - if (tourType === 'admin') { + if (flow.tourType === 'admin') { restoreAdminState(); } else { restoreWorkbenchState(); } - completeTour(); + flow.handleTourCompletion(); }; - const loginEnabled = !!config?.enableLogin; - const isAuthenticated = !!session; - const shouldShowIntro = !preferences.hasSeenIntroOnboarding && (!loginEnabled || isAuthenticated); - return ( <> - { - if (!preferences.hasSeenIntroOnboarding) { - updatePreference('hasSeenIntroOnboarding', true); - } - }} - /> - { - setShowWelcomeModal(false); - startTour(); - }} - onMaybeLater={() => { - setShowWelcomeModal(false); - }} - onDontShowAgain={() => { - setShowWelcomeModal(false); - completeTour(); - }} - /> + + { @@ -453,13 +156,10 @@ export default function OnboardingTour() { advanceTour(clickProps); }} keyboardHandler={(e, clickProps, status) => { - // Handle right arrow key to advance tour if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) { e.preventDefault(); advanceTour(clickProps); - } - // Handle escape key to close tour - else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) { + } else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) { e.preventDefault(); handleCloseTour(clickProps); } @@ -512,22 +212,16 @@ export default function OnboardingTour() { }} components={{ Close: ({ onClick }) => ( - + ), - Content: ({ content } : {content: string}) => ( -
+ Content: ({ content }: { content: string }) => ( +
), }} > + ); } diff --git a/frontend/src/core/components/onboarding/ServerLicenseModal.tsx b/frontend/src/core/components/onboarding/ServerLicenseModal.tsx new file mode 100644 index 000000000..7e3e07b92 --- /dev/null +++ b/frontend/src/core/components/onboarding/ServerLicenseModal.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Modal, Button, Group, Stack } from '@mantine/core'; +import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground'; +import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide'; +import { LicenseNotice } from '@app/components/onboarding/slides/types'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; + +interface ServerLicenseModalProps { + opened: boolean; + onClose: () => void; + onSeePlans?: () => void; + licenseNotice: LicenseNotice; +} + +export default function ServerLicenseModal({ + opened, + onClose, + onSeePlans, + licenseNotice, +}: ServerLicenseModalProps) { + const slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]); + const primaryLabel = licenseNotice.isOverLimit ? 'Upgrade now →' : 'See Plans →'; + + const handleSeePlans = () => { + onSeePlans?.(); + onClose(); + }; + + const secondaryStyles = { + root: { + background: 'var(--onboarding-secondary-button-bg)', + border: '1px solid var(--onboarding-secondary-button-border)', + color: 'var(--onboarding-secondary-button-text)', + }, + }; + + const primaryStyles = { + root: { + background: 'var(--onboarding-primary-button-bg)', + color: 'var(--onboarding-primary-button-text)', + }, + }; + + return ( + + +
+ +
+
+ Stirling logo +
+
+
+ +
+ +
+ {slide.title} +
+
+ {slide.body} +
+ + + + +
+
+
+
+ ); +} + diff --git a/frontend/src/core/components/onboarding/TourContent.tsx b/frontend/src/core/components/onboarding/TourContent.tsx new file mode 100644 index 000000000..c0f787ec9 --- /dev/null +++ b/frontend/src/core/components/onboarding/TourContent.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useTour } from '@reactour/tour'; +import { useOnboarding } from '@app/contexts/OnboardingContext'; + +export default function TourContent() { + const { isOpen } = useOnboarding(); + const { setIsOpen, setCurrentStep } = useTour(); + const previousIsOpenRef = React.useRef(isOpen); + + React.useEffect(() => { + const wasClosedNowOpen = !previousIsOpenRef.current && isOpen; + previousIsOpenRef.current = isOpen; + + if (wasClosedNowOpen) { + setCurrentStep(0); + } + setIsOpen(isOpen); + }, [isOpen, setIsOpen, setCurrentStep]); + + return null; +} + diff --git a/frontend/src/core/components/onboarding/adminStepsConfig.ts b/frontend/src/core/components/onboarding/adminStepsConfig.ts new file mode 100644 index 000000000..27c6fd9d4 --- /dev/null +++ b/frontend/src/core/components/onboarding/adminStepsConfig.ts @@ -0,0 +1,133 @@ +import type { StepType } from '@reactour/tour'; +import type { TFunction } from 'i18next'; +import { AdminTourStep } from './tourSteps'; +import { addGlowToElements, removeAllGlows } from './tourGlow'; + +interface AdminStepActions { + saveAdminState: () => void; + openConfigModal: () => void; + navigateToSection: (section: string) => void; + scrollNavToSection: (section: string) => Promise | void; +} + +interface CreateAdminStepsConfigArgs { + t: TFunction; + actions: AdminStepActions; +} + +export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record { + const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions; + + return { + [AdminTourStep.WELCOME]: { + selector: '[data-tour="config-button"]', + content: t('adminOnboarding.welcome', "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators."), + position: 'right', + padding: 10, + action: () => { + saveAdminState(); + }, + }, + [AdminTourStep.CONFIG_BUTTON]: { + selector: '[data-tour="config-button"]', + content: t('adminOnboarding.configButton', "Click the Config button to access all system settings and administrative controls."), + position: 'right', + padding: 10, + actionAfter: () => { + openConfigModal(); + }, + }, + [AdminTourStep.SETTINGS_OVERVIEW]: { + selector: '.modal-nav', + content: t('adminOnboarding.settingsOverview', "This is the Settings Panel. Admin settings are organised by category for easy navigation."), + position: 'right', + padding: 0, + action: () => { + removeAllGlows(); + }, + }, + [AdminTourStep.TEAMS_AND_USERS]: { + selector: '[data-tour="admin-people-nav"]', + highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.teamsAndUsers', "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('people'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.SYSTEM_CUSTOMIZATION]: { + selector: '[data-tour="admin-adminGeneral-nav"]', + highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('adminGeneral'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.DATABASE_SECTION]: { + selector: '[data-tour="admin-adminDatabase-nav"]', + highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('adminDatabase'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.CONNECTIONS_SECTION]: { + selector: '[data-tour="admin-adminConnections-nav"]', + highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.connectionsSection', "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('adminConnections'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + actionAfter: async () => { + await scrollNavToSection('adminAudit'); + }, + }, + [AdminTourStep.ADMIN_TOOLS]: { + selector: '[data-tour="admin-adminAudit-nav"]', + highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('adminAudit'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.WRAP_UP]: { + selector: '[data-tour="help-button"]', + content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + }, + }, + }; +} + diff --git a/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts b/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts new file mode 100644 index 000000000..edb4f589d --- /dev/null +++ b/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { usePreferences } from '@app/contexts/PreferencesContext'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { useCookieConsentContext } from '@app/contexts/CookieConsentContext'; +import { useOnboarding } from '@app/contexts/OnboardingContext'; +import { useAuth } from '@app/auth/UseSession'; +import type { LicenseNotice } from '@app/components/onboarding/slides/types'; + +interface InitialModalHandlers { + opened: boolean; + onLicenseNoticeUpdate: (notice: LicenseNotice) => void; + onRequestServerLicense: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void; + onClose: () => void; +} + +interface ServerLicenseModalHandlers { + opened: boolean; + licenseNotice: LicenseNotice; + onClose: () => void; +} + +export function useOnboardingFlow() { + const { preferences, updatePreference } = usePreferences(); + const { config } = useAppConfig(); + const { showCookieConsent, isReady: isCookieConsentReady } = useCookieConsentContext(); + const { completeTour, tourType, isOpen } = useOnboarding(); + let session: any = null; + try { + // eslint-disable-next-line react-hooks/rules-of-hooks + session = useAuth()?.session ?? null; + } catch { + session = {} as any; + } + + const loginEnabled = !!config?.enableLogin; + const isAuthenticated = !!session; + const shouldShowIntro = !preferences.hasSeenIntroOnboarding && (!loginEnabled || isAuthenticated); + const isAdminUser = !!config?.isAdmin; + + const [licenseNotice, setLicenseNotice] = useState({ + totalUsers: null, + freeTierLimit: 5, + isOverLimit: false, + }); + const [cookieBannerPending, setCookieBannerPending] = useState(false); + const [serverLicenseIntent, setServerLicenseIntent] = useState<'idle' | 'pending' | 'deferred'>('idle'); + const [serverLicenseSource, setServerLicenseSource] = useState<'config' | 'self-reported' | null>(null); + const [isServerLicenseOpen, setIsServerLicenseOpen] = useState(false); + const [hasShownServerLicense, setHasShownServerLicense] = useState(false); + const [toolPromptCompleted, setToolPromptCompleted] = useState( + preferences.toolPanelModePromptSeen || preferences.hasSelectedToolPanelMode, + ); + const introWasOpenRef = useRef(false); + + const handleInitialModalClose = useCallback(() => { + if (!preferences.hasSeenIntroOnboarding) { + updatePreference('hasSeenIntroOnboarding', true); + } + }, [preferences.hasSeenIntroOnboarding, updatePreference]); + + const handleLicenseNoticeUpdate = useCallback((notice: LicenseNotice) => { + setLicenseNotice(notice); + }, []); + + const handleToolPromptComplete = useCallback(() => { + setToolPromptCompleted(true); + }, []); + + const maybeShowCookieBanner = useCallback(() => { + if (preferences.hasSeenCookieBanner) { + return; + } + + if (!isCookieConsentReady || isServerLicenseOpen || serverLicenseIntent !== 'idle' || !toolPromptCompleted) { + setCookieBannerPending(true); + return; + } + + setCookieBannerPending(false); + showCookieConsent(); + updatePreference('hasSeenCookieBanner', true); + }, [ + isCookieConsentReady, + isServerLicenseOpen, + preferences.hasSeenCookieBanner, + serverLicenseIntent, + showCookieConsent, + toolPromptCompleted, + updatePreference, + ]); + + const requestServerLicense = useCallback( + ({ + deferUntilTourComplete = false, + selfReportedAdmin = false, + }: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean } = {}) => { + const qualifies = isAdminUser || selfReportedAdmin; + if (!qualifies) { + return; + } + setServerLicenseSource(isAdminUser ? 'config' : 'self-reported'); + setServerLicenseIntent((prev) => { + if (prev === 'pending') { + return prev; + } + if (prev === 'deferred') { + return deferUntilTourComplete ? prev : 'pending'; + } + if (prev === 'idle') { + return deferUntilTourComplete ? 'deferred' : 'pending'; + } + return prev; + }); + }, + [isAdminUser], + ); + + useEffect(() => { + if ( + cookieBannerPending && + isCookieConsentReady && + serverLicenseIntent === 'idle' && + !isServerLicenseOpen && + toolPromptCompleted + ) { + maybeShowCookieBanner(); + } + }, [ + cookieBannerPending, + isCookieConsentReady, + isServerLicenseOpen, + serverLicenseIntent, + toolPromptCompleted, + maybeShowCookieBanner, + ]); + + useEffect(() => { + const isEligibleAdmin = isAdminUser || serverLicenseSource === 'self-reported'; + if ( + introWasOpenRef.current && + !shouldShowIntro && + isEligibleAdmin && + toolPromptCompleted && + !hasShownServerLicense && + serverLicenseIntent === 'idle' + ) { + setServerLicenseIntent('pending'); + } + introWasOpenRef.current = shouldShowIntro; + }, [ + hasShownServerLicense, + isAdminUser, + serverLicenseIntent, + shouldShowIntro, + serverLicenseSource, + toolPromptCompleted, + ]); + + useEffect(() => { + const isEligibleAdmin = isAdminUser || serverLicenseSource === 'self-reported'; + if ( + serverLicenseIntent !== 'idle' && + !shouldShowIntro && + !isOpen && + !isServerLicenseOpen && + isEligibleAdmin && + toolPromptCompleted + ) { + setIsServerLicenseOpen(true); + setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle'); + } + }, [ + isAdminUser, + isOpen, + isServerLicenseOpen, + serverLicenseIntent, + shouldShowIntro, + serverLicenseSource, + toolPromptCompleted, + ]); + + const handleServerLicenseClose = useCallback(() => { + setIsServerLicenseOpen(false); + setHasShownServerLicense(true); + setServerLicenseIntent('idle'); + setServerLicenseSource(null); + maybeShowCookieBanner(); + }, [maybeShowCookieBanner]); + + const handleTourCompletion = useCallback(() => { + completeTour(); + if (serverLicenseIntent === 'deferred') { + setServerLicenseIntent('pending'); + } else if (tourType === 'admin' && (isAdminUser || serverLicenseSource === 'self-reported')) { + setServerLicenseSource((prev) => prev ?? (isAdminUser ? 'config' : 'self-reported')); + setServerLicenseIntent((prev) => (prev === 'pending' ? prev : 'pending')); + } + maybeShowCookieBanner(); + }, [ + completeTour, + isAdminUser, + maybeShowCookieBanner, + serverLicenseIntent, + serverLicenseSource, + tourType, + ]); + + const initialModalProps: InitialModalHandlers = useMemo( + () => ({ + opened: shouldShowIntro, + onLicenseNoticeUpdate: handleLicenseNoticeUpdate, + onRequestServerLicense: requestServerLicense, + onClose: handleInitialModalClose, + }), + [handleInitialModalClose, handleLicenseNoticeUpdate, requestServerLicense, shouldShowIntro], + ); + + const serverLicenseModalProps: ServerLicenseModalHandlers = useMemo( + () => ({ + opened: isServerLicenseOpen, + licenseNotice, + onClose: handleServerLicenseClose, + }), + [handleServerLicenseClose, isServerLicenseOpen, licenseNotice], + ); + + return { + tourType, + isTourOpen: isOpen, + maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined, + initialModalProps, + handleToolPromptComplete, + serverLicenseModalProps, + handleTourCompletion, + }; +} + diff --git a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts new file mode 100644 index 000000000..780f03772 --- /dev/null +++ b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts @@ -0,0 +1,197 @@ +import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide'; +import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide'; +import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide'; +import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide'; +import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide'; +import { SlideConfig, LicenseNotice } from '@app/components/onboarding/slides/types'; + +export type SlideId = + | 'welcome' + | 'desktop-install' + | 'security-check' + | 'admin-overview' + | 'server-license'; + +export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo'; + +export type ButtonAction = + | 'next' + | 'prev' + | 'close' + | 'complete-close' + | 'download-selected' + | 'security-next' + | 'launch-admin' + | 'launch-tools' + | 'launch-auto' + | 'see-plans' + | 'skip-to-license'; + +export interface FlowState { + selectedRole: 'admin' | 'user' | null; +} + +export interface SlideFactoryParams { + osLabel: string; + osUrl: string; + selectedRole: 'admin' | 'user' | null; + onRoleSelect: (role: 'admin' | 'user' | null) => void; + licenseNotice?: LicenseNotice; +} + +export interface HeroDefinition { + type: HeroType; +} + +export interface ButtonDefinition { + key: string; + type: 'button' | 'icon'; + label?: string; + icon?: 'chevron-left'; + variant?: 'primary' | 'secondary' | 'default'; + group: 'left' | 'right'; + action: ButtonAction; + disabledWhen?: (state: FlowState) => boolean; +} + +export interface SlideDefinition { + id: SlideId; + createSlide: (params: SlideFactoryParams) => SlideConfig; + hero: HeroDefinition; + buttons: ButtonDefinition[]; +} + +export const SLIDE_DEFINITIONS: Record = { + 'welcome': { + id: 'welcome', + createSlide: () => WelcomeSlide(), + hero: { type: 'rocket' }, + buttons: [ + { + key: 'welcome-next', + type: 'button', + label: 'Next →', + variant: 'primary', + group: 'right', + action: 'next', + }, + ], + }, + 'desktop-install': { + id: 'desktop-install', + createSlide: ({ osLabel, osUrl }) => DesktopInstallSlide({ osLabel, osUrl }), + hero: { type: 'dual-icon' }, + buttons: [ + { + key: 'desktop-back', + type: 'icon', + icon: 'chevron-left', + group: 'left', + action: 'prev', + }, + { + key: 'desktop-skip', + type: 'button', + label: 'Skip for now', + variant: 'secondary', + group: 'left', + action: 'next', + }, + { + key: 'desktop-download', + type: 'button', + label: 'Download →', + variant: 'primary', + group: 'right', + action: 'download-selected', + }, + ], + }, + 'security-check': { + id: 'security-check', + createSlide: ({ selectedRole, onRoleSelect }) => + SecurityCheckSlide({ selectedRole, onRoleSelect }), + hero: { type: 'shield' }, + buttons: [ + { + key: 'security-back', + type: 'button', + label: 'Back', + variant: 'secondary', + group: 'left', + action: 'prev', + }, + { + key: 'security-next', + type: 'button', + label: 'Next →', + variant: 'primary', + group: 'right', + action: 'security-next', + disabledWhen: (state) => !state.selectedRole, + }, + ], + }, + 'admin-overview': { + id: 'admin-overview', + createSlide: ({ licenseNotice }) => PlanOverviewSlide({ isAdmin: true, licenseNotice }), + hero: { type: 'diamond' }, + buttons: [ + { + key: 'admin-back', + type: 'icon', + icon: 'chevron-left', + group: 'left', + action: 'prev', + }, + { + key: 'admin-show', + type: 'button', + label: 'Show me around', + variant: 'primary', + group: 'right', + action: 'launch-admin', + }, + { + key: 'admin-skip', + type: 'button', + label: 'Skip the tour', + variant: 'secondary', + group: 'left', + action: 'skip-to-license', + }, + ], + }, + 'server-license': { + id: 'server-license', + createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }), + hero: { type: 'logo' }, + buttons: [ + { + key: 'license-close', + type: 'button', + label: 'Skip for now', + variant: 'secondary', + group: 'left', + action: 'close', + }, + { + key: 'license-see-plans', + type: 'button', + label: 'See Plans →', + variant: 'primary', + group: 'right', + action: 'see-plans', + }, + ], + }, +}; + +export const FLOW_SEQUENCES = { + loginAdmin: ['welcome', 'desktop-install', 'admin-overview'] as SlideId[], + loginUser: ['welcome', 'desktop-install'] as SlideId[], + noLoginBase: ['welcome', 'desktop-install', 'security-check'] as SlideId[], + noLoginAdmin: ['admin-overview'] as SlideId[], +}; + + diff --git a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css index 36cb84308..5e5799ec2 100644 --- a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css +++ b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css @@ -4,11 +4,37 @@ height: 220px; overflow: hidden; border-radius: 0; - background-size: 180% 180%; - animation: gradientShift 18s ease-in-out infinite alternate; } -.heroActive {} +.gradientLayer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: 180% 180%; + opacity: 0; + transition: opacity 0.8s ease-in-out; + z-index: 1; +} + +.gradientLayerActive { + opacity: 1; + animation: gradientShift 18s ease-in-out infinite alternate; + z-index: 2; +} + +.gradientLayerPrev { + opacity: 1; + z-index: 3; + transition: opacity 0.8s ease-in-out; +} + +.gradientLayerPrevFadeOut { + opacity: 0; + z-index: 3; + transition: opacity 0.8s ease-in-out; +} .circle { position: absolute; @@ -22,6 +48,7 @@ animation-duration: var(--circle-duration, 15s); animation-delay: var(--circle-delay, 0s); will-change: transform; + z-index: 10; } @keyframes gradientShift { diff --git a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx index 0d973d4da..d2187e3d5 100644 --- a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx +++ b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx @@ -20,19 +20,59 @@ export default function AnimatedSlideBackground({ isActive, slideKey, }: AnimatedSlideBackgroundComponentProps) { - const gradientStyle = React.useMemo( + const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null); + const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops); + const [isTransitioning, setIsTransitioning] = React.useState(false); + const isFirstMount = React.useRef(true); + + React.useEffect(() => { + // Skip transition on first mount + if (isFirstMount.current) { + isFirstMount.current = false; + setCurrentGradient(gradientStops); + return; + } + + // Only transition if gradient actually changed + if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) { + // Store previous gradient and start transition + setPrevGradient(currentGradient); + setIsTransitioning(true); + + // Update to new gradient (will fade in) + setCurrentGradient(gradientStops); + } + }, [gradientStops]); + + const currentGradientStyle = React.useMemo( () => ({ - backgroundImage: `linear-gradient(135deg, ${gradientStops[0]}, ${gradientStops[1]})`, + backgroundImage: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`, }), - [gradientStops], + [currentGradient], ); + const prevGradientStyle = prevGradient + ? { + backgroundImage: `linear-gradient(135deg, ${prevGradient[0]}, ${prevGradient[1]})`, + } + : null; + return ( -
+
+ {prevGradientStyle && isTransitioning && ( +
{ + setPrevGradient(null); + setIsTransitioning(false); + }} + /> + )} +
{circles.map((circle, index) => { const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle; @@ -65,7 +105,7 @@ export default function AnimatedSlideBackground({ return (
diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx index 3c06a8775..a759611ae 100644 --- a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { SlideConfig } from './types'; +import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig'; interface DesktopInstallSlideProps { osLabel: string; @@ -20,29 +21,7 @@ export default function DesktopInstallSlide({ osLabel, osUrl }: DesktopInstallSl downloadUrl: osUrl, background: { gradientStops: ['#2563EB', '#0EA5E9'], - circles: [ - { - position: 'bottom-left', - size: 260, - color: 'rgba(255, 255, 255, 0.2)', - opacity: 0.88, - amplitude: 24, - duration: 11, - offsetX: 16, - offsetY: 12, - }, - { - position: 'top-right', - size: 300, - color: 'rgba(28, 155, 235, 0.34)', - opacity: 0.86, - amplitude: 28, - duration: 12, - delay: 1, - offsetX: 20, - offsetY: 16, - }, - ], + circles: UNIFIED_CIRCLE_CONFIG, }, }; } diff --git a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx index e5e5218c4..d344f68f7 100644 --- a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx @@ -1,48 +1,35 @@ import React from 'react'; -import { SlideConfig } from './types'; +import { SlideConfig, LicenseNotice } from './types'; +import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig'; interface PlanOverviewSlideProps { isAdmin: boolean; + licenseNotice?: LicenseNotice; } -export default function PlanOverviewSlide({ isAdmin }: PlanOverviewSlideProps): SlideConfig { +const DEFAULT_FREE_TIER_LIMIT = 5; + +export default function PlanOverviewSlide({ isAdmin, licenseNotice }: PlanOverviewSlideProps): SlideConfig { + const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT; + + const adminBody = ( + + As an admin, you can manage users, configure settings, and monitor server health. The first{' '} + {freeTierLimit} people on your server get to use Stirling free of charge. + + ); + return { key: isAdmin ? 'admin-overview' : 'plan-overview', title: isAdmin ? 'Admin Overview' : 'Plan Overview', - body: isAdmin ? ( + body: isAdmin ? adminBody : ( - As an admin, you can manage users, configure settings, and monitor server health. The first 5 people on your server get to use Stirling free of charge. - - ) : ( - - For the next 30 days, you'll enjoy unlimited Pro access to the Reader and the Editor. Afterwards, you can continue with the Reader for free or upgrade to keep the Editor too. + Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use. ), background: { - gradientStops: ['#F97316', '#EF4444'], - circles: [ - { - position: 'bottom-left', - size: 260, - color: 'rgba(255, 255, 255, 0.25)', - opacity: 0.9, - amplitude: 26, - duration: 11, - offsetX: 18, - offsetY: 12, - }, - { - position: 'top-right', - size: 300, - color: 'rgba(251, 191, 36, 0.4)', - opacity: 0.9, - amplitude: 30, - duration: 12, - delay: 1.4, - offsetX: 24, - offsetY: 18, - }, - ], + gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'], + circles: UNIFIED_CIRCLE_CONFIG, }, }; } diff --git a/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx new file mode 100644 index 000000000..515542bc7 --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Select } from '@mantine/core'; +import styles from '../InitialOnboardingModal/InitialOnboardingModal.module.css'; +import { SlideConfig } from './types'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig'; + +interface SecurityCheckSlideProps { + selectedRole: 'admin' | 'user' | null; + onRoleSelect: (role: 'admin' | 'user' | null) => void; +} + +export default function SecurityCheckSlide({ + selectedRole, + onRoleSelect, +}: SecurityCheckSlideProps): SlideConfig { + return { + key: 'security-check', + title: 'Security Check', + body: ( +
+
+
+ + Oops! Your Stirling server doesn't have an admin yet. +
+ +