Shorten onbaording (#5198)

Also added `enableDesktopInstallSlide` flag in `settings.yml` to hide
the download for desktop page in the onboarding.

---------

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
EthanHealy01
2025-12-15 09:31:41 +00:00
committed by GitHub
co-authored by James Brunton
parent 371d816ce7
commit 0064c1866e
33 changed files with 948 additions and 475 deletions
+31 -3
View File
@@ -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 <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours."
leftPanel = "The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly."
fileUpload = "Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace."
rightRail = "The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results."
topBar = "The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>."
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 <strong>Tours</strong> 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"
@@ -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
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group>
);
}
}
@@ -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<string | null>(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<StepType[]>(() => {
const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
return Object.values(config);
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
useEffect(() => {
if (currentStep?.id === 'tour' && !isTourOpen) {
markStepSeen('tour');
setIsTourOpen(true);
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 (
<OnboardingModalSlide
slideDefinition={slideDefinition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={() => {}} // 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 (
<OnboardingModalSlide
slideDefinition={slideDefinition}
@@ -271,40 +364,34 @@ export default function Onboarding() {
);
}
// Always render the tour component (it controls its own visibility with isOpen)
const tourComponent = (
<OnboardingTour
isOpen={isTourOpen}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
// If no active onboarding, just show the tour (which may or may not be open)
if (isLoading || !isActive || !currentStep) {
return (
<OnboardingTour
isOpen={isTourOpen}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
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 <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
case 'tour':
return (
<OnboardingTour
isOpen={true}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
case 'analytics-modal':
return <AdminAnalyticsChoiceModal opened={true} onClose={actions.complete} />;
case 'modal-slide':
if (!currentSlideDefinition || !currentSlideContent) return null;
return (
@@ -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' && (
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'analytics' && (
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
{slideDefinition.hero.type === 'logo' && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
@@ -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 && (
<ActionIcon
onClick={onSkip}
radius="md"
size={36}
style={{
position: 'absolute',
top: 16,
right: 16,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(4px)',
zIndex: 10,
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
},
}}
>
<CloseIcon fontSize="small" />
</ActionIcon>
)}
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
{renderHero()}
</div>
@@ -114,7 +146,9 @@ export default function OnboardingModalSlide({
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
{modalSlideCount > 1 && (
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
)}
<div className={styles.buttonContainer}>
<SlideButtons
@@ -49,7 +49,7 @@ interface CloseArgs {
interface OnboardingTourProps {
tourSteps: StepType[];
tourType: 'admin' | 'tools';
tourType: 'admin' | 'tools' | 'whatsnew';
isRTL: boolean;
t: TFunction;
isOpen: boolean;
@@ -4,6 +4,8 @@ import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckS
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide';
import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types';
export type SlideId =
@@ -12,9 +14,11 @@ export type SlideId =
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'server-license';
| 'server-license'
| 'tour-overview'
| 'analytics-choice';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics';
export type ButtonAction =
| 'next'
@@ -27,7 +31,10 @@ export type ButtonAction =
| 'launch-tools'
| 'launch-auto'
| 'see-plans'
| 'skip-to-license';
| 'skip-to-license'
| 'skip-tour'
| 'enable-analytics'
| 'disable-analytics';
export interface FlowState {
selectedRole: 'admin' | 'user' | null;
@@ -52,6 +59,8 @@ export interface SlideFactoryParams {
firstLoginUsername?: string;
onPasswordChanged?: () => void;
usingDefaultCredentials?: boolean;
analyticsError?: string | null;
analyticsLoading?: boolean;
}
export interface HeroDefinition {
@@ -79,9 +88,9 @@ export interface SlideDefinition {
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'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<SlideId, SlideDefinition> = {
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<SlideId, SlideDefinition> = {
},
],
},
'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',
},
],
},
};
@@ -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 {
@@ -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<string, boolean> {
const state: Record<string, boolean> = {};
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<string, unknown>;
// 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 {
@@ -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<OnboardingRuntimeState>): 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<OnboardingRuntimeState>): 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<OnboardingRuntimeState>(() =>
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
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<OnboardingConditionContext>(() => ({
...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<OnboardingRuntimeState>) => {
persistRuntimeState(updates);
@@ -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: (
<div className={styles.bodyCopyInner}>
<Trans
i18nKey="analytics.paragraph1"
defaults="Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents."
components={{ strong: <strong /> }}
/>
<br />
<Trans
i18nKey="analytics.paragraph2"
defaults="Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better."
components={{ strong: <strong /> }}
/>
<br />
<div style={{ textAlign: 'right', marginTop: 0 }}>
<Button
variant="default"
size="sm"
onClick={() => window.open('https://docs.stirlingpdf.com/analytics-telemetry/', '_blank')}
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
>
{i18n.t('analytics.learnMore', 'Learn more about our analytics')}
</Button>
</div>
{analyticsError && (
<div style={{ color: 'var(--mantine-color-red-6)', marginTop: 12 }}>
{analyticsError}
</div>
)}
</div>
),
background: {
gradientStops: ['#0EA5E9', '#6366F1'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -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: (
<span className={styles.bodyCopyInner}>
<Trans
i18nKey="onboarding.tourOverview.body"
defaults="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."
components={{ strong: <strong /> }}
/>
</span>
),
background: {
gradientStops: ['#2563EB', '#7C3AED'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -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<boolean>(() => 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;
}
@@ -51,14 +51,14 @@ export function useTourRequest(): {
clearTourRequest: () => void;
} {
const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
const [requestedTourType, setRequestedTourType] = useState<TourType>('whatsnew');
useEffect(() => {
if (typeof window === 'undefined') return;
const handleTourRequest = (event: Event) => {
const { detail } = event as CustomEvent<StartTourPayload>;
setRequestedTourType(detail?.tourType ?? 'tools');
setRequestedTourType(detail?.tourType ?? 'whatsnew');
setTourRequested(true);
};
@@ -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 <strong>Viewer</strong> lets you read and annotate your PDFs."),
position: 'center',
padding: 0,
action: () => switchToViewer(),
},
[TourStep.PAGE_EDITOR]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
position: 'center',
padding: 0,
action: () => switchToPageEditor(),
},
[TourStep.ACTIVE_FILES]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
@@ -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 <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
position: 'left',
padding: 5,
action: () => selectFirstFile(),
},
[TourStep.CROP_SETTINGS]: {
selector: '[data-tour="crop-settings"]',
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
@@ -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<void> {
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<void> {
if (typeof document === 'undefined') return;
const start = Date.now();
return new Promise((resolve) => {
const check = () => {
const el = document.querySelector<HTMLElement>(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> | void;
switchToViewer: () => void;
switchToPageEditor: () => void;
switchToActiveFiles: () => void;
selectFirstFile: () => void;
}
interface CreateWhatsNewStepsConfigArgs {
t: TFunction;
actions: WhatsNewStepActions;
}
export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
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 <strong>Quick Access</strong> 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 <strong>Tools</strong> 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 <strong>Files</strong> 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 <strong>Right Rail</strong> 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 <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>.'
),
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 <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour.'
),
position: 'right',
padding: 10,
},
};
}
@@ -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<string | null>(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 (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing
closeOnClickOutside={false}
closeOnEscape={false}
withCloseButton={false}
size="lg"
centered
zIndex={Z_ANALYTICS_MODAL}
>
<Stack gap="md">
<Title order={2}>{t('analytics.title', 'Do you want make Stirling PDF better?')}</Title>
<Text size="sm" c="dimmed">
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product.')}
</Text>
<Text size="sm" style={privacyHighlightStyles}>
{t('analytics.privacyAssurance', 'We do not track any personal information or the contents of your files.')}
</Text>
<Text size="sm" c="dimmed">
{t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '}
<Anchor
href="https://docs.stirlingpdf.com/analytics-telemetry"
target="_blank"
rel="noopener noreferrer"
size="sm"
>
{t('analytics.learnMore', 'Learn more')}
</Anchor>
</Text>
{error && (
<Text c="red" size="sm">
{error}
</Text>
)}
<Stack gap="sm">
<Button
onClick={handleEnable}
loading={loading}
fullWidth
size="md"
>
{t('analytics.enable', 'Enable analytics')}
</Button>
<Button
onClick={handleDisable}
loading={loading}
fullWidth
size="md"
variant="subtle"
c="gray"
>
{t('analytics.disable', 'Disable analytics')}
</Button>
</Stack>
<Text size="xs" c="dimmed" ta="center">
{t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')}
</Text>
</Stack>
</Modal>
);
}
@@ -95,6 +95,22 @@ export default function Footer({
>
{t('legal.terms', 'Terms and Conditions')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href="https://discord.gg/Cn8pWhQRxZ"
>
{t('footer.discord', 'Discord')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/Stirling-Tools/Stirling-PDF"
>
{t('footer.issues', 'GitHub')}
</a>
<a
className="footer-link px-3"
target="_blank"
@@ -11,6 +11,7 @@ import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import { ButtonConfig } from '@app/types/sidebar';
import '@app/components/shared/quickAccessBar/QuickAccessBar.css';
import { Tooltip } from '@app/components/shared/Tooltip';
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
import AppConfigModal from '@app/components/shared/AppConfigModal';
@@ -18,6 +19,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import { requestStartTour } from '@app/constants/events';
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
import { useToursTooltip } from '@app/components/shared/quickAccessBar/useToursTooltip';
import {
isNavButtonActive,
@@ -41,6 +43,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(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<HTMLDivElement>((_, ref) => {
const bottomButtons: ButtonConfig[] = [
{
id: 'help',
name: t("quickAccess.help", "Help"),
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
name: t("quickAccess.tours", "Tours"),
icon: <LocalIcon icon="explore-rounded" width="1.25rem" height="1.25rem" />,
isRound: true,
size: 'md',
type: 'action',
@@ -192,6 +202,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div
ref={ref}
data-sidebar="quick-access"
data-tour="quick-access-bar"
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
>
{/* Fixed header outside scrollable area */}
@@ -247,58 +258,83 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, 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: <LocalIcon icon="auto-awesome-rounded" width="1.25rem" height="1.25rem" />,
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: <LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />,
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: <LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />,
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 (
<div
key={buttonConfig.id}
data-tour="help-button"
onClick={() => requestStartTour('tools')}
const helpButtonNode = (
<div data-tour="help-button">
<Menu
position={isRTL ? 'left' : 'right'}
offset={10}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
opened={toursMenuOpen}
onChange={setToursMenuOpen}
>
{renderNavButton(buttonConfig, index)}
</div>
);
}
// If admin, show menu with both options
return (
<div key={buttonConfig.id} data-tour="help-button">
<Menu position={isRTL ? 'left' : 'right'} offset={10} zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}>
<Menu.Target>
<div>{renderNavButton(buttonConfig, index)}</div>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => requestStartTour('tools')}
>
<div>
<div style={{ fontWeight: 500 }}>
{t("quickAccess.helpMenu.toolsTour", "Tools Tour")}
{tourItems.map((item) => (
<Menu.Item
key={item.key}
leftSection={item.icon}
onClick={item.onClick}
>
<div>
<div style={{ fontWeight: 500 }}>
{item.title}
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{item.description}
</div>
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do")}
</div>
</div>
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => requestStartTour('admin')}
>
<div>
<div style={{ fontWeight: 500 }}>
{t("quickAccess.helpMenu.adminTour", "Admin Tour")}
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features")}
</div>
</div>
</Menu.Item>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</div>
);
return (
<Tooltip
position="right"
arrow
offset={8}
open={tooltipOpen}
manualCloseOnly={manualCloseOnly}
showCloseButton={showCloseButton}
closeOnOutside={false}
openOnFocus={false}
content={toursTooltipContent}
onOpenChange={handleTooltipOpenChange}
>
{helpButtonNode}
</Tooltip>
);
}
const buttonNode = renderNavButton(buttonConfig, index);
+20 -11
View File
@@ -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<TooltipProps> = ({
@@ -55,6 +59,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
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<TooltipProps> = ({
const isControlled = controlledOpen !== undefined;
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
const allowAutoClose = !manualCloseOnly;
const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => {
const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr';
@@ -132,11 +139,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
}
// 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<TooltipProps> = ({
}
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<TooltipProps> = ({
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<TooltipProps> = ({
(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<TooltipProps> = ({
});
const shouldShowTooltip = open;
const shouldShowCloseButton = showCloseButton || isPinned;
const tooltipElement = shouldShowTooltip ? (
<div
@@ -325,7 +334,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
>
{isPinned && (
{shouldShowCloseButton && (
<button
className={styles['tooltip-pin-button']}
onClick={(e) => {
@@ -363,7 +372,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
<span className={styles['tooltip-title']}>{header.title}</span>
</div>
)}
<TooltipContent content={content} tips={tips} />
<TooltipContent content={content} tips={tips} extraRightPadding={shouldShowCloseButton ? 48 : 0} />
</div>
) : null;
@@ -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<TourStatePayload>;
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,
};
}
@@ -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;
@@ -5,18 +5,20 @@ import { TooltipTip } from '@app/types/tips';
interface TooltipContentProps {
content?: React.ReactNode;
tips?: TooltipTip[];
extraRightPadding?: number;
}
export const TooltipContent: React.FC<TooltipContentProps> = ({
content,
tips,
extraRightPadding = 0,
}) => {
return (
<div
className={`${styles['tooltip-body']}`}
style={{
color: 'var(--text-primary)',
padding: '16px',
padding: `16px ${16 + extraRightPadding}px 16px 16px`,
fontSize: '14px',
lineHeight: '1.6'
}}
+1 -1
View File
@@ -25,7 +25,7 @@ export interface UpgradeBannerAlertPayload {
freeTierLimit?: number;
}
export type TourType = 'admin' | 'tools';
export type TourType = 'admin' | 'tools' | 'whatsnew';
export interface StartTourPayload {
tourType: TourType;
@@ -29,6 +29,7 @@ export interface AppConfig {
enableAnalytics?: boolean | null;
enablePosthog?: boolean | null;
enableScarf?: boolean | null;
enableDesktopInstallSlide?: boolean;
premiumEnabled?: boolean;
premiumKey?: string;
termsAndConditions?: string;
@@ -111,6 +111,8 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
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' });
@@ -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) => {
+3 -1
View File
@@ -49,7 +49,9 @@ const auditService = {
* Get audit system status
*/
async getSystemStatus(): Promise<AuditSystemStatus> {
const response = await apiClient.get<any>('/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
@@ -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;
}
@@ -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 (
<Alert color="blue" title={t('audit.enterpriseRequired', 'Enterprise License Required')}>
{t(
'audit.enterpriseRequiredMessage',
'The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics.'
)}
</Alert>
);
}
return (
<Alert color="red" title={t('audit.error.title', 'Error loading audit system')}>
{error}
@@ -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,