mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
co-authored by
James Brunton
parent
371d816ce7
commit
0064c1866e
@@ -357,6 +357,7 @@ public class ApplicationProperties {
|
|||||||
private Boolean enableAnalytics;
|
private Boolean enableAnalytics;
|
||||||
private Boolean enablePosthog;
|
private Boolean enablePosthog;
|
||||||
private Boolean enableScarf;
|
private Boolean enableScarf;
|
||||||
|
private Boolean enableDesktopInstallSlide;
|
||||||
private Datasource datasource;
|
private Datasource datasource;
|
||||||
private Boolean disableSanitize;
|
private Boolean disableSanitize;
|
||||||
private int maxDPI;
|
private int maxDPI;
|
||||||
|
|||||||
+3
@@ -124,6 +124,9 @@ public class ConfigController {
|
|||||||
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
|
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
|
||||||
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
|
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
|
||||||
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
|
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
|
||||||
|
configData.put(
|
||||||
|
"enableDesktopInstallSlide",
|
||||||
|
applicationProperties.getSystem().getEnableDesktopInstallSlide());
|
||||||
|
|
||||||
// Premium/Enterprise settings
|
// Premium/Enterprise settings
|
||||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ system:
|
|||||||
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
|
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.
|
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
|
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
|
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
|
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
|
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
|
||||||
|
|||||||
@@ -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"
|
ssoAdvert = "Looking for more user management features? Check out Stirling PDF Pro"
|
||||||
|
|
||||||
[analytics]
|
[analytics]
|
||||||
title = "Do you want make Stirling PDF better?"
|
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."
|
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."
|
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"
|
enable = "Enable analytics"
|
||||||
disable = "Disable analytics"
|
disable = "Disable analytics"
|
||||||
settings = "You can change the settings for analytics in the config/settings.yml file"
|
settings = "You can change the settings for analytics in the config/settings.yml file"
|
||||||
@@ -340,6 +340,10 @@ advance = "Advanced"
|
|||||||
edit = "View & Edit"
|
edit = "View & Edit"
|
||||||
popular = "Popular"
|
popular = "Popular"
|
||||||
|
|
||||||
|
[footer]
|
||||||
|
discord = "Discord"
|
||||||
|
issues = "GitHub"
|
||||||
|
|
||||||
[settings.preferences]
|
[settings.preferences]
|
||||||
title = "Preferences"
|
title = "Preferences"
|
||||||
|
|
||||||
@@ -4060,12 +4064,20 @@ settings = "Settings"
|
|||||||
adminSettings = "Admin Settings"
|
adminSettings = "Admin Settings"
|
||||||
allTools = "Tools"
|
allTools = "Tools"
|
||||||
reader = "Reader"
|
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]
|
[quickAccess.helpMenu]
|
||||||
toolsTour = "Tools Tour"
|
toolsTour = "Tools Tour"
|
||||||
toolsTourDesc = "Learn what the tools can do"
|
toolsTourDesc = "Learn what the tools can do"
|
||||||
adminTour = "Admin Tour"
|
adminTour = "Admin Tour"
|
||||||
adminTourDesc = "Explore admin settings & features"
|
adminTourDesc = "Explore admin settings & features"
|
||||||
|
whatsNewTour = "See what's new in V2"
|
||||||
|
whatsNewTourDesc = "Tour the updated layout"
|
||||||
|
|
||||||
[admin]
|
[admin]
|
||||||
error = "Error"
|
error = "Error"
|
||||||
@@ -5259,6 +5271,16 @@ finish = "Finish"
|
|||||||
startTour = "Start Tour"
|
startTour = "Start Tour"
|
||||||
startTourDescription = "Take a guided tour of Stirling PDF's key features"
|
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]
|
[onboarding.welcomeModal]
|
||||||
title = "Welcome to Stirling PDF!"
|
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?"
|
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"
|
showMeAround = "Show me around"
|
||||||
skipTheTour = "Skip the tour"
|
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]
|
[onboarding.serverLicense]
|
||||||
skip = "Skip for now"
|
skip = "Skip for now"
|
||||||
seePlans = "See Plans →"
|
seePlans = "See Plans →"
|
||||||
@@ -5817,6 +5843,8 @@ notAvailable = "Audit system not available"
|
|||||||
notAvailableMessage = "The audit system is not configured or not available."
|
notAvailableMessage = "The audit system is not configured or not available."
|
||||||
disabled = "Audit logging is disabled"
|
disabled = "Audit logging is disabled"
|
||||||
disabledMessage = "Enable audit logging in your application configuration to track system events."
|
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]
|
[audit.error]
|
||||||
title = "Error loading audit system"
|
title = "Error loading audit system"
|
||||||
|
|||||||
@@ -47,11 +47,11 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
|||||||
) {
|
) {
|
||||||
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
|
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate the label (it's a translation key)
|
// Translate the label (it's a translation key)
|
||||||
const label = button.label ?? '';
|
const label = button.label ?? '';
|
||||||
if (!label) return '';
|
if (!label) return '';
|
||||||
|
|
||||||
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
|
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
|
||||||
const fallback = label.split('.').pop() || label;
|
const fallback = label.split('.').pop() || label;
|
||||||
return t(label, fallback);
|
return t(label, fallback);
|
||||||
@@ -105,4 +105,4 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
|||||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import { isAuthRoute } from '@app/constants/routes';
|
import { isAuthRoute } from '@app/constants/routes';
|
||||||
import { dispatchTourState } from '@app/constants/events';
|
import { dispatchTourState } from '@app/constants/events';
|
||||||
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
|
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
|
||||||
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
|
||||||
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
|
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
|
||||||
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
|
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
|
||||||
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
|
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
|
||||||
@@ -20,10 +19,12 @@ import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
|||||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
||||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
||||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
||||||
|
import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig';
|
||||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
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';
|
import '@app/components/onboarding/OnboardingTour.css';
|
||||||
|
|
||||||
export default function Onboarding() {
|
export default function Onboarding() {
|
||||||
@@ -39,6 +40,11 @@ export default function Onboarding() {
|
|||||||
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
|
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
|
||||||
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
|
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
|
||||||
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
|
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) => {
|
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
|
||||||
actions.updateRuntimeState({ selectedRole: role });
|
actions.updateRuntimeState({ selectedRole: role });
|
||||||
@@ -50,7 +56,34 @@ export default function Onboarding() {
|
|||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}, [actions]);
|
}, [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) {
|
switch (action) {
|
||||||
case 'next':
|
case 'next':
|
||||||
case 'complete-close':
|
case 'complete-close':
|
||||||
@@ -69,35 +102,43 @@ export default function Onboarding() {
|
|||||||
case 'security-next':
|
case 'security-next':
|
||||||
if (!runtimeState.selectedRole) return;
|
if (!runtimeState.selectedRole) return;
|
||||||
if (runtimeState.selectedRole !== 'admin') {
|
if (runtimeState.selectedRole !== 'admin') {
|
||||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
actions.updateRuntimeState({ tourType: 'whatsnew' });
|
||||||
|
setIsTourOpen(true);
|
||||||
}
|
}
|
||||||
actions.complete();
|
actions.complete();
|
||||||
break;
|
break;
|
||||||
case 'launch-admin':
|
case 'launch-admin':
|
||||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
|
actions.updateRuntimeState({ tourType: 'admin' });
|
||||||
actions.complete();
|
setIsTourOpen(true);
|
||||||
break;
|
break;
|
||||||
case 'launch-tools':
|
case 'launch-tools':
|
||||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
actions.updateRuntimeState({ tourType: 'whatsnew' });
|
||||||
actions.complete();
|
setIsTourOpen(true);
|
||||||
break;
|
break;
|
||||||
case 'launch-auto': {
|
case 'launch-auto': {
|
||||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
|
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew';
|
||||||
actions.updateRuntimeState({ tourRequested: true, tourType });
|
actions.updateRuntimeState({ tourType });
|
||||||
actions.complete();
|
setIsTourOpen(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'skip-to-license':
|
case 'skip-to-license':
|
||||||
markStepSeen('tour');
|
actions.complete();
|
||||||
actions.updateRuntimeState({ tourRequested: false });
|
break;
|
||||||
|
case 'skip-tour':
|
||||||
actions.complete();
|
actions.complete();
|
||||||
break;
|
break;
|
||||||
case 'see-plans':
|
case 'see-plans':
|
||||||
actions.complete();
|
actions.complete();
|
||||||
navigate('/settings/adminPlan');
|
navigate('/settings/adminPlan');
|
||||||
break;
|
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 isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||||
const [isTourOpen, setIsTourOpen] = useState(false);
|
const [isTourOpen, setIsTourOpen] = useState(false);
|
||||||
@@ -117,10 +158,7 @@ export default function Onboarding() {
|
|||||||
backToAllTools: tourOrch.backToAllTools,
|
backToAllTools: tourOrch.backToAllTools,
|
||||||
selectCropTool: tourOrch.selectCropTool,
|
selectCropTool: tourOrch.selectCropTool,
|
||||||
loadSampleFile: tourOrch.loadSampleFile,
|
loadSampleFile: tourOrch.loadSampleFile,
|
||||||
switchToViewer: tourOrch.switchToViewer,
|
|
||||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
|
||||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||||
selectFirstFile: tourOrch.selectFirstFile,
|
|
||||||
pinFile: tourOrch.pinFile,
|
pinFile: tourOrch.pinFile,
|
||||||
modifyCropSettings: tourOrch.modifyCropSettings,
|
modifyCropSettings: tourOrch.modifyCropSettings,
|
||||||
executeTool: tourOrch.executeTool,
|
executeTool: tourOrch.executeTool,
|
||||||
@@ -130,6 +168,24 @@ export default function Onboarding() {
|
|||||||
[t, tourOrch, closeFilesModal, openFilesModal]
|
[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(
|
const adminStepsConfig = useMemo(
|
||||||
() => createAdminStepsConfig({
|
() => createAdminStepsConfig({
|
||||||
t,
|
t,
|
||||||
@@ -144,21 +200,19 @@ export default function Onboarding() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tourSteps = useMemo<StepType[]>(() => {
|
const tourSteps = useMemo<StepType[]>(() => {
|
||||||
const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
|
switch (runtimeState.tourType) {
|
||||||
return Object.values(config);
|
case 'admin':
|
||||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
|
return Object.values(adminStepsConfig);
|
||||||
|
case 'whatsnew':
|
||||||
useEffect(() => {
|
return Object.values(whatsNewStepsConfig);
|
||||||
if (currentStep?.id === 'tour' && !isTourOpen) {
|
default:
|
||||||
markStepSeen('tour');
|
return Object.values(userStepsConfig);
|
||||||
setIsTourOpen(true);
|
|
||||||
}
|
}
|
||||||
}, [currentStep, isTourOpen, activeFlow]);
|
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (externalTourRequested) {
|
if (externalTourRequested) {
|
||||||
actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
|
actions.updateRuntimeState({ tourType: requestedTourType });
|
||||||
markStepSeen('tour');
|
|
||||||
setIsTourOpen(true);
|
setIsTourOpen(true);
|
||||||
clearTourRequest();
|
clearTourRequest();
|
||||||
}
|
}
|
||||||
@@ -176,9 +230,9 @@ export default function Onboarding() {
|
|||||||
} else {
|
} else {
|
||||||
tourOrch.restoreWorkbenchState();
|
tourOrch.restoreWorkbenchState();
|
||||||
}
|
}
|
||||||
markStepSeen('tour');
|
// Advance to next onboarding step after tour completes
|
||||||
if (currentStep?.id === 'tour') actions.complete();
|
actions.complete();
|
||||||
}, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]);
|
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]);
|
||||||
|
|
||||||
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
|
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
|
||||||
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
||||||
@@ -216,8 +270,10 @@ export default function Onboarding() {
|
|||||||
firstLoginUsername: runtimeState.firstLoginUsername,
|
firstLoginUsername: runtimeState.firstLoginUsername,
|
||||||
onPasswordChanged: handlePasswordChanged,
|
onPasswordChanged: handlePasswordChanged,
|
||||||
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
|
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(() => {
|
const modalSlideCount = useMemo(() => {
|
||||||
return activeFlow.filter((step) => step.type === 'modal-slide').length;
|
return activeFlow.filter((step) => step.type === 'modal-slide').length;
|
||||||
@@ -237,8 +293,45 @@ export default function Onboarding() {
|
|||||||
return null;
|
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) {
|
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 effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
|
||||||
const slideContent = slideDefinition.createSlide({
|
const slideContent = slideDefinition.createSlide({
|
||||||
osLabel: '',
|
osLabel: '',
|
||||||
@@ -250,7 +343,7 @@ export default function Onboarding() {
|
|||||||
licenseNotice: effectiveLicenseNotice,
|
licenseNotice: effectiveLicenseNotice,
|
||||||
loginEnabled: serverExperience.loginEnabled,
|
loginEnabled: serverExperience.loginEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingModalSlide
|
<OnboardingModalSlide
|
||||||
slideDefinition={slideDefinition}
|
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) {
|
if (isLoading || !isActive || !currentStep) {
|
||||||
return (
|
return tourComponent;
|
||||||
<OnboardingTour
|
|
||||||
isOpen={isTourOpen}
|
|
||||||
tourSteps={tourSteps}
|
|
||||||
tourType={runtimeState.tourType}
|
|
||||||
isRTL={isRTL}
|
|
||||||
t={t}
|
|
||||||
onAdvance={handleAdvanceTour}
|
|
||||||
onClose={handleCloseTour}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
switch (currentStep.type) {
|
||||||
case 'tool-prompt':
|
case 'tool-prompt':
|
||||||
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
|
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':
|
case 'modal-slide':
|
||||||
if (!currentSlideDefinition || !currentSlideContent) return null;
|
if (!currentSlideDefinition || !currentSlideContent) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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 DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
|
||||||
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||||
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||||
@@ -28,6 +29,7 @@ interface OnboardingModalSlideProps {
|
|||||||
currentModalSlideIndex: number;
|
currentModalSlideIndex: number;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
onAction: (action: ButtonAction) => void;
|
onAction: (action: ButtonAction) => void;
|
||||||
|
allowDismiss?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OnboardingModalSlide({
|
export default function OnboardingModalSlide({
|
||||||
@@ -38,6 +40,7 @@ export default function OnboardingModalSlide({
|
|||||||
currentModalSlideIndex,
|
currentModalSlideIndex,
|
||||||
onSkip,
|
onSkip,
|
||||||
onAction,
|
onAction,
|
||||||
|
allowDismiss = true,
|
||||||
}: OnboardingModalSlideProps) {
|
}: OnboardingModalSlideProps) {
|
||||||
|
|
||||||
const renderHero = () => {
|
const renderHero = () => {
|
||||||
@@ -62,6 +65,9 @@ export default function OnboardingModalSlide({
|
|||||||
{slideDefinition.hero.type === 'lock' && (
|
{slideDefinition.hero.type === 'lock' && (
|
||||||
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
<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 === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
||||||
{slideDefinition.hero.type === 'logo' && (
|
{slideDefinition.hero.type === 'logo' && (
|
||||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||||
@@ -75,6 +81,7 @@ export default function OnboardingModalSlide({
|
|||||||
opened={true}
|
opened={true}
|
||||||
onClose={onSkip}
|
onClose={onSkip}
|
||||||
closeOnClickOutside={false}
|
closeOnClickOutside={false}
|
||||||
|
closeOnEscape={allowDismiss}
|
||||||
centered
|
centered
|
||||||
size="lg"
|
size="lg"
|
||||||
radius="lg"
|
radius="lg"
|
||||||
@@ -93,6 +100,31 @@ export default function OnboardingModalSlide({
|
|||||||
isActive
|
isActive
|
||||||
slideKey={slideContent.key}
|
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}`}>
|
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
|
||||||
{renderHero()}
|
{renderHero()}
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +146,9 @@ export default function OnboardingModalSlide({
|
|||||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
{modalSlideCount > 1 && (
|
||||||
|
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
<SlideButtons
|
<SlideButtons
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ interface CloseArgs {
|
|||||||
|
|
||||||
interface OnboardingTourProps {
|
interface OnboardingTourProps {
|
||||||
tourSteps: StepType[];
|
tourSteps: StepType[];
|
||||||
tourType: 'admin' | 'tools';
|
tourType: 'admin' | 'tools' | 'whatsnew';
|
||||||
isRTL: boolean;
|
isRTL: boolean;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckS
|
|||||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
||||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||||
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
|
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';
|
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||||
|
|
||||||
export type SlideId =
|
export type SlideId =
|
||||||
@@ -12,9 +14,11 @@ export type SlideId =
|
|||||||
| 'desktop-install'
|
| 'desktop-install'
|
||||||
| 'security-check'
|
| 'security-check'
|
||||||
| 'admin-overview'
|
| '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 =
|
export type ButtonAction =
|
||||||
| 'next'
|
| 'next'
|
||||||
@@ -27,7 +31,10 @@ export type ButtonAction =
|
|||||||
| 'launch-tools'
|
| 'launch-tools'
|
||||||
| 'launch-auto'
|
| 'launch-auto'
|
||||||
| 'see-plans'
|
| 'see-plans'
|
||||||
| 'skip-to-license';
|
| 'skip-to-license'
|
||||||
|
| 'skip-tour'
|
||||||
|
| 'enable-analytics'
|
||||||
|
| 'disable-analytics';
|
||||||
|
|
||||||
export interface FlowState {
|
export interface FlowState {
|
||||||
selectedRole: 'admin' | 'user' | null;
|
selectedRole: 'admin' | 'user' | null;
|
||||||
@@ -52,6 +59,8 @@ export interface SlideFactoryParams {
|
|||||||
firstLoginUsername?: string;
|
firstLoginUsername?: string;
|
||||||
onPasswordChanged?: () => void;
|
onPasswordChanged?: () => void;
|
||||||
usingDefaultCredentials?: boolean;
|
usingDefaultCredentials?: boolean;
|
||||||
|
analyticsError?: string | null;
|
||||||
|
analyticsLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HeroDefinition {
|
export interface HeroDefinition {
|
||||||
@@ -79,9 +88,9 @@ export interface SlideDefinition {
|
|||||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||||
'first-login': {
|
'first-login': {
|
||||||
id: 'first-login',
|
id: 'first-login',
|
||||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||||
FirstLoginSlide({
|
FirstLoginSlide({
|
||||||
username: firstLoginUsername || '',
|
username: firstLoginUsername || '',
|
||||||
onPasswordChanged: onPasswordChanged || (() => {}),
|
onPasswordChanged: onPasswordChanged || (() => {}),
|
||||||
usingDefaultCredentials: usingDefaultCredentials || false,
|
usingDefaultCredentials: usingDefaultCredentials || false,
|
||||||
}),
|
}),
|
||||||
@@ -194,6 +203,13 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
|||||||
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
|
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
|
||||||
hero: { type: 'dual-icon' },
|
hero: { type: 'dual-icon' },
|
||||||
buttons: [
|
buttons: [
|
||||||
|
{
|
||||||
|
key: 'license-back',
|
||||||
|
type: 'icon',
|
||||||
|
icon: 'chevron-left',
|
||||||
|
group: 'left',
|
||||||
|
action: 'prev',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'license-close',
|
key: 'license-close',
|
||||||
type: 'button',
|
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'
|
| 'security-check'
|
||||||
| 'admin-overview'
|
| 'admin-overview'
|
||||||
| 'tool-layout'
|
| 'tool-layout'
|
||||||
| 'tour'
|
| 'tour-overview'
|
||||||
| 'server-license'
|
| 'server-license'
|
||||||
| 'analytics-choice';
|
| 'analytics-choice';
|
||||||
|
|
||||||
export type OnboardingStepType =
|
export type OnboardingStepType =
|
||||||
| 'modal-slide'
|
| 'modal-slide'
|
||||||
| 'tool-prompt'
|
| 'tool-prompt';
|
||||||
| 'tour'
|
|
||||||
| 'analytics-modal';
|
|
||||||
|
|
||||||
export interface OnboardingRuntimeState {
|
export interface OnboardingRuntimeState {
|
||||||
selectedRole: 'admin' | 'user' | null;
|
selectedRole: 'admin' | 'user' | null;
|
||||||
tourRequested: boolean;
|
tourRequested: boolean;
|
||||||
tourType: 'admin' | 'tools';
|
tourType: 'admin' | 'tools' | 'whatsnew';
|
||||||
isDesktopApp: boolean;
|
isDesktopApp: boolean;
|
||||||
|
desktopSlideEnabled: boolean;
|
||||||
analyticsNotConfigured: boolean;
|
analyticsNotConfigured: boolean;
|
||||||
analyticsEnabled: boolean;
|
analyticsEnabled: boolean;
|
||||||
licenseNotice: {
|
licenseNotice: {
|
||||||
@@ -42,13 +41,13 @@ export interface OnboardingStep {
|
|||||||
id: OnboardingStepId;
|
id: OnboardingStepId;
|
||||||
type: OnboardingStepType;
|
type: OnboardingStepType;
|
||||||
condition: (ctx: OnboardingConditionContext) => boolean;
|
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 = {
|
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||||
selectedRole: null,
|
selectedRole: null,
|
||||||
tourRequested: false,
|
tourRequested: false,
|
||||||
tourType: 'tools',
|
tourType: 'whatsnew',
|
||||||
isDesktopApp: false,
|
isDesktopApp: false,
|
||||||
analyticsNotConfigured: false,
|
analyticsNotConfigured: false,
|
||||||
analyticsEnabled: false,
|
analyticsEnabled: false,
|
||||||
@@ -61,6 +60,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
|||||||
requiresPasswordChange: false,
|
requiresPasswordChange: false,
|
||||||
firstLoginUsername: '',
|
firstLoginUsername: '',
|
||||||
usingDefaultCredentials: false,
|
usingDefaultCredentials: false,
|
||||||
|
desktopSlideEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||||
@@ -76,18 +76,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
|||||||
slideId: 'welcome',
|
slideId: 'welcome',
|
||||||
condition: () => true,
|
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',
|
id: 'admin-overview',
|
||||||
type: 'modal-slide',
|
type: 'modal-slide',
|
||||||
@@ -95,14 +83,27 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
|||||||
condition: (ctx) => ctx.effectiveIsAdmin,
|
condition: (ctx) => ctx.effectiveIsAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tool-layout',
|
id: 'desktop-install',
|
||||||
type: 'tool-prompt',
|
type: 'modal-slide',
|
||||||
condition: () => true,
|
slideId: 'desktop-install',
|
||||||
|
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tour',
|
id: 'security-check',
|
||||||
type: 'tour',
|
type: 'modal-slide',
|
||||||
condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin,
|
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',
|
id: 'server-license',
|
||||||
@@ -110,11 +111,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
|||||||
slideId: 'server-license',
|
slideId: 'server-license',
|
||||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
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 {
|
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 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 {
|
export function isOnboardingCompleted(): boolean {
|
||||||
return `${STORAGE_PREFIX}::${stepId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasSeenStep(stepId: OnboardingStepId): boolean {
|
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(getStorageKey(stepId)) === 'true';
|
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true';
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markStepSeen(stepId: OnboardingStepId): void {
|
export function markOnboardingCompleted(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(getStorageKey(stepId), 'true');
|
localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true');
|
||||||
} catch (error) {
|
} 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;
|
if (typeof window === 'undefined') return;
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(getStorageKey(stepId));
|
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[onboardingStorage] Error resetting step seen:', error);
|
console.error('[onboardingStorage] Error resetting onboarding progress:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetAllOnboardingProgress(): void {
|
export function hasShownToursTooltip(): boolean {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return false;
|
||||||
try {
|
try {
|
||||||
const prefix = `${STORAGE_PREFIX}::`;
|
return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true';
|
||||||
const keysToRemove: string[] = [];
|
} catch {
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
return false;
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key?.startsWith(prefix)) keysToRemove.push(key);
|
|
||||||
}
|
|
||||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[onboardingStorage] Error resetting all onboarding progress:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOnboardingStorageState(): Record<string, boolean> {
|
export function markToursTooltipShown(): void {
|
||||||
const state: Record<string, boolean> = {};
|
if (typeof window === 'undefined') return;
|
||||||
ONBOARDING_STEPS.forEach((step) => {
|
try {
|
||||||
state[step.id] = hasSeenStep(step.id);
|
localStorage.setItem(TOURS_TOOLTIP_KEY, 'true');
|
||||||
});
|
} catch (error) {
|
||||||
return state;
|
console.error('[onboardingStorage] Error marking tours tooltip as shown:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function migrateFromLegacyPreferences(): void {
|
export function migrateFromLegacyPreferences(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
const migrationKey = `${STORAGE_PREFIX}::migrated`;
|
const migrationKey = `${STORAGE_PREFIX}::migrated`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Skip if already migrated
|
// Skip if already migrated
|
||||||
if (localStorage.getItem(migrationKey) === 'true') return;
|
if (localStorage.getItem(migrationKey) === 'true') return;
|
||||||
|
|
||||||
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
|
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
|
||||||
if (prefsRaw) {
|
if (prefsRaw) {
|
||||||
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
||||||
|
|
||||||
// Migrate based on legacy flags
|
// If user had completed onboarding in old system, mark new system as complete
|
||||||
if (prefs.hasSeenIntroOnboarding === true) {
|
if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) {
|
||||||
markStepSeen('welcome');
|
markOnboardingCompleted();
|
||||||
markStepSeen('desktop-install');
|
|
||||||
markStepSeen('security-check');
|
|
||||||
markStepSeen('admin-overview');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) {
|
|
||||||
markStepSeen('tool-layout');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prefs.hasCompletedOnboarding === true) {
|
|
||||||
markStepSeen('tour');
|
|
||||||
markStepSeen('analytics-choice');
|
|
||||||
markStepSeen('server-license');
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark migration complete
|
// Mark migration complete
|
||||||
localStorage.setItem(migrationKey, 'true');
|
localStorage.setItem(migrationKey, 'true');
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
DEFAULT_RUNTIME_STATE,
|
DEFAULT_RUNTIME_STATE,
|
||||||
} from '@app/components/onboarding/orchestrator/onboardingConfig';
|
} from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||||
import {
|
import {
|
||||||
hasSeenStep,
|
isOnboardingCompleted,
|
||||||
markStepSeen,
|
markOnboardingCompleted,
|
||||||
migrateFromLegacyPreferences,
|
migrateFromLegacyPreferences,
|
||||||
} from '@app/components/onboarding/orchestrator/onboardingStorage';
|
} from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||||
import { accountService } from '@app/services/accountService';
|
import { accountService } from '@app/services/accountService';
|
||||||
@@ -35,12 +35,15 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return baseState;
|
return baseState;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
|
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;
|
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseState,
|
...baseState,
|
||||||
tourRequested,
|
tourRequested,
|
||||||
@@ -54,7 +57,7 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
|
|||||||
|
|
||||||
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (state.tourRequested !== undefined) {
|
if (state.tourRequested !== undefined) {
|
||||||
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
|
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
|
||||||
@@ -76,7 +79,7 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
|||||||
|
|
||||||
function clearRuntimeStateSession(): void {
|
function clearRuntimeStateSession(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
|
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
|
||||||
sessionStorage.removeItem(SESSION_TOUR_TYPE);
|
sessionStorage.removeItem(SESSION_TOUR_TYPE);
|
||||||
@@ -145,7 +148,7 @@ export function useOnboardingOrchestrator(
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const bypassOnboarding = useBypassOnboarding();
|
const bypassOnboarding = useBypassOnboarding();
|
||||||
|
|
||||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||||
getInitialRuntimeState(defaultState)
|
getInitialRuntimeState(defaultState)
|
||||||
);
|
);
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
@@ -166,6 +169,7 @@ export function useOnboardingOrchestrator(
|
|||||||
...prev,
|
...prev,
|
||||||
analyticsEnabled: config?.enableAnalytics === true,
|
analyticsEnabled: config?.enableAnalytics === true,
|
||||||
analyticsNotConfigured: config?.enableAnalytics == null,
|
analyticsNotConfigured: config?.enableAnalytics == null,
|
||||||
|
desktopSlideEnabled: config?.enableDesktopInstallSlide ?? true,
|
||||||
licenseNotice: {
|
licenseNotice: {
|
||||||
totalUsers: serverExperience.totalUsers,
|
totalUsers: serverExperience.totalUsers,
|
||||||
freeTierLimit: serverExperience.freeTierLimit,
|
freeTierLimit: serverExperience.freeTierLimit,
|
||||||
@@ -221,7 +225,7 @@ export function useOnboardingOrchestrator(
|
|||||||
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
|
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
|
||||||
...serverExperience,
|
...serverExperience,
|
||||||
...runtimeState,
|
...runtimeState,
|
||||||
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
|
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
|
||||||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
|
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
|
||||||
}), [serverExperience, runtimeState]);
|
}), [serverExperience, runtimeState]);
|
||||||
|
|
||||||
@@ -235,53 +239,44 @@ export function useOnboardingOrchestrator(
|
|||||||
|
|
||||||
// Wait for config AND admin status before calculating initial step
|
// Wait for config AND admin status before calculating initial step
|
||||||
const adminStatusResolved = !configLoading && (
|
const adminStatusResolved = !configLoading && (
|
||||||
config?.enableLogin === false ||
|
config?.enableLogin === false ||
|
||||||
config?.enableLogin === undefined ||
|
config?.enableLogin === undefined ||
|
||||||
config?.isAdmin !== undefined
|
config?.isAdmin !== undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
|
if (configLoading || !adminStatusResolved) return;
|
||||||
|
|
||||||
let firstUnseenIndex = -1;
|
// If there are no steps to show, mark initialized/completed baseline
|
||||||
for (let i = 0; i < activeFlow.length; i++) {
|
if (activeFlow.length === 0) {
|
||||||
// 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) {
|
|
||||||
setCurrentStepIndex(0);
|
setCurrentStepIndex(0);
|
||||||
initialIndexSet.current = true;
|
initialIndexSet.current = true;
|
||||||
} else if (firstUnseenIndex === -1) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If onboarding has been completed, don't show it
|
||||||
|
if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) {
|
||||||
setCurrentStepIndex(activeFlow.length);
|
setCurrentStepIndex(activeFlow.length);
|
||||||
initialIndexSet.current = true;
|
initialIndexSet.current = true;
|
||||||
} else if (!initialIndexSet.current) {
|
return;
|
||||||
setCurrentStepIndex(firstUnseenIndex);
|
}
|
||||||
|
|
||||||
|
// Start from the beginning
|
||||||
|
if (!initialIndexSet.current) {
|
||||||
|
setCurrentStepIndex(0);
|
||||||
initialIndexSet.current = true;
|
initialIndexSet.current = true;
|
||||||
}
|
}
|
||||||
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
|
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
|
||||||
|
|
||||||
const totalSteps = activeFlow.length;
|
const totalSteps = activeFlow.length;
|
||||||
|
|
||||||
const allStepsAlreadySeen = useMemo(() => {
|
const isComplete = isInitialized &&
|
||||||
if (activeFlow.length === 0) return false;
|
(totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
|
||||||
return activeFlow.every(step => hasSeenStep(step.id));
|
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps)
|
||||||
}, [activeFlow]);
|
? activeFlow[currentStepIndex]
|
||||||
|
|
||||||
const isComplete = isInitialized && initialIndexSet.current &&
|
|
||||||
(currentStepIndex >= totalSteps || allStepsAlreadySeen);
|
|
||||||
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen)
|
|
||||||
? activeFlow[currentStepIndex]
|
|
||||||
: null;
|
: null;
|
||||||
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== 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);
|
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -293,35 +288,33 @@ export function useOnboardingOrchestrator(
|
|||||||
}, [isComplete]);
|
}, [isComplete]);
|
||||||
|
|
||||||
const next = useCallback(() => {
|
const next = useCallback(() => {
|
||||||
if (currentStep) markStepSeen(currentStep.id);
|
const nextIndex = currentStepIndex + 1;
|
||||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
if (nextIndex >= totalSteps) {
|
||||||
}, [currentStep, totalSteps]);
|
// Reached the end, mark onboarding as completed
|
||||||
|
markOnboardingCompleted();
|
||||||
|
}
|
||||||
|
setCurrentStepIndex(nextIndex);
|
||||||
|
}, [currentStepIndex, totalSteps]);
|
||||||
|
|
||||||
const prev = useCallback(() => {
|
const prev = useCallback(() => {
|
||||||
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
|
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const skip = useCallback(() => {
|
const skip = useCallback(() => {
|
||||||
if (currentStep) markStepSeen(currentStep.id);
|
// Skip marks the entire onboarding as completed
|
||||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
markOnboardingCompleted();
|
||||||
}, [currentStep, totalSteps]);
|
setCurrentStepIndex(totalSteps);
|
||||||
|
}, [totalSteps]);
|
||||||
|
|
||||||
const complete = useCallback(() => {
|
const complete = useCallback(() => {
|
||||||
if (currentStep) markStepSeen(currentStep.id);
|
const nextIndex = currentStepIndex + 1;
|
||||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
if (nextIndex >= totalSteps) {
|
||||||
}, [currentStep, totalSteps]);
|
// Reached the end, mark onboarding as completed
|
||||||
|
markOnboardingCompleted();
|
||||||
useEffect(() => {
|
|
||||||
if (!currentStep || isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Special case: never auto-complete first-login step if requiresPasswordChange is true
|
setCurrentStepIndex(nextIndex);
|
||||||
const isFirstLoginStep = currentStep.id === 'first-login';
|
}, [currentStepIndex, totalSteps]);
|
||||||
|
|
||||||
if (!isFirstLoginStep && hasSeenStep(currentStep.id)) {
|
|
||||||
complete();
|
|
||||||
}
|
|
||||||
}, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]);
|
|
||||||
|
|
||||||
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
||||||
persistRuntimeState(updates);
|
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 { useLocation } from 'react-router-dom';
|
||||||
import { ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||||
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
|
||||||
|
|
||||||
const SESSION_KEY = 'onboarding::bypass-all';
|
const SESSION_KEY = 'onboarding::bypass-all';
|
||||||
const PARAM_KEY = 'bypassOnboarding';
|
const PARAM_KEY = 'bypassOnboarding';
|
||||||
@@ -34,13 +33,12 @@ function setStoredBypass(enabled: boolean): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects the `bypassOnboarding` query parameter and stores it in session storage
|
* 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
|
* so that onboarding remains disabled while the app is open. Also marks onboarding
|
||||||
* as seen to ensure any dependent UI elements remain hidden.
|
* as completed to ensure any dependent UI elements remain hidden.
|
||||||
*/
|
*/
|
||||||
export function useBypassOnboarding(): boolean {
|
export function useBypassOnboarding(): boolean {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
|
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
|
||||||
const stepsMarkedRef = useRef(false);
|
|
||||||
|
|
||||||
const shouldBypassFromSearch = useMemo(() => {
|
const shouldBypassFromSearch = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -57,14 +55,9 @@ export function useBypassOnboarding(): boolean {
|
|||||||
setBypassOnboarding(nextBypass);
|
setBypassOnboarding(nextBypass);
|
||||||
if (nextBypass) {
|
if (nextBypass) {
|
||||||
setStoredBypass(true);
|
setStoredBypass(true);
|
||||||
|
markOnboardingCompleted();
|
||||||
}
|
}
|
||||||
}, [shouldBypassFromSearch]);
|
}, [shouldBypassFromSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!bypassOnboarding || stepsMarkedRef.current) return;
|
|
||||||
stepsMarkedRef.current = true;
|
|
||||||
ONBOARDING_STEPS.forEach((step) => markStepSeen(step.id));
|
|
||||||
}, [bypassOnboarding]);
|
|
||||||
|
|
||||||
return bypassOnboarding;
|
return bypassOnboarding;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,14 +51,14 @@ export function useTourRequest(): {
|
|||||||
clearTourRequest: () => void;
|
clearTourRequest: () => void;
|
||||||
} {
|
} {
|
||||||
const [tourRequested, setTourRequested] = useState(false);
|
const [tourRequested, setTourRequested] = useState(false);
|
||||||
const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
|
const [requestedTourType, setRequestedTourType] = useState<TourType>('whatsnew');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
const handleTourRequest = (event: Event) => {
|
const handleTourRequest = (event: Event) => {
|
||||||
const { detail } = event as CustomEvent<StartTourPayload>;
|
const { detail } = event as CustomEvent<StartTourPayload>;
|
||||||
setRequestedTourType(detail?.tourType ?? 'tools');
|
setRequestedTourType(detail?.tourType ?? 'whatsnew');
|
||||||
setTourRequested(true);
|
setTourRequested(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,8 @@ export enum TourStep {
|
|||||||
FILES_BUTTON,
|
FILES_BUTTON,
|
||||||
FILE_SOURCES,
|
FILE_SOURCES,
|
||||||
WORKBENCH,
|
WORKBENCH,
|
||||||
VIEW_SWITCHER,
|
|
||||||
VIEWER,
|
|
||||||
PAGE_EDITOR,
|
|
||||||
ACTIVE_FILES,
|
ACTIVE_FILES,
|
||||||
FILE_CHECKBOX,
|
FILE_CHECKBOX,
|
||||||
SELECT_CONTROLS,
|
|
||||||
CROP_SETTINGS,
|
CROP_SETTINGS,
|
||||||
RUN_BUTTON,
|
RUN_BUTTON,
|
||||||
RESULTS,
|
RESULTS,
|
||||||
@@ -28,10 +24,7 @@ interface UserStepActions {
|
|||||||
backToAllTools: () => void;
|
backToAllTools: () => void;
|
||||||
selectCropTool: () => void;
|
selectCropTool: () => void;
|
||||||
loadSampleFile: () => void;
|
loadSampleFile: () => void;
|
||||||
switchToViewer: () => void;
|
|
||||||
switchToPageEditor: () => void;
|
|
||||||
switchToActiveFiles: () => void;
|
switchToActiveFiles: () => void;
|
||||||
selectFirstFile: () => void;
|
|
||||||
pinFile: () => void;
|
pinFile: () => void;
|
||||||
modifyCropSettings: () => void;
|
modifyCropSettings: () => void;
|
||||||
executeTool: () => void;
|
executeTool: () => void;
|
||||||
@@ -50,10 +43,7 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
|||||||
backToAllTools,
|
backToAllTools,
|
||||||
selectCropTool,
|
selectCropTool,
|
||||||
loadSampleFile,
|
loadSampleFile,
|
||||||
switchToViewer,
|
|
||||||
switchToPageEditor,
|
|
||||||
switchToActiveFiles,
|
switchToActiveFiles,
|
||||||
selectFirstFile,
|
|
||||||
pinFile,
|
pinFile,
|
||||||
modifyCropSettings,
|
modifyCropSettings,
|
||||||
executeTool,
|
executeTool,
|
||||||
@@ -108,26 +98,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
|||||||
position: 'center',
|
position: 'center',
|
||||||
padding: 0,
|
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]: {
|
[TourStep.ACTIVE_FILES]: {
|
||||||
selector: '[data-tour="workbench"]',
|
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."),
|
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',
|
position: 'top',
|
||||||
padding: 10,
|
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]: {
|
[TourStep.CROP_SETTINGS]: {
|
||||||
selector: '[data-tour="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."),
|
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')}
|
{t('legal.terms', 'Terms and Conditions')}
|
||||||
</a>
|
</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
|
<a
|
||||||
className="footer-link px-3"
|
className="footer-link px-3"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
|
|||||||
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
|
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
|
||||||
import { ButtonConfig } from '@app/types/sidebar';
|
import { ButtonConfig } from '@app/types/sidebar';
|
||||||
import '@app/components/shared/quickAccessBar/QuickAccessBar.css';
|
import '@app/components/shared/quickAccessBar/QuickAccessBar.css';
|
||||||
|
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||||
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
|
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
|
||||||
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
|
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
|
||||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||||
@@ -18,6 +19,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
|
|||||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||||
import { requestStartTour } from '@app/constants/events';
|
import { requestStartTour } from '@app/constants/events';
|
||||||
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
|
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
|
||||||
|
import { useToursTooltip } from '@app/components/shared/quickAccessBar/useToursTooltip';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isNavButtonActive,
|
isNavButtonActive,
|
||||||
@@ -41,6 +43,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const {
|
||||||
|
tooltipOpen,
|
||||||
|
manualCloseOnly,
|
||||||
|
showCloseButton,
|
||||||
|
toursMenuOpen,
|
||||||
|
setToursMenuOpen,
|
||||||
|
handleTooltipOpenChange,
|
||||||
|
} = useToursTooltip();
|
||||||
|
|
||||||
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||||
|
|
||||||
@@ -166,8 +176,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
const bottomButtons: ButtonConfig[] = [
|
const bottomButtons: ButtonConfig[] = [
|
||||||
{
|
{
|
||||||
id: 'help',
|
id: 'help',
|
||||||
name: t("quickAccess.help", "Help"),
|
name: t("quickAccess.tours", "Tours"),
|
||||||
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
|
icon: <LocalIcon icon="explore-rounded" width="1.25rem" height="1.25rem" />,
|
||||||
isRound: true,
|
isRound: true,
|
||||||
size: 'md',
|
size: 'md',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
@@ -192,6 +202,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-sidebar="quick-access"
|
data-sidebar="quick-access"
|
||||||
|
data-tour="quick-access-bar"
|
||||||
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||||
>
|
>
|
||||||
{/* Fixed header outside scrollable area */}
|
{/* Fixed header outside scrollable area */}
|
||||||
@@ -247,58 +258,83 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
// Handle help button with menu or direct action
|
// Handle help button with menu or direct action
|
||||||
if (buttonConfig.id === 'help') {
|
if (buttonConfig.id === 'help') {
|
||||||
const isAdmin = config?.isAdmin === true;
|
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
|
const helpButtonNode = (
|
||||||
if (!isAdmin) {
|
<div data-tour="help-button">
|
||||||
return (
|
<Menu
|
||||||
<div
|
position={isRTL ? 'left' : 'right'}
|
||||||
key={buttonConfig.id}
|
offset={10}
|
||||||
data-tour="help-button"
|
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||||
onClick={() => requestStartTour('tools')}
|
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>
|
<Menu.Target>
|
||||||
<div>{renderNavButton(buttonConfig, index)}</div>
|
<div>{renderNavButton(buttonConfig, index)}</div>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
{tourItems.map((item) => (
|
||||||
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
|
<Menu.Item
|
||||||
onClick={() => requestStartTour('tools')}
|
key={item.key}
|
||||||
>
|
leftSection={item.icon}
|
||||||
<div>
|
onClick={item.onClick}
|
||||||
<div style={{ fontWeight: 500 }}>
|
>
|
||||||
{t("quickAccess.helpMenu.toolsTour", "Tools Tour")}
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
|
</Menu.Item>
|
||||||
{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.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</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);
|
const buttonNode = renderNavButton(buttonConfig, index);
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export interface TooltipProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** If false, tooltip will not open on focus (hover only) */
|
/** If false, tooltip will not open on focus (hover only) */
|
||||||
openOnFocus?: boolean;
|
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> = ({
|
export const Tooltip: React.FC<TooltipProps> = ({
|
||||||
@@ -55,6 +59,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
closeOnOutside = true,
|
closeOnOutside = true,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
openOnFocus = true,
|
openOnFocus = true,
|
||||||
|
manualCloseOnly = false,
|
||||||
|
showCloseButton = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
const [isPinned, setIsPinned] = useState(false);
|
const [isPinned, setIsPinned] = useState(false);
|
||||||
@@ -81,6 +87,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
|
|
||||||
const isControlled = controlledOpen !== undefined;
|
const isControlled = controlledOpen !== undefined;
|
||||||
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
||||||
|
const allowAutoClose = !manualCloseOnly;
|
||||||
|
|
||||||
const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => {
|
const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => {
|
||||||
const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr';
|
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
|
// Not pinned and configured to close on outside
|
||||||
if (closeOnOutside && !insideTooltip && !insideTrigger) {
|
if (allowAutoClose && closeOnOutside && !insideTooltip && !insideTrigger) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isPinned, closeOnOutside, setOpen]
|
[isPinned, closeOnOutside, setOpen, allowAutoClose]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -200,10 +207,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearTimers();
|
clearTimers();
|
||||||
if (!isPinned) setOpen(false);
|
if (allowAutoClose && !isPinned) setOpen(false);
|
||||||
(children.props as any)?.onPointerLeave?.(e);
|
(children.props as any)?.onPointerLeave?.(e);
|
||||||
},
|
},
|
||||||
[clearTimers, isPinned, setOpen, children.props]
|
[clearTimers, isPinned, setOpen, children.props, allowAutoClose]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
@@ -257,15 +264,16 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearTimers();
|
clearTimers();
|
||||||
if (!isPinned) setOpen(false);
|
if (allowAutoClose && !isPinned) setOpen(false);
|
||||||
(children.props as any)?.onBlur?.(e);
|
(children.props as any)?.onBlur?.(e);
|
||||||
},
|
},
|
||||||
[isPinned, setOpen, children.props, clearTimers]
|
[isPinned, setOpen, children.props, allowAutoClose, clearTimers]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (manualCloseOnly) return;
|
||||||
if (e.key === 'Escape') setOpen(false);
|
if (e.key === 'Escape') setOpen(false);
|
||||||
}, [setOpen]);
|
}, [setOpen, manualCloseOnly]);
|
||||||
|
|
||||||
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
|
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
|
||||||
const handleTooltipPointerEnter = useCallback(() => {
|
const handleTooltipPointerEnter = useCallback(() => {
|
||||||
@@ -276,9 +284,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
const related = e.relatedTarget as Node | null;
|
const related = e.relatedTarget as Node | null;
|
||||||
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
|
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
|
// Enhance child with handlers and ref
|
||||||
@@ -301,6 +309,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const shouldShowTooltip = open;
|
const shouldShowTooltip = open;
|
||||||
|
const shouldShowCloseButton = showCloseButton || isPinned;
|
||||||
|
|
||||||
const tooltipElement = shouldShowTooltip ? (
|
const tooltipElement = shouldShowTooltip ? (
|
||||||
<div
|
<div
|
||||||
@@ -325,7 +334,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
|
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
|
||||||
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
|
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
|
||||||
>
|
>
|
||||||
{isPinned && (
|
{shouldShowCloseButton && (
|
||||||
<button
|
<button
|
||||||
className={styles['tooltip-pin-button']}
|
className={styles['tooltip-pin-button']}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -363,7 +372,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
<span className={styles['tooltip-title']}>{header.title}</span>
|
<span className={styles['tooltip-title']}>{header.title}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<TooltipContent content={content} tips={tips} />
|
<TooltipContent content={content} tips={tips} extraRightPadding={shouldShowCloseButton ? 48 : 0} />
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : 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);
|
background: var(--bg-raised);
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
border: 0.0625rem solid var(--primary-color, #3b82f6);
|
border: 0.0625rem solid var(--border-default);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -60,6 +60,13 @@
|
|||||||
border-color: #ef4444 !important;
|
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 */
|
||||||
.tooltip-header {
|
.tooltip-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -91,7 +98,7 @@
|
|||||||
|
|
||||||
/* Tooltip Body */
|
/* Tooltip Body */
|
||||||
.tooltip-body {
|
.tooltip-body {
|
||||||
padding: 1rem !important;
|
padding: 1rem;
|
||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
font-size: 0.875rem !important;
|
font-size: 0.875rem !important;
|
||||||
line-height: 1.6 !important;
|
line-height: 1.6 !important;
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ import { TooltipTip } from '@app/types/tips';
|
|||||||
interface TooltipContentProps {
|
interface TooltipContentProps {
|
||||||
content?: React.ReactNode;
|
content?: React.ReactNode;
|
||||||
tips?: TooltipTip[];
|
tips?: TooltipTip[];
|
||||||
|
extraRightPadding?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TooltipContent: React.FC<TooltipContentProps> = ({
|
export const TooltipContent: React.FC<TooltipContentProps> = ({
|
||||||
content,
|
content,
|
||||||
tips,
|
tips,
|
||||||
|
extraRightPadding = 0,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles['tooltip-body']}`}
|
className={`${styles['tooltip-body']}`}
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
padding: '16px',
|
padding: `16px ${16 + extraRightPadding}px 16px 16px`,
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
lineHeight: '1.6'
|
lineHeight: '1.6'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface UpgradeBannerAlertPayload {
|
|||||||
freeTierLimit?: number;
|
freeTierLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TourType = 'admin' | 'tools';
|
export type TourType = 'admin' | 'tools' | 'whatsnew';
|
||||||
|
|
||||||
export interface StartTourPayload {
|
export interface StartTourPayload {
|
||||||
tourType: TourType;
|
tourType: TourType;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface AppConfig {
|
|||||||
enableAnalytics?: boolean | null;
|
enableAnalytics?: boolean | null;
|
||||||
enablePosthog?: boolean | null;
|
enablePosthog?: boolean | null;
|
||||||
enableScarf?: boolean | null;
|
enableScarf?: boolean | null;
|
||||||
|
enableDesktopInstallSlide?: boolean;
|
||||||
premiumEnabled?: boolean;
|
premiumEnabled?: boolean;
|
||||||
premiumKey?: string;
|
premiumKey?: string;
|
||||||
termsAndConditions?: string;
|
termsAndConditions?: string;
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
|
|||||||
|
|
||||||
const loadSampleFile = useCallback(async () => {
|
const loadSampleFile = useCallback(async () => {
|
||||||
try {
|
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 response = await fetch(`${BASE_PATH}/samples/Sample.pdf`);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' });
|
const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' });
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ export function useServerExperience(): ServerExperienceValue {
|
|||||||
|
|
||||||
const loginEnabled = config?.enableLogin !== false;
|
const loginEnabled = config?.enableLogin !== false;
|
||||||
const configIsAdmin = Boolean(config?.isAdmin);
|
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 hasPaidLicense = config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE';
|
||||||
|
|
||||||
const setSelfReportedAdmin = useCallback((value: boolean) => {
|
const setSelfReportedAdmin = useCallback((value: boolean) => {
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ const auditService = {
|
|||||||
* Get audit system status
|
* Get audit system status
|
||||||
*/
|
*/
|
||||||
async getSystemStatus(): Promise<AuditSystemStatus> {
|
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;
|
const data = response.data;
|
||||||
|
|
||||||
// Map V1 response to expected format
|
// Map V1 response to expected format
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
UPGRADE_BANNER_ALERT_EVENT,
|
UPGRADE_BANNER_ALERT_EVENT,
|
||||||
} from '@core/constants/events';
|
} from '@core/constants/events';
|
||||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
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 FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt';
|
||||||
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
@@ -26,6 +26,7 @@ const UpgradeBanner: React.FC = () => {
|
|||||||
const onAuthRoute = isAuthRoute(location.pathname);
|
const onAuthRoute = isAuthRoute(location.pathname);
|
||||||
const { openCheckout } = useCheckout();
|
const { openCheckout } = useCheckout();
|
||||||
const {
|
const {
|
||||||
|
loginEnabled,
|
||||||
totalUsers,
|
totalUsers,
|
||||||
userCountResolved,
|
userCountResolved,
|
||||||
userCountLoading,
|
userCountLoading,
|
||||||
@@ -34,9 +35,11 @@ const UpgradeBanner: React.FC = () => {
|
|||||||
licenseLoading,
|
licenseLoading,
|
||||||
freeTierLimit,
|
freeTierLimit,
|
||||||
overFreeTierLimit,
|
overFreeTierLimit,
|
||||||
|
weeklyActiveUsers,
|
||||||
scenarioKey,
|
scenarioKey,
|
||||||
} = useServerExperience();
|
} = useServerExperience();
|
||||||
const onboardingComplete = hasSeenStep('welcome');
|
const onboardingComplete = isOnboardingCompleted();
|
||||||
|
console.log('onboardingComplete', onboardingComplete);
|
||||||
const [friendlyVisible, setFriendlyVisible] = useState(() => {
|
const [friendlyVisible, setFriendlyVisible] = useState(() => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
|
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
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -23,8 +23,14 @@ const AdminAuditSection: React.FC = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const status = await auditService.getSystemStatus();
|
const status = await auditService.getSystemStatus();
|
||||||
setSystemStatus(status);
|
setSystemStatus(status);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load audit system status');
|
// 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -56,6 +62,16 @@ const AdminAuditSection: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
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 (
|
return (
|
||||||
<Alert color="red" title={t('audit.error.title', 'Error loading audit system')}>
|
<Alert color="red" title={t('audit.error.title', 'Error loading audit system')}>
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
|||||||
appVersion: '2.0.0',
|
appVersion: '2.0.0',
|
||||||
serverCertificateEnabled: false,
|
serverCertificateEnabled: false,
|
||||||
enableAlphaFunctionality: false,
|
enableAlphaFunctionality: false,
|
||||||
|
enableDesktopInstallSlide: true,
|
||||||
serverPort: 8080,
|
serverPort: 8080,
|
||||||
premiumEnabled: false,
|
premiumEnabled: false,
|
||||||
runningProOrHigher: false,
|
runningProOrHigher: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user