diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 35ddddeaa..7e64ea9a7 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -357,6 +357,7 @@ public class ApplicationProperties { private Boolean enableAnalytics; private Boolean enablePosthog; private Boolean enableScarf; + private Boolean enableDesktopInstallSlide; private Datasource datasource; private Boolean disableSanitize; private int maxDPI; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 43aaecd9d..91aa9924d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -124,6 +124,9 @@ public class ConfigController { "enableAnalytics", applicationProperties.getSystem().getEnableAnalytics()); configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog()); configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf()); + configData.put( + "enableDesktopInstallSlide", + applicationProperties.getSystem().getEnableDesktopInstallSlide()); // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 5a50ef903..64b4bd50e 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -126,6 +126,7 @@ system: customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored. enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch + enableDesktopInstallSlide: true # Set to 'false' to hide the desktop app installation slide in the onboarding flow enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 83963987d..3f269a52c 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -312,10 +312,10 @@ yamlAdvert = "Stirling PDF Pro supports YAML configuration files and other SSO f ssoAdvert = "Looking for more user management features? Check out Stirling PDF Pro" [analytics] -title = "Do you want make Stirling PDF better?" -paragraph1 = "Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents." +title = "Do you want to help make Stirling PDF better?" +paragraph1 = "Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents." paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better." -learnMore = "Learn more" +learnMore = "Learn more about our analytics" enable = "Enable analytics" disable = "Disable analytics" settings = "You can change the settings for analytics in the config/settings.yml file" @@ -340,6 +340,10 @@ advance = "Advanced" edit = "View & Edit" popular = "Popular" +[footer] +discord = "Discord" +issues = "GitHub" + [settings.preferences] title = "Preferences" @@ -4060,12 +4064,20 @@ settings = "Settings" adminSettings = "Admin Settings" allTools = "Tools" reader = "Reader" +tours = "Tours" +showMeAround = "Show me around" + +[quickAccess.toursTooltip] +admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour." +user = "Watch walkthroughs here: Tools tour and the New V2 layout tour." [quickAccess.helpMenu] toolsTour = "Tools Tour" toolsTourDesc = "Learn what the tools can do" adminTour = "Admin Tour" adminTourDesc = "Explore admin settings & features" +whatsNewTour = "See what's new in V2" +whatsNewTourDesc = "Tour the updated layout" [admin] error = "Error" @@ -5259,6 +5271,16 @@ finish = "Finish" startTour = "Start Tour" startTourDescription = "Take a guided tour of Stirling PDF's key features" +[onboarding.whatsNew] +quickAccess = "Start at the Quick Access rail to jump between Reader, Automate, your files, and all the tours." +leftPanel = "The left Tools panel lists everything you can do. Browse categories or search to find a tool quickly." +fileUpload = "Use the Files button to upload or pick a recent PDF. We will load a sample so you can see the workspace." +rightRail = "The Right Rail holds quick actions to select files, change theme or language, and download results." +topBar = "The top bar lets you swap between Viewer, Page Editor, and Active Files." +pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages." +activeFilesView = "Use Active Files to see everything you have open and pick what to work on." +wrapUp = "That is what is new in V2. Open the Tours menu anytime to replay this, the Tools tour, or the Admin tour." + [onboarding.welcomeModal] title = "Welcome to Stirling PDF!" description = "Would you like to take a quick 1-minute tour to learn the key features and how to get started?" @@ -5279,6 +5301,10 @@ download = "Download →" showMeAround = "Show me around" skipTheTour = "Skip the tour" +[onboarding.tourOverview] +title = "Tour Overview" +body = "Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need." + [onboarding.serverLicense] skip = "Skip for now" seePlans = "See Plans →" @@ -5817,6 +5843,8 @@ notAvailable = "Audit system not available" notAvailableMessage = "The audit system is not configured or not available." disabled = "Audit logging is disabled" disabledMessage = "Enable audit logging in your application configuration to track system events." +enterpriseRequired = "Enterprise License Required" +enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics." [audit.error] title = "Error loading audit system" diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx index ae90c27da..c50ae7b43 100644 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx @@ -47,11 +47,11 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi ) { return t('onboarding.serverLicense.upgrade', 'Upgrade now →'); } - + // Translate the label (it's a translation key) const label = button.label ?? ''; if (!label) return ''; - + // Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next') const fallback = label.split('.').pop() || label; return t(label, fallback); @@ -105,4 +105,4 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi {rightButtons.map(renderButton)} ); -} \ No newline at end of file +} diff --git a/frontend/src/core/components/onboarding/Onboarding.tsx b/frontend/src/core/components/onboarding/Onboarding.tsx index e851ee60b..bcfa449bf 100644 --- a/frontend/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/src/core/components/onboarding/Onboarding.tsx @@ -5,7 +5,6 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { isAuthRoute } from '@app/constants/routes'; import { dispatchTourState } from '@app/constants/events'; import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator'; -import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage'; import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding'; import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour'; import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide'; @@ -20,10 +19,12 @@ import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext'; import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext'; import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig'; import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig'; +import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig'; import { removeAllGlows } from '@app/components/onboarding/tourGlow'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useServerExperience } from '@app/hooks/useServerExperience'; -import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import apiClient from '@app/services/apiClient'; import '@app/components/onboarding/OnboardingTour.css'; export default function Onboarding() { @@ -39,6 +40,11 @@ export default function Onboarding() { const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload(); const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest(); const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest(); + const { config, refetch: refetchConfig } = useAppConfig(); + const [analyticsError, setAnalyticsError] = useState(null); + const [analyticsLoading, setAnalyticsLoading] = useState(false); + const [showAnalyticsModal, setShowAnalyticsModal] = useState(false); + const [analyticsModalDismissed, setAnalyticsModalDismissed] = useState(false); const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => { actions.updateRuntimeState({ selectedRole: role }); @@ -50,7 +56,34 @@ export default function Onboarding() { window.location.href = '/login'; }, [actions]); - const handleButtonAction = useCallback((action: ButtonAction) => { + // Check if we should show analytics modal before onboarding + useEffect(() => { + if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) { + setShowAnalyticsModal(true); + } + }, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]); + + const handleAnalyticsChoice = useCallback(async (enableAnalytics: boolean) => { + if (analyticsLoading) return; + setAnalyticsLoading(true); + setAnalyticsError(null); + + try { + const formData = new FormData(); + formData.append('enabled', enableAnalytics.toString()); + + await apiClient.post('/api/v1/settings/update-enable-analytics', formData); + await refetchConfig(); + setShowAnalyticsModal(false); + setAnalyticsModalDismissed(true); + setAnalyticsLoading(false); + } catch (error) { + setAnalyticsError(error instanceof Error ? error.message : 'Unknown error'); + setAnalyticsLoading(false); + } + }, [analyticsLoading, refetchConfig]); + + const handleButtonAction = useCallback(async (action: ButtonAction) => { switch (action) { case 'next': case 'complete-close': @@ -69,35 +102,43 @@ export default function Onboarding() { case 'security-next': if (!runtimeState.selectedRole) return; if (runtimeState.selectedRole !== 'admin') { - actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' }); + actions.updateRuntimeState({ tourType: 'whatsnew' }); + setIsTourOpen(true); } actions.complete(); break; case 'launch-admin': - actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' }); - actions.complete(); + actions.updateRuntimeState({ tourType: 'admin' }); + setIsTourOpen(true); break; case 'launch-tools': - actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' }); - actions.complete(); + actions.updateRuntimeState({ tourType: 'whatsnew' }); + setIsTourOpen(true); break; case 'launch-auto': { - const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools'; - actions.updateRuntimeState({ tourRequested: true, tourType }); - actions.complete(); + const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew'; + actions.updateRuntimeState({ tourType }); + setIsTourOpen(true); break; } case 'skip-to-license': - markStepSeen('tour'); - actions.updateRuntimeState({ tourRequested: false }); + actions.complete(); + break; + case 'skip-tour': actions.complete(); break; case 'see-plans': actions.complete(); navigate('/settings/adminPlan'); break; + case 'enable-analytics': + await handleAnalyticsChoice(true); + break; + case 'disable-analytics': + await handleAnalyticsChoice(false); + break; } - }, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]); + }, [actions, handleAnalyticsChoice, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]); const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false; const [isTourOpen, setIsTourOpen] = useState(false); @@ -117,10 +158,7 @@ export default function Onboarding() { backToAllTools: tourOrch.backToAllTools, selectCropTool: tourOrch.selectCropTool, loadSampleFile: tourOrch.loadSampleFile, - switchToViewer: tourOrch.switchToViewer, - switchToPageEditor: tourOrch.switchToPageEditor, switchToActiveFiles: tourOrch.switchToActiveFiles, - selectFirstFile: tourOrch.selectFirstFile, pinFile: tourOrch.pinFile, modifyCropSettings: tourOrch.modifyCropSettings, executeTool: tourOrch.executeTool, @@ -130,6 +168,24 @@ export default function Onboarding() { [t, tourOrch, closeFilesModal, openFilesModal] ); + const whatsNewStepsConfig = useMemo( + () => createWhatsNewStepsConfig({ + t, + actions: { + saveWorkbenchState: tourOrch.saveWorkbenchState, + closeFilesModal, + backToAllTools: tourOrch.backToAllTools, + openFilesModal, + loadSampleFile: tourOrch.loadSampleFile, + switchToViewer: tourOrch.switchToViewer, + switchToPageEditor: tourOrch.switchToPageEditor, + switchToActiveFiles: tourOrch.switchToActiveFiles, + selectFirstFile: tourOrch.selectFirstFile, + }, + }), + [t, tourOrch, closeFilesModal, openFilesModal] + ); + const adminStepsConfig = useMemo( () => createAdminStepsConfig({ t, @@ -144,21 +200,19 @@ export default function Onboarding() { ); const tourSteps = useMemo(() => { - const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig; - return Object.values(config); - }, [adminStepsConfig, runtimeState.tourType, userStepsConfig]); - - useEffect(() => { - if (currentStep?.id === 'tour' && !isTourOpen) { - markStepSeen('tour'); - setIsTourOpen(true); + switch (runtimeState.tourType) { + case 'admin': + return Object.values(adminStepsConfig); + case 'whatsnew': + return Object.values(whatsNewStepsConfig); + default: + return Object.values(userStepsConfig); } - }, [currentStep, isTourOpen, activeFlow]); + }, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]); useEffect(() => { if (externalTourRequested) { - actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType }); - markStepSeen('tour'); + actions.updateRuntimeState({ tourType: requestedTourType }); setIsTourOpen(true); clearTourRequest(); } @@ -176,9 +230,9 @@ export default function Onboarding() { } else { tourOrch.restoreWorkbenchState(); } - markStepSeen('tour'); - if (currentStep?.id === 'tour') actions.complete(); - }, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]); + // Advance to next onboarding step after tour completes + actions.complete(); + }, [actions, adminTourOrch, runtimeState.tourType, tourOrch]); const handleAdvanceTour = useCallback((args: AdvanceArgs) => { const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args; @@ -216,8 +270,10 @@ export default function Onboarding() { firstLoginUsername: runtimeState.firstLoginUsername, onPasswordChanged: handlePasswordChanged, usingDefaultCredentials: runtimeState.usingDefaultCredentials, + analyticsError, + analyticsLoading, }); - }, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]); + }, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]); const modalSlideCount = useMemo(() => { return activeFlow.filter((step) => step.type === 'modal-slide').length; @@ -237,8 +293,45 @@ export default function Onboarding() { return null; } + // Show analytics modal before onboarding if needed + if (showAnalyticsModal) { + const slideDefinition = SLIDE_DEFINITIONS['analytics-choice']; + const slideContent = slideDefinition.createSlide({ + osLabel: '', + osUrl: '', + selectedRole: null, + onRoleSelect: () => {}, + analyticsError, + analyticsLoading, + }); + + return ( + {}} // No skip allowed + onAction={async (action) => { + if (action === 'enable-analytics') { + await handleAnalyticsChoice(true); + } else if (action === 'disable-analytics') { + await handleAnalyticsChoice(false); + } + }} + allowDismiss={false} + /> + ); + } + if (showLicenseSlide) { - const slideDefinition = SLIDE_DEFINITIONS['server-license']; + const baseSlideDefinition = SLIDE_DEFINITIONS['server-license']; + // Remove back button for external license notice + const slideDefinition = { + ...baseSlideDefinition, + buttons: baseSlideDefinition.buttons.filter(btn => btn.key !== 'license-back') + }; const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice; const slideContent = slideDefinition.createSlide({ osLabel: '', @@ -250,7 +343,7 @@ export default function Onboarding() { licenseNotice: effectiveLicenseNotice, loginEnabled: serverExperience.loginEnabled, }); - + return ( + ); + + // If no active onboarding, just show the tour (which may or may not be open) if (isLoading || !isActive || !currentStep) { - return ( - - ); + return tourComponent; } + // If tour is open, hide the onboarding modal and just show the tour + if (isTourOpen) { + return tourComponent; + } + + // Render the current onboarding step switch (currentStep.type) { case 'tool-prompt': return ; - case 'tour': - return ( - - ); - - case 'analytics-modal': - return ; - case 'modal-slide': if (!currentSlideDefinition || !currentSlideContent) return null; return ( diff --git a/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx b/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx index 381a608d8..60a930919 100644 --- a/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx +++ b/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx @@ -6,8 +6,9 @@ */ import React from 'react'; -import { Modal, Stack } from '@mantine/core'; +import { Modal, Stack, ActionIcon } from '@mantine/core'; import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined'; +import CloseIcon from '@mui/icons-material/Close'; import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig'; import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig'; @@ -28,6 +29,7 @@ interface OnboardingModalSlideProps { currentModalSlideIndex: number; onSkip: () => void; onAction: (action: ButtonAction) => void; + allowDismiss?: boolean; } export default function OnboardingModalSlide({ @@ -38,6 +40,7 @@ export default function OnboardingModalSlide({ currentModalSlideIndex, onSkip, onAction, + allowDismiss = true, }: OnboardingModalSlideProps) { const renderHero = () => { @@ -62,6 +65,9 @@ export default function OnboardingModalSlide({ {slideDefinition.hero.type === 'lock' && ( )} + {slideDefinition.hero.type === 'analytics' && ( + + )} {slideDefinition.hero.type === 'diamond' && } {slideDefinition.hero.type === 'logo' && ( Stirling logo @@ -75,6 +81,7 @@ export default function OnboardingModalSlide({ opened={true} onClose={onSkip} closeOnClickOutside={false} + closeOnEscape={allowDismiss} centered size="lg" radius="lg" @@ -93,6 +100,31 @@ export default function OnboardingModalSlide({ isActive slideKey={slideContent.key} /> + {allowDismiss && ( + + + + )}
{renderHero()}
@@ -114,7 +146,9 @@ export default function OnboardingModalSlide({ - + {modalSlideCount > 1 && ( + + )}
void; usingDefaultCredentials?: boolean; + analyticsError?: string | null; + analyticsLoading?: boolean; } export interface HeroDefinition { @@ -79,9 +88,9 @@ export interface SlideDefinition { export const SLIDE_DEFINITIONS: Record = { 'first-login': { id: 'first-login', - createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) => - FirstLoginSlide({ - username: firstLoginUsername || '', + createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) => + FirstLoginSlide({ + username: firstLoginUsername || '', onPasswordChanged: onPasswordChanged || (() => {}), usingDefaultCredentials: usingDefaultCredentials || false, }), @@ -194,6 +203,13 @@ export const SLIDE_DEFINITIONS: Record = { createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }), hero: { type: 'dual-icon' }, buttons: [ + { + key: 'license-back', + type: 'icon', + icon: 'chevron-left', + group: 'left', + action: 'prev', + }, { key: 'license-close', type: 'button', @@ -212,5 +228,58 @@ export const SLIDE_DEFINITIONS: Record = { }, ], }, + 'tour-overview': { + id: 'tour-overview', + createSlide: () => TourOverviewSlide(), + hero: { type: 'rocket' }, + buttons: [ + { + key: 'tour-overview-back', + type: 'icon', + icon: 'chevron-left', + group: 'left', + action: 'prev', + }, + { + key: 'tour-overview-skip', + type: 'button', + label: 'onboarding.buttons.skipForNow', + variant: 'secondary', + group: 'left', + action: 'skip-tour', + }, + { + key: 'tour-overview-show', + type: 'button', + label: 'onboarding.buttons.showMeAround', + variant: 'primary', + group: 'right', + action: 'launch-tools', + }, + ], + }, + 'analytics-choice': { + id: 'analytics-choice', + createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }), + hero: { type: 'analytics' }, + buttons: [ + { + key: 'analytics-disable', + type: 'button', + label: 'no', + variant: 'secondary', + group: 'left', + action: 'disable-analytics', + }, + { + key: 'analytics-enable', + type: 'button', + label: 'yes', + variant: 'primary', + group: 'right', + action: 'enable-analytics', + }, + ], + }, }; diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts index 60843dea1..7c9431dc4 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts @@ -5,21 +5,20 @@ export type OnboardingStepId = | 'security-check' | 'admin-overview' | 'tool-layout' - | 'tour' + | 'tour-overview' | 'server-license' | 'analytics-choice'; export type OnboardingStepType = | 'modal-slide' - | 'tool-prompt' - | 'tour' - | 'analytics-modal'; + | 'tool-prompt'; export interface OnboardingRuntimeState { selectedRole: 'admin' | 'user' | null; tourRequested: boolean; - tourType: 'admin' | 'tools'; + tourType: 'admin' | 'tools' | 'whatsnew'; isDesktopApp: boolean; + desktopSlideEnabled: boolean; analyticsNotConfigured: boolean; analyticsEnabled: boolean; licenseNotice: { @@ -42,13 +41,13 @@ export interface OnboardingStep { id: OnboardingStepId; type: OnboardingStepType; condition: (ctx: OnboardingConditionContext) => boolean; - slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license'; + slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice'; } export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { selectedRole: null, tourRequested: false, - tourType: 'tools', + tourType: 'whatsnew', isDesktopApp: false, analyticsNotConfigured: false, analyticsEnabled: false, @@ -61,6 +60,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { requiresPasswordChange: false, firstLoginUsername: '', usingDefaultCredentials: false, + desktopSlideEnabled: true, }; export const ONBOARDING_STEPS: OnboardingStep[] = [ @@ -76,18 +76,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ slideId: 'welcome', condition: () => true, }, - { - id: 'desktop-install', - type: 'modal-slide', - slideId: 'desktop-install', - condition: (ctx) => !ctx.isDesktopApp, - }, - { - id: 'security-check', - type: 'modal-slide', - slideId: 'security-check', - condition: (ctx) => !ctx.loginEnabled && !ctx.isDesktopApp, - }, { id: 'admin-overview', type: 'modal-slide', @@ -95,14 +83,27 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ condition: (ctx) => ctx.effectiveIsAdmin, }, { - id: 'tool-layout', - type: 'tool-prompt', - condition: () => true, + id: 'desktop-install', + type: 'modal-slide', + slideId: 'desktop-install', + condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled, }, { - id: 'tour', - type: 'tour', - condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin, + id: 'security-check', + type: 'modal-slide', + slideId: 'security-check', + condition: () => false, + }, + { + id: 'tool-layout', + type: 'tool-prompt', + condition: () => false, + }, + { + id: 'tour-overview', + type: 'modal-slide', + slideId: 'tour-overview', + condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin', }, { id: 'server-license', @@ -110,11 +111,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ slideId: 'server-license', condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense, }, - { - id: 'analytics-choice', - type: 'analytics-modal', - condition: (ctx) => ctx.effectiveIsAdmin && ctx.analyticsNotConfigured, - }, ]; export function getStepById(id: OnboardingStepId): OnboardingStep | undefined { diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts index ef9f51be1..9e3065614 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts @@ -1,94 +1,71 @@ -import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig'; - const STORAGE_PREFIX = 'onboarding'; +const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`; +const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`; -export function getStorageKey(stepId: OnboardingStepId): string { - return `${STORAGE_PREFIX}::${stepId}`; -} - -export function hasSeenStep(stepId: OnboardingStepId): boolean { +export function isOnboardingCompleted(): boolean { if (typeof window === 'undefined') return false; try { - return localStorage.getItem(getStorageKey(stepId)) === 'true'; + return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true'; } catch { return false; } } -export function markStepSeen(stepId: OnboardingStepId): void { +export function markOnboardingCompleted(): void { if (typeof window === 'undefined') return; try { - localStorage.setItem(getStorageKey(stepId), 'true'); + localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true'); } catch (error) { - console.error('[onboardingStorage] Error marking step as seen:', error); + console.error('[onboardingStorage] Error marking onboarding as completed:', error); } } -export function resetStepSeen(stepId: OnboardingStepId): void { +export function resetOnboardingProgress(): void { if (typeof window === 'undefined') return; try { - localStorage.removeItem(getStorageKey(stepId)); + localStorage.removeItem(ONBOARDING_COMPLETED_KEY); } catch (error) { - console.error('[onboardingStorage] Error resetting step seen:', error); + console.error('[onboardingStorage] Error resetting onboarding progress:', error); } } -export function resetAllOnboardingProgress(): void { - if (typeof window === 'undefined') return; +export function hasShownToursTooltip(): boolean { + if (typeof window === 'undefined') return false; try { - const prefix = `${STORAGE_PREFIX}::`; - const keysToRemove: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key?.startsWith(prefix)) keysToRemove.push(key); - } - keysToRemove.forEach((key) => localStorage.removeItem(key)); - } catch (error) { - console.error('[onboardingStorage] Error resetting all onboarding progress:', error); + return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true'; + } catch { + return false; } } -export function getOnboardingStorageState(): Record { - const state: Record = {}; - ONBOARDING_STEPS.forEach((step) => { - state[step.id] = hasSeenStep(step.id); - }); - return state; +export function markToursTooltipShown(): void { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(TOURS_TOOLTIP_KEY, 'true'); + } catch (error) { + console.error('[onboardingStorage] Error marking tours tooltip as shown:', error); + } } export function migrateFromLegacyPreferences(): void { if (typeof window === 'undefined') return; - + const migrationKey = `${STORAGE_PREFIX}::migrated`; - + try { // Skip if already migrated if (localStorage.getItem(migrationKey) === 'true') return; - + const prefsRaw = localStorage.getItem('stirlingpdf_preferences'); if (prefsRaw) { const prefs = JSON.parse(prefsRaw) as Record; - - // Migrate based on legacy flags - if (prefs.hasSeenIntroOnboarding === true) { - markStepSeen('welcome'); - markStepSeen('desktop-install'); - markStepSeen('security-check'); - markStepSeen('admin-overview'); + + // If user had completed onboarding in old system, mark new system as complete + if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) { + markOnboardingCompleted(); } - - if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) { - markStepSeen('tool-layout'); - } - - if (prefs.hasCompletedOnboarding === true) { - markStepSeen('tour'); - markStepSeen('analytics-choice'); - markStepSeen('server-license'); - } - } - + // Mark migration complete localStorage.setItem(migrationKey, 'true'); } catch { diff --git a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index 514b98e1c..eb3c5367e 100644 --- a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -12,8 +12,8 @@ import { DEFAULT_RUNTIME_STATE, } from '@app/components/onboarding/orchestrator/onboardingConfig'; import { - hasSeenStep, - markStepSeen, + isOnboardingCompleted, + markOnboardingCompleted, migrateFromLegacyPreferences, } from '@app/components/onboarding/orchestrator/onboardingStorage'; import { accountService } from '@app/services/accountService'; @@ -35,12 +35,15 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu if (typeof window === 'undefined') { return baseState; } - + try { const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true'; - const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools'; + const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE); + const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew') + ? sessionTourType + : 'whatsnew'; const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null; - + return { ...baseState, tourRequested, @@ -54,7 +57,7 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu function persistRuntimeState(state: Partial): void { if (typeof window === 'undefined') return; - + try { if (state.tourRequested !== undefined) { sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false'); @@ -76,7 +79,7 @@ function persistRuntimeState(state: Partial): void { function clearRuntimeStateSession(): void { if (typeof window === 'undefined') return; - + try { sessionStorage.removeItem(SESSION_TOUR_REQUESTED); sessionStorage.removeItem(SESSION_TOUR_TYPE); @@ -145,7 +148,7 @@ export function useOnboardingOrchestrator( const location = useLocation(); const bypassOnboarding = useBypassOnboarding(); - const [runtimeState, setRuntimeState] = useState(() => + const [runtimeState, setRuntimeState] = useState(() => getInitialRuntimeState(defaultState) ); const [isPaused, setIsPaused] = useState(false); @@ -166,6 +169,7 @@ export function useOnboardingOrchestrator( ...prev, analyticsEnabled: config?.enableAnalytics === true, analyticsNotConfigured: config?.enableAnalytics == null, + desktopSlideEnabled: config?.enableDesktopInstallSlide ?? true, licenseNotice: { totalUsers: serverExperience.totalUsers, freeTierLimit: serverExperience.freeTierLimit, @@ -221,7 +225,7 @@ export function useOnboardingOrchestrator( const conditionContext = useMemo(() => ({ ...serverExperience, ...runtimeState, - effectiveIsAdmin: serverExperience.effectiveIsAdmin || + effectiveIsAdmin: serverExperience.effectiveIsAdmin || (!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'), }), [serverExperience, runtimeState]); @@ -235,53 +239,44 @@ export function useOnboardingOrchestrator( // Wait for config AND admin status before calculating initial step const adminStatusResolved = !configLoading && ( - config?.enableLogin === false || - config?.enableLogin === undefined || + config?.enableLogin === false || + config?.enableLogin === undefined || config?.isAdmin !== undefined ); - + useEffect(() => { - if (configLoading || !adminStatusResolved || activeFlow.length === 0) return; + if (configLoading || !adminStatusResolved) return; - let firstUnseenIndex = -1; - for (let i = 0; i < activeFlow.length; i++) { - // Special case: first-login step should always be considered "unseen" if requiresPasswordChange is true - const isFirstLoginStep = activeFlow[i].id === 'first-login'; - const shouldTreatAsUnseen = isFirstLoginStep ? runtimeState.requiresPasswordChange : !hasSeenStep(activeFlow[i].id); - - if (shouldTreatAsUnseen) { - firstUnseenIndex = i; - break; - } - } - - // Force reset index when password change is required (overrides initialIndexSet) - if (runtimeState.requiresPasswordChange && firstUnseenIndex === 0) { + // If there are no steps to show, mark initialized/completed baseline + if (activeFlow.length === 0) { setCurrentStepIndex(0); initialIndexSet.current = true; - } else if (firstUnseenIndex === -1) { + return; + } + + // If onboarding has been completed, don't show it + if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) { setCurrentStepIndex(activeFlow.length); initialIndexSet.current = true; - } else if (!initialIndexSet.current) { - setCurrentStepIndex(firstUnseenIndex); + return; + } + + // Start from the beginning + if (!initialIndexSet.current) { + setCurrentStepIndex(0); initialIndexSet.current = true; } }, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]); const totalSteps = activeFlow.length; - - const allStepsAlreadySeen = useMemo(() => { - if (activeFlow.length === 0) return false; - return activeFlow.every(step => hasSeenStep(step.id)); - }, [activeFlow]); - - const isComplete = isInitialized && initialIndexSet.current && - (currentStepIndex >= totalSteps || allStepsAlreadySeen); - const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen) - ? activeFlow[currentStepIndex] + + const isComplete = isInitialized && + (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted()); + const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps) + ? activeFlow[currentStepIndex] : null; const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null; - const isLoading = configLoading || !adminStatusResolved || !isInitialized || + const isLoading = configLoading || !adminStatusResolved || !isInitialized || !initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0); useEffect(() => { @@ -293,35 +288,33 @@ export function useOnboardingOrchestrator( }, [isComplete]); const next = useCallback(() => { - if (currentStep) markStepSeen(currentStep.id); - setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps)); - }, [currentStep, totalSteps]); + const nextIndex = currentStepIndex + 1; + if (nextIndex >= totalSteps) { + // Reached the end, mark onboarding as completed + markOnboardingCompleted(); + } + setCurrentStepIndex(nextIndex); + }, [currentStepIndex, totalSteps]); const prev = useCallback(() => { setCurrentStepIndex((prev) => Math.max(prev - 1, 0)); }, []); const skip = useCallback(() => { - if (currentStep) markStepSeen(currentStep.id); - setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps)); - }, [currentStep, totalSteps]); + // Skip marks the entire onboarding as completed + markOnboardingCompleted(); + setCurrentStepIndex(totalSteps); + }, [totalSteps]); const complete = useCallback(() => { - if (currentStep) markStepSeen(currentStep.id); - setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps)); - }, [currentStep, totalSteps]); - - useEffect(() => { - if (!currentStep || isLoading) { - return; + const nextIndex = currentStepIndex + 1; + if (nextIndex >= totalSteps) { + // Reached the end, mark onboarding as completed + markOnboardingCompleted(); } - // Special case: never auto-complete first-login step if requiresPasswordChange is true - const isFirstLoginStep = currentStep.id === 'first-login'; + setCurrentStepIndex(nextIndex); + }, [currentStepIndex, totalSteps]); - if (!isFirstLoginStep && hasSeenStep(currentStep.id)) { - complete(); - } - }, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]); const updateRuntimeState = useCallback((updates: Partial) => { persistRuntimeState(updates); diff --git a/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx b/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx new file mode 100644 index 000000000..c04009ca4 --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Trans } from 'react-i18next'; +import { Button } from '@mantine/core'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import i18n from '@app/i18n'; +import { SlideConfig } from '@app/types/types'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; + +interface AnalyticsChoiceSlideProps { + analyticsError?: string | null; +} + +export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig { + return { + key: 'analytics-choice', + title: i18n.t('analytics.title', 'Do you want to help make Stirling PDF better?'), + body: ( +
+ }} + /> +
+ }} + /> +
+
+ +
+ {analyticsError && ( +
+ {analyticsError} +
+ )} +
+ ), + background: { + gradientStops: ['#0EA5E9', '#6366F1'], + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} + diff --git a/frontend/src/core/components/onboarding/slides/TourOverviewSlide.tsx b/frontend/src/core/components/onboarding/slides/TourOverviewSlide.tsx new file mode 100644 index 000000000..b76a10b7f --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/TourOverviewSlide.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Trans } from 'react-i18next'; +import i18n from '@app/i18n'; +import { SlideConfig } from '@app/types/types'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; + +export default function TourOverviewSlide(): SlideConfig { + return { + key: 'tour-overview', + title: i18n.t('onboarding.tourOverview.title', 'Tour Overview'), + body: ( + + }} + /> + + ), + background: { + gradientStops: ['#2563EB', '#7C3AED'], + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} + diff --git a/frontend/src/core/components/onboarding/useBypassOnboarding.ts b/frontend/src/core/components/onboarding/useBypassOnboarding.ts index 8e7d9b14f..166c89352 100644 --- a/frontend/src/core/components/onboarding/useBypassOnboarding.ts +++ b/frontend/src/core/components/onboarding/useBypassOnboarding.ts @@ -1,7 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig'; -import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage'; +import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage'; const SESSION_KEY = 'onboarding::bypass-all'; const PARAM_KEY = 'bypassOnboarding'; @@ -34,13 +33,12 @@ function setStoredBypass(enabled: boolean): void { /** * Detects the `bypassOnboarding` query parameter and stores it in session storage - * so that onboarding remains disabled while the app is open. Also marks all steps - * as seen to ensure any dependent UI elements remain hidden. + * so that onboarding remains disabled while the app is open. Also marks onboarding + * as completed to ensure any dependent UI elements remain hidden. */ export function useBypassOnboarding(): boolean { const location = useLocation(); const [bypassOnboarding, setBypassOnboarding] = useState(() => readStoredBypass()); - const stepsMarkedRef = useRef(false); const shouldBypassFromSearch = useMemo(() => { try { @@ -57,14 +55,9 @@ export function useBypassOnboarding(): boolean { setBypassOnboarding(nextBypass); if (nextBypass) { setStoredBypass(true); + markOnboardingCompleted(); } }, [shouldBypassFromSearch]); - useEffect(() => { - if (!bypassOnboarding || stepsMarkedRef.current) return; - stepsMarkedRef.current = true; - ONBOARDING_STEPS.forEach((step) => markStepSeen(step.id)); - }, [bypassOnboarding]); - return bypassOnboarding; } diff --git a/frontend/src/core/components/onboarding/useOnboardingEffects.ts b/frontend/src/core/components/onboarding/useOnboardingEffects.ts index 54c58ecee..f7eacb1f9 100644 --- a/frontend/src/core/components/onboarding/useOnboardingEffects.ts +++ b/frontend/src/core/components/onboarding/useOnboardingEffects.ts @@ -51,14 +51,14 @@ export function useTourRequest(): { clearTourRequest: () => void; } { const [tourRequested, setTourRequested] = useState(false); - const [requestedTourType, setRequestedTourType] = useState('tools'); + const [requestedTourType, setRequestedTourType] = useState('whatsnew'); useEffect(() => { if (typeof window === 'undefined') return; const handleTourRequest = (event: Event) => { const { detail } = event as CustomEvent; - setRequestedTourType(detail?.tourType ?? 'tools'); + setRequestedTourType(detail?.tourType ?? 'whatsnew'); setTourRequested(true); }; diff --git a/frontend/src/core/components/onboarding/userStepsConfig.ts b/frontend/src/core/components/onboarding/userStepsConfig.ts index 07e3da760..5a936b3d6 100644 --- a/frontend/src/core/components/onboarding/userStepsConfig.ts +++ b/frontend/src/core/components/onboarding/userStepsConfig.ts @@ -8,12 +8,8 @@ export enum TourStep { FILES_BUTTON, FILE_SOURCES, WORKBENCH, - VIEW_SWITCHER, - VIEWER, - PAGE_EDITOR, ACTIVE_FILES, FILE_CHECKBOX, - SELECT_CONTROLS, CROP_SETTINGS, RUN_BUTTON, RESULTS, @@ -28,10 +24,7 @@ interface UserStepActions { backToAllTools: () => void; selectCropTool: () => void; loadSampleFile: () => void; - switchToViewer: () => void; - switchToPageEditor: () => void; switchToActiveFiles: () => void; - selectFirstFile: () => void; pinFile: () => void; modifyCropSettings: () => void; executeTool: () => void; @@ -50,10 +43,7 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs) backToAllTools, selectCropTool, loadSampleFile, - switchToViewer, - switchToPageEditor, switchToActiveFiles, - selectFirstFile, pinFile, modifyCropSettings, executeTool, @@ -108,26 +98,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs) 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."), @@ -141,14 +111,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs) 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."), diff --git a/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts b/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts new file mode 100644 index 000000000..cb86644c4 --- /dev/null +++ b/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts @@ -0,0 +1,197 @@ +import type { StepType } from '@reactour/tour'; +import type { TFunction } from 'i18next'; + +async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise { + if (typeof document === 'undefined') return; + const start = Date.now(); + // Immediate hit + if (document.querySelector(selector)) return; + + return new Promise((resolve) => { + const check = () => { + if (document.querySelector(selector) || Date.now() - start >= timeoutMs) { + resolve(); + return; + } + setTimeout(check, intervalMs); + }; + check(); + }); +} + +async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise { + if (typeof document === 'undefined') return; + const start = Date.now(); + + return new Promise((resolve) => { + const check = () => { + const el = document.querySelector(selector); + const isVisible = !!el && el.getClientRects().length > 0; + if (isVisible || Date.now() - start >= timeoutMs) { + // Nudge Reactour to recalc positions in case layout shifted + window.dispatchEvent(new Event('resize')); + requestAnimationFrame(() => window.dispatchEvent(new Event('resize'))); + resolve(); + return; + } + setTimeout(check, intervalMs); + }; + check(); + }); +} + +export enum WhatsNewTourStep { + QUICK_ACCESS, + LEFT_PANEL, + FILE_UPLOAD, + RIGHT_RAIL, + TOP_BAR, + PAGE_EDITOR_VIEW, + ACTIVE_FILES_VIEW, + WRAP_UP, +} + +interface WhatsNewStepActions { + saveWorkbenchState: () => void; + closeFilesModal: () => void; + backToAllTools: () => void; + openFilesModal: () => void; + loadSampleFile: () => Promise | void; + switchToViewer: () => void; + switchToPageEditor: () => void; + switchToActiveFiles: () => void; + selectFirstFile: () => void; +} + +interface CreateWhatsNewStepsConfigArgs { + t: TFunction; + actions: WhatsNewStepActions; +} + +export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record { + const { + saveWorkbenchState, + closeFilesModal, + backToAllTools, + openFilesModal, + loadSampleFile, + switchToViewer, + switchToPageEditor, + switchToActiveFiles, + selectFirstFile, + } = actions; + + return { + [WhatsNewTourStep.QUICK_ACCESS]: { + selector: '[data-tour="quick-access-bar"]', + content: t( + 'onboarding.whatsNew.quickAccess', + 'Start at the Quick Access rail to jump between Reader, Automate, your files, and all the tours.' + ), + position: 'right', + padding: 10, + action: () => { + saveWorkbenchState(); + closeFilesModal(); + backToAllTools(); + }, + }, + [WhatsNewTourStep.LEFT_PANEL]: { + selector: '[data-tour="tool-panel"]', + content: t( + 'onboarding.whatsNew.leftPanel', + 'The left Tools panel lists everything you can do. Browse categories or search to find a tool quickly.' + ), + position: 'center', + padding: 0, + }, + [WhatsNewTourStep.FILE_UPLOAD]: { + selector: '[data-tour="files-button"]', + content: t( + 'onboarding.whatsNew.fileUpload', + 'Use the Files button to upload or pick a recent PDF. We will load a sample so you can see the workspace.' + ), + position: 'right', + padding: 10, + action: async () => { + openFilesModal(); + await waitForElement('[data-tour="file-sources"]', 5000, 100); + }, + actionAfter: async () => { + await Promise.resolve(loadSampleFile()); + closeFilesModal(); + switchToViewer(); + // wait for file render and top controls to mount + await waitForElement('[data-tour="view-switcher"]', 7000, 100); + await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + }, + }, + [WhatsNewTourStep.RIGHT_RAIL]: { + selector: '[data-tour="right-rail-controls"]', + highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'], + content: t( + 'onboarding.whatsNew.rightRail', + 'The Right Rail holds quick actions to select files, change theme or language, and download results.' + ), + position: 'left', + padding: 10, + action: async () => { + await waitForElement('[data-tour="right-rail-controls"]', 7000, 100); + selectFirstFile(); + }, + }, + [WhatsNewTourStep.TOP_BAR]: { + selector: '[data-tour="view-switcher"]', + content: t( + 'onboarding.whatsNew.topBar', + 'The top bar lets you swap between Viewer, Page Editor, and Active Files.' + ), + position: 'bottom', + padding: 8, + // Ensure the switcher has mounted before this step renders + action: async () => { + switchToViewer(); + await waitForElement('[data-tour="view-switcher"]', 7000, 100); + await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + }, + }, + [WhatsNewTourStep.PAGE_EDITOR_VIEW]: { + selector: '[data-tour="view-switcher"]', + content: t( + 'onboarding.whatsNew.pageEditorView', + 'Switch to the Page Editor to reorder, rotate, or delete pages.' + ), + position: 'bottom', + padding: 8, + action: async () => { + switchToPageEditor(); + await waitForElement('[data-tour="view-switcher"]', 7000, 100); + await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + }, + }, + [WhatsNewTourStep.ACTIVE_FILES_VIEW]: { + selector: '[data-tour="view-switcher"]', + content: t( + 'onboarding.whatsNew.activeFilesView', + 'Use Active Files to see everything you have open and pick what to work on.' + ), + position: 'bottom', + padding: 8, + action: async () => { + switchToActiveFiles(); + await waitForElement('[data-tour="view-switcher"]', 7000, 100); + await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + }, + }, + [WhatsNewTourStep.WRAP_UP]: { + selector: '[data-tour="help-button"]', + content: t( + 'onboarding.whatsNew.wrapUp', + 'That is what is new in V2. Open the Tours menu anytime to replay this, the Tools tour, or the Admin tour.' + ), + position: 'right', + padding: 10, + }, + }; +} + diff --git a/frontend/src/core/components/shared/AdminAnalyticsChoiceModal.tsx b/frontend/src/core/components/shared/AdminAnalyticsChoiceModal.tsx deleted file mode 100644 index f72d28452..000000000 --- a/frontend/src/core/components/shared/AdminAnalyticsChoiceModal.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { - Modal, - Stack, - Button, - Text, - Title, - Anchor, - useMantineTheme, - useComputedColorScheme, -} from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { useState } from 'react'; -import { Z_ANALYTICS_MODAL } from '@app/styles/zIndex'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import apiClient from '@app/services/apiClient'; - -interface AdminAnalyticsChoiceModalProps { - opened: boolean; - onClose: () => void; -} - -export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnalyticsChoiceModalProps) { - const { t } = useTranslation(); - const { refetch } = useAppConfig(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const theme = useMantineTheme(); - const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true }); - const isDark = computedColorScheme === 'dark'; - const privacyHighlightStyles = { - color: isDark ? '#FFFFFF' : theme.colors.blue[7], - padding: `${theme.spacing.xs} ${theme.spacing.sm}`, - borderRadius: theme.radius.md, - fontWeight: 700, - textAlign: 'center' as const, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - gap: theme.spacing.xs, - letterSpacing: 0.3, - }; - - const handleChoice = async (enableAnalytics: boolean) => { - setLoading(true); - setError(null); - - try { - const formData = new FormData(); - formData.append('enabled', enableAnalytics.toString()); - - await apiClient.post('/api/v1/settings/update-enable-analytics', formData); - - // Refetch config to apply new settings without page reload - await refetch(); - - // Close the modal after successful save - onClose(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error occurred'); - setLoading(false); - } - }; - - const handleEnable = () => { - handleChoice(true); - }; - - const handleDisable = () => { - handleChoice(false); - }; - - return ( - {}} // Prevent closing - closeOnClickOutside={false} - closeOnEscape={false} - withCloseButton={false} - size="lg" - centered - zIndex={Z_ANALYTICS_MODAL} - > - - {t('analytics.title', 'Do you want make Stirling PDF better?')} - - - {t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product.')} - - - • {t('analytics.privacyAssurance', 'We do not track any personal information or the contents of your files.')} • - - - - {t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '} - - {t('analytics.learnMore', 'Learn more')} - - - - {error && ( - - {error} - - )} - - - - - - - - - {t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')} - - - - ); -} diff --git a/frontend/src/core/components/shared/Footer.tsx b/frontend/src/core/components/shared/Footer.tsx index 172861d1b..6a03a2f54 100644 --- a/frontend/src/core/components/shared/Footer.tsx +++ b/frontend/src/core/components/shared/Footer.tsx @@ -95,6 +95,22 @@ export default function Footer({ > {t('legal.terms', 'Terms and Conditions')} + + {t('footer.discord', 'Discord')} + + + {t('footer.issues', 'GitHub')} + ((_, ref) => { const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); + const { + tooltipOpen, + manualCloseOnly, + showCloseButton, + toursMenuOpen, + setToursMenuOpen, + handleTooltipOpenChange, + } = useToursTooltip(); const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'; @@ -166,8 +176,8 @@ const QuickAccessBar = forwardRef((_, ref) => { const bottomButtons: ButtonConfig[] = [ { id: 'help', - name: t("quickAccess.help", "Help"), - icon: , + name: t("quickAccess.tours", "Tours"), + icon: , isRound: true, size: 'md', type: 'action', @@ -192,6 +202,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
{/* Fixed header outside scrollable area */} @@ -247,58 +258,83 @@ const QuickAccessBar = forwardRef((_, ref) => { // Handle help button with menu or direct action if (buttonConfig.id === 'help') { const isAdmin = config?.isAdmin === true; + const toursTooltipContent = isAdmin + ? t('quickAccess.toursTooltip.admin', 'Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.') + : t('quickAccess.toursTooltip.user', 'Watch walkthroughs here: Tools tour and the New V2 layout tour.'); + const tourItems = [ + { + key: 'whatsnew', + icon: , + title: t("quickAccess.helpMenu.whatsNewTour", "See what's new in V2"), + description: t("quickAccess.helpMenu.whatsNewTourDesc", "Tour the updated layout"), + onClick: () => requestStartTour('whatsnew'), + }, + { + key: 'tools', + icon: , + title: t("quickAccess.helpMenu.toolsTour", "Tools Tour"), + description: t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do"), + onClick: () => requestStartTour('tools'), + }, + ...(isAdmin ? [{ + key: 'admin', + icon: , + title: t("quickAccess.helpMenu.adminTour", "Admin Tour"), + description: t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features"), + onClick: () => requestStartTour('admin'), + }] : []), + ]; - // If not admin, just show button that starts tools tour directly - if (!isAdmin) { - return ( -
requestStartTour('tools')} + const helpButtonNode = ( +
+ - {renderNavButton(buttonConfig, index)} -
- ); - } - - // If admin, show menu with both options - return ( -
-
{renderNavButton(buttonConfig, index)}
- } - onClick={() => requestStartTour('tools')} - > -
-
- {t("quickAccess.helpMenu.toolsTour", "Tools Tour")} + {tourItems.map((item) => ( + +
+
+ {item.title} +
+
+ {item.description} +
-
- {t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do")} -
-
- - } - onClick={() => requestStartTour('admin')} - > -
-
- {t("quickAccess.helpMenu.adminTour", "Admin Tour")} -
-
- {t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features")} -
-
-
+ + ))}
); + + return ( + + {helpButtonNode} + + ); } const buttonNode = renderNavButton(buttonConfig, index); diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx index 980d2cfe7..10fefec02 100644 --- a/frontend/src/core/components/shared/Tooltip.tsx +++ b/frontend/src/core/components/shared/Tooltip.tsx @@ -33,6 +33,10 @@ export interface TooltipProps { disabled?: boolean; /** If false, tooltip will not open on focus (hover only) */ openOnFocus?: boolean; + /** If true, tooltip stays open until explicitly closed (ignores hover/blur/esc/outside) */ + manualCloseOnly?: boolean; + /** Show a close button even when not pinned */ + showCloseButton?: boolean; } export const Tooltip: React.FC = ({ @@ -55,6 +59,8 @@ export const Tooltip: React.FC = ({ closeOnOutside = true, disabled = false, openOnFocus = true, + manualCloseOnly = false, + showCloseButton = false, }) => { const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); @@ -81,6 +87,7 @@ export const Tooltip: React.FC = ({ const isControlled = controlledOpen !== undefined; const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; + const allowAutoClose = !manualCloseOnly; const resolvedPosition: NonNullable = useMemo(() => { const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr'; @@ -132,11 +139,11 @@ export const Tooltip: React.FC = ({ } // Not pinned and configured to close on outside - if (closeOnOutside && !insideTooltip && !insideTrigger) { + if (allowAutoClose && closeOnOutside && !insideTooltip && !insideTrigger) { setOpen(false); } }, - [isPinned, closeOnOutside, setOpen] + [isPinned, closeOnOutside, setOpen, allowAutoClose] ); useEffect(() => { @@ -200,10 +207,10 @@ export const Tooltip: React.FC = ({ } clearTimers(); - if (!isPinned) setOpen(false); + if (allowAutoClose && !isPinned) setOpen(false); (children.props as any)?.onPointerLeave?.(e); }, - [clearTimers, isPinned, setOpen, children.props] + [clearTimers, isPinned, setOpen, children.props, allowAutoClose] ); const handleMouseDown = useCallback( @@ -257,15 +264,16 @@ export const Tooltip: React.FC = ({ return; } clearTimers(); - if (!isPinned) setOpen(false); + if (allowAutoClose && !isPinned) setOpen(false); (children.props as any)?.onBlur?.(e); }, - [isPinned, setOpen, children.props, clearTimers] + [isPinned, setOpen, children.props, allowAutoClose, clearTimers] ); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (manualCloseOnly) return; if (e.key === 'Escape') setOpen(false); - }, [setOpen]); + }, [setOpen, manualCloseOnly]); // Keep open while pointer is over the tooltip; close when leaving it (if not pinned) const handleTooltipPointerEnter = useCallback(() => { @@ -276,9 +284,9 @@ export const Tooltip: React.FC = ({ (e: React.PointerEvent) => { const related = e.relatedTarget as Node | null; if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return; - if (!isPinned) setOpen(false); + if (allowAutoClose && !isPinned) setOpen(false); }, - [isPinned, setOpen] + [isPinned, setOpen, allowAutoClose] ); // Enhance child with handlers and ref @@ -301,6 +309,7 @@ export const Tooltip: React.FC = ({ }); const shouldShowTooltip = open; + const shouldShowCloseButton = showCloseButton || isPinned; const tooltipElement = shouldShowTooltip ? (
= ({ className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`} onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined} > - {isPinned && ( + {shouldShowCloseButton && (
)} - +
) : null; diff --git a/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts b/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts new file mode 100644 index 000000000..bed983fdd --- /dev/null +++ b/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { TOUR_STATE_EVENT, type TourStatePayload } from '@app/constants/events'; +import { isOnboardingCompleted, hasShownToursTooltip, markToursTooltipShown } from '@app/components/onboarding/orchestrator/onboardingStorage'; + +export interface ToursTooltipState { + tooltipOpen: boolean | undefined; + manualCloseOnly: boolean; + showCloseButton: boolean; + toursMenuOpen: boolean; + setToursMenuOpen: (open: boolean) => void; + handleTooltipOpenChange: (next: boolean) => void; +} + +/** + * Encapsulates all the logic for the tours tooltip: + * - Shows automatically after onboarding/tour completes (once per user) + * - Hides while the tours menu is open + * - After dismissal, reverts to hover-only tooltip + */ +export function useToursTooltip(): ToursTooltipState { + const [showToursTooltip, setShowToursTooltip] = useState(false); + const [toursMenuOpen, setToursMenuOpen] = useState(false); + const tourWasOpenRef = useRef(false); + + // Auto-show when a tour ends (fires once per user) + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleTourStateChange = (event: Event) => { + const { detail } = event as CustomEvent; + const wasOpen = tourWasOpenRef.current; + tourWasOpenRef.current = detail.isOpen; + + if (wasOpen && !detail.isOpen && !hasShownToursTooltip()) { + setShowToursTooltip(true); + } + }; + + window.addEventListener(TOUR_STATE_EVENT, handleTourStateChange); + return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange); + }, []); + + // Show once after onboarding is complete + useEffect(() => { + if (isOnboardingCompleted() && !hasShownToursTooltip()) { + setShowToursTooltip(true); + } + }, []); + + const handleDismissToursTooltip = useCallback(() => { + markToursTooltipShown(); + setShowToursTooltip(false); + }, []); + + const hasBeenDismissed = hasShownToursTooltip(); + + const handleTooltipOpenChange = useCallback( + (next: boolean) => { + if (!next) { + if (!hasBeenDismissed) { + handleDismissToursTooltip(); + } + } else if (!hasBeenDismissed && !toursMenuOpen) { + setShowToursTooltip(true); + } + }, + [hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip] + ); + + const tooltipOpen = toursMenuOpen ? false : hasBeenDismissed ? undefined : showToursTooltip; + + return { + tooltipOpen, + manualCloseOnly: !hasBeenDismissed, + showCloseButton: !hasBeenDismissed, + toursMenuOpen, + setToursMenuOpen, + handleTooltipOpenChange, + }; +} + diff --git a/frontend/src/core/components/shared/tooltip/Tooltip.module.css b/frontend/src/core/components/shared/tooltip/Tooltip.module.css index 62c4bf696..9d3bdb80a 100644 --- a/frontend/src/core/components/shared/tooltip/Tooltip.module.css +++ b/frontend/src/core/components/shared/tooltip/Tooltip.module.css @@ -39,7 +39,7 @@ background: var(--bg-raised); padding: 0.25rem; border-radius: 0.25rem; - border: 0.0625rem solid var(--primary-color, #3b82f6); + border: 0.0625rem solid var(--border-default); cursor: pointer; transition: background-color 0.2s ease, border-color 0.2s ease; z-index: 1; @@ -60,6 +60,13 @@ border-color: #ef4444 !important; } +.tooltip-pin-button:focus, +.tooltip-pin-button:focus-visible { + outline: none; + border-color: var(--border-default) !important; + background-color: var(--bg-raised) !important; +} + /* Tooltip Header */ .tooltip-header { display: flex; @@ -91,7 +98,7 @@ /* Tooltip Body */ .tooltip-body { - padding: 1rem !important; + padding: 1rem; color: var(--text-primary) !important; font-size: 0.875rem !important; line-height: 1.6 !important; diff --git a/frontend/src/core/components/shared/tooltip/TooltipContent.tsx b/frontend/src/core/components/shared/tooltip/TooltipContent.tsx index 8bd85966a..42ee19af3 100644 --- a/frontend/src/core/components/shared/tooltip/TooltipContent.tsx +++ b/frontend/src/core/components/shared/tooltip/TooltipContent.tsx @@ -5,18 +5,20 @@ import { TooltipTip } from '@app/types/tips'; interface TooltipContentProps { content?: React.ReactNode; tips?: TooltipTip[]; + extraRightPadding?: number; } export const TooltipContent: React.FC = ({ content, tips, + extraRightPadding = 0, }) => { return (
const loadSampleFile = useCallback(async () => { try { + // Hide the modal immediately so the tour targets are visible while we load + closeFilesModal(); const response = await fetch(`${BASE_PATH}/samples/Sample.pdf`); const blob = await response.blob(); const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' }); diff --git a/frontend/src/core/hooks/useServerExperience.ts b/frontend/src/core/hooks/useServerExperience.ts index 28f62c1c5..7b78a58d4 100644 --- a/frontend/src/core/hooks/useServerExperience.ts +++ b/frontend/src/core/hooks/useServerExperience.ts @@ -64,7 +64,10 @@ export function useServerExperience(): ServerExperienceValue { const loginEnabled = config?.enableLogin !== false; const configIsAdmin = Boolean(config?.isAdmin); - const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin); + // For no-login servers, treat everyone as a regular user (no effective admin) + // Commented out the previous self-reported admin path to avoid elevating users. + // const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin); + const effectiveIsAdmin = loginEnabled ? configIsAdmin : false; const hasPaidLicense = config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE'; const setSelfReportedAdmin = useCallback((value: boolean) => { diff --git a/frontend/src/core/services/auditService.ts b/frontend/src/core/services/auditService.ts index ac2da176b..30951b67b 100644 --- a/frontend/src/core/services/auditService.ts +++ b/frontend/src/core/services/auditService.ts @@ -49,7 +49,9 @@ const auditService = { * Get audit system status */ async getSystemStatus(): Promise { - const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-dashboard'); + const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-dashboard', { + suppressErrorToast: true, + }); const data = response.data; // Map V1 response to expected format diff --git a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx index 966a82f2e..51441f228 100644 --- a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx +++ b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx @@ -13,7 +13,7 @@ import { UPGRADE_BANNER_ALERT_EVENT, } from '@core/constants/events'; import { useServerExperience } from '@app/hooks/useServerExperience'; -import { hasSeenStep } from '@core/components/onboarding/orchestrator/onboardingStorage'; +import { isOnboardingCompleted } from '@core/components/onboarding/orchestrator/onboardingStorage'; const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt'; const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000; @@ -26,6 +26,7 @@ const UpgradeBanner: React.FC = () => { const onAuthRoute = isAuthRoute(location.pathname); const { openCheckout } = useCheckout(); const { + loginEnabled, totalUsers, userCountResolved, userCountLoading, @@ -34,9 +35,11 @@ const UpgradeBanner: React.FC = () => { licenseLoading, freeTierLimit, overFreeTierLimit, + weeklyActiveUsers, scenarioKey, } = useServerExperience(); - const onboardingComplete = hasSeenStep('welcome'); + const onboardingComplete = isOnboardingCompleted(); + console.log('onboardingComplete', onboardingComplete); const [friendlyVisible, setFriendlyVisible] = useState(() => { if (typeof window === 'undefined') return false; const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY); @@ -296,8 +299,18 @@ const UpgradeBanner: React.FC = () => { ); }; + const suppressForNoLogin = + !loginEnabled || + (!loginEnabled && (weeklyActiveUsers ?? Number.POSITIVE_INFINITY) > 5); + // Don't show on auth routes or if neither banner type should show - if (onAuthRoute || (!friendlyVisible && !shouldEvaluateUrgent)) { + // Also suppress entirely for no-login servers (treat them as regular users only) + // and, per request, never surface upgrade messaging there when WAU > 5. + if ( + onAuthRoute || + suppressForNoLogin || + (!friendlyVisible && !shouldEvaluateUrgent) + ) { return null; } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx index c7142b3d2..1f71b090d 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx @@ -23,8 +23,14 @@ const AdminAuditSection: React.FC = () => { setError(null); const status = await auditService.getSystemStatus(); setSystemStatus(status); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load audit system status'); + } catch (err: any) { + // Check if this is a permission/license error (403/404) + const status = err?.response?.status; + if (status === 403 || status === 404) { + setError('enterprise-license-required'); + } else { + setError(err instanceof Error ? err.message : 'Failed to load audit system status'); + } } finally { setLoading(false); } @@ -56,6 +62,16 @@ const AdminAuditSection: React.FC = () => { } if (error) { + if (error === 'enterprise-license-required') { + return ( + + {t( + 'audit.enterpriseRequiredMessage', + 'The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics.' + )} + + ); + } return ( {error} diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts index 0aa745d54..ce6b60ab0 100644 --- a/frontend/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/src/proprietary/testing/serverExperienceSimulations.ts @@ -51,6 +51,7 @@ const BASE_NO_LOGIN_CONFIG: AppConfig = { appVersion: '2.0.0', serverCertificateEnabled: false, enableAlphaFunctionality: false, + enableDesktopInstallSlide: true, serverPort: 8080, premiumEnabled: false, runningProOrHigher: false,