Merge branch 'main' into codex/update-demo-page-for-admin-users

This commit is contained in:
Anthony Stirling 2025-12-15 10:39:12 +00:00 committed by GitHub
commit 7e7bad4f86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1044 additions and 565 deletions

View File

@ -52,7 +52,6 @@ jobs:
core.setOutput('repository', pr.head.repo.full_name);
core.setOutput('ref', pr.head.ref);
core.setOutput('is_fork', String(pr.head.repo.fork));
core.setOutput('base_ref', pr.base.ref);
core.setOutput('author', pr.user.login);
core.setOutput('state', pr.state);
@ -65,10 +64,6 @@ jobs:
IS_FORK: ${{ steps.resolve.outputs.is_fork }}
# nur bei workflow_dispatch gesetzt:
ALLOW_FORK_INPUT: ${{ inputs.allow_fork }}
# für Auto-PR-Logik:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_BASE: ${{ steps.resolve.outputs.base_ref }}
PR_AUTHOR: ${{ steps.resolve.outputs.author }}
run: |
set -e
@ -89,14 +84,8 @@ jobs:
else
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then
if [ "$is_auth" = true ]; then
should=true
else
title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true
branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true
if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then
should=true
fi
fi
fi
@ -174,7 +163,7 @@ jobs:
owner,
repo,
issue_number: prNumber,
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment for approved V2 contributors._\n\n⚠ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
});
return newComment.id;
@ -394,7 +383,7 @@ jobs:
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;
`🔄 **Auto-deployed** for approved V2 contributors.`;
await github.rest.issues.createComment({
owner,

View File

@ -357,6 +357,7 @@ public class ApplicationProperties {
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Boolean enableDesktopInstallSlide;
private Datasource datasource;
private Boolean disableSanitize;
private int maxDPI;

View File

@ -26,6 +26,7 @@ public class RequestUriUtils {
|| normalizedUri.startsWith("/public/")
|| normalizedUri.startsWith("/pdfjs/")
|| normalizedUri.startsWith("/pdfjs-legacy/")
|| normalizedUri.startsWith("/pdfium/")
|| normalizedUri.startsWith("/assets/")
|| normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/")
@ -61,7 +62,8 @@ public class RequestUriUtils {
|| normalizedUri.endsWith(".css")
|| normalizedUri.endsWith(".mjs")
|| normalizedUri.endsWith(".html")
|| normalizedUri.endsWith(".toml");
|| normalizedUri.endsWith(".toml")
|| normalizedUri.endsWith(".wasm");
}
public static boolean isFrontendRoute(String contextPath, String requestURI) {
@ -125,11 +127,13 @@ public class RequestUriUtils {
|| requestURI.endsWith("popularity.txt")
|| requestURI.endsWith(".js")
|| requestURI.endsWith(".toml")
|| requestURI.endsWith(".wasm")
|| requestURI.contains("swagger")
|| requestURI.startsWith("/api/v1/info")
|| requestURI.startsWith("/site.webmanifest")
|| requestURI.startsWith("/fonts")
|| requestURI.startsWith("/pdfjs"));
|| requestURI.startsWith("/pdfjs")
|| requestURI.startsWith("/pdfium"));
}
/**

View File

@ -24,6 +24,9 @@ public class RequestUriUtilsTest {
assertTrue(
RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"),
"PDF.js files should be static");
assertTrue(
RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"),
"PDFium wasm should be static");
assertTrue(
RequestUriUtils.isStaticResource("/api/v1/info/status"),
"API status should be static");
@ -110,7 +113,8 @@ public class RequestUriUtilsTest {
"/downloads/document.png",
"/assets/brand.ico",
"/any/path/with/image.svg",
"/deep/nested/folder/icon.png"
"/deep/nested/folder/icon.png",
"/pdfium/pdfium.wasm"
})
void testIsStaticResourceWithFileExtensions(String path) {
assertTrue(
@ -148,6 +152,9 @@ public class RequestUriUtilsTest {
assertFalse(
RequestUriUtils.isTrackableResource("/script.js"),
"JS files should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"),
"PDFium wasm should not be trackable");
assertFalse(
RequestUriUtils.isTrackableResource("/swagger/index.html"),
"Swagger files should not be trackable");
@ -224,7 +231,8 @@ public class RequestUriUtilsTest {
"/api/v1/info/health",
"/site.webmanifest",
"/fonts/roboto.woff",
"/pdfjs/viewer.js"
"/pdfjs/viewer.js",
"/pdfium/pdfium.wasm"
})
void testNonTrackableResources(String path) {
assertFalse(

View File

@ -124,6 +124,9 @@ public class ConfigController {
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
configData.put(
"enableDesktopInstallSlide",
applicationProperties.getSystem().getEnableDesktopInstallSlide());
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());

View File

@ -191,6 +191,12 @@ public class CertSignController {
switch (certType) {
case "PEM":
privateKeyFile =
validateFilePresent(
privateKeyFile, "PEM private key", "private key file is required");
certFile =
validateFilePresent(
certFile, "PEM certificate", "certificate file is required");
ks = KeyStore.getInstance("JKS");
ks.load(null);
PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password);
@ -200,10 +206,16 @@ public class CertSignController {
break;
case "PKCS12":
case "PFX":
p12File =
validateFilePresent(
p12File, "PKCS12 keystore", "PKCS12/PFX keystore file is required");
ks = KeyStore.getInstance("PKCS12");
ks.load(p12File.getInputStream(), password.toCharArray());
break;
case "JKS":
jksfile =
validateFilePresent(
jksfile, "JKS keystore", "JKS keystore file is required");
ks = KeyStore.getInstance("JKS");
ks.load(jksfile.getInputStream(), password.toCharArray());
break;
@ -251,6 +263,17 @@ public class CertSignController {
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_signed.pdf"));
}
private MultipartFile validateFilePresent(
MultipartFile file, String argumentName, String errorDescription) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
argumentName + " - " + errorDescription);
}
return file;
}
private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password)
throws IOException, OperatorCreationException, PKCSException {
try (PEMParser pemParser =

View File

@ -126,6 +126,7 @@ system:
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
enableDesktopInstallSlide: true # Set to 'false' to hide the desktop app installation slide in the onboarding flow
enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally

View File

@ -1,9 +1,10 @@
package stirling.software.SPDF.controller.api.security;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
@ -107,7 +108,8 @@ class CertSignControllerTest {
derCertBytes = baos.toByteArray();
}
when(pdfDocumentFactory.load(any(MultipartFile.class)))
lenient()
.when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(
invocation -> {
MultipartFile file = invocation.getArgument(0);
@ -167,6 +169,31 @@ class CertSignControllerTest {
assertTrue(response.getBody().length > 0);
}
@Test
void testSignPdfWithMissingPkcs12FileThrowsError() {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
SignPDFWithCertRequest request = new SignPDFWithCertRequest();
request.setFileInput(pdfFile);
request.setCertType("PFX");
request.setPassword("password");
request.setShowSignature(false);
request.setReason("test");
request.setLocation("test");
request.setName("tester");
request.setPageNumber(1);
request.setShowLogo(false);
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> certSignController.signPDFWithCert(request));
assertTrue(exception.getMessage().contains("PKCS12 keystore"));
}
@Test
void testSignPdfWithJks() throws Exception {
MockMultipartFile pdfFile =

View File

@ -6131,8 +6131,8 @@ tags = "text,anmerkung,beschriftung"
applySignatures = "Text anwenden"
[addText.text]
name = "Textinhalt"
placeholder = "Geben Sie den hinzuzufügenden Text ein"
name = "Text"
placeholder = "Text eingeben"
fontLabel = "Schriftart"
fontSizeLabel = "Schriftgröße"
fontSizePlaceholder = "Schriftgröße eingeben oder wählen (8-200)"

View File

@ -312,10 +312,10 @@ yamlAdvert = "Stirling PDF Pro supports YAML configuration files and other SSO f
ssoAdvert = "Looking for more user management features? Check out Stirling PDF Pro"
[analytics]
title = "Do you want make Stirling PDF better?"
paragraph1 = "Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents."
title = "Do you want to help make Stirling PDF better?"
paragraph1 = "Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents."
paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better."
learnMore = "Learn more"
learnMore = "Learn more about our analytics"
enable = "Enable analytics"
disable = "Disable analytics"
settings = "You can change the settings for analytics in the config/settings.yml file"
@ -340,6 +340,10 @@ advance = "Advanced"
edit = "View & Edit"
popular = "Popular"
[footer]
discord = "Discord"
issues = "GitHub"
[settings.preferences]
title = "Preferences"
@ -4060,12 +4064,20 @@ settings = "Settings"
adminSettings = "Admin Settings"
allTools = "Tools"
reader = "Reader"
tours = "Tours"
showMeAround = "Show me around"
[quickAccess.toursTooltip]
admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour."
user = "Watch walkthroughs here: Tools tour and the New V2 layout tour."
[quickAccess.helpMenu]
toolsTour = "Tools Tour"
toolsTourDesc = "Learn what the tools can do"
adminTour = "Admin Tour"
adminTourDesc = "Explore admin settings & features"
whatsNewTour = "See what's new in V2"
whatsNewTourDesc = "Tour the updated layout"
[admin]
error = "Error"
@ -5263,6 +5275,16 @@ finish = "Finish"
startTour = "Start Tour"
startTourDescription = "Take a guided tour of Stirling PDF's key features"
[onboarding.whatsNew]
quickAccess = "Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours."
leftPanel = "The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly."
fileUpload = "Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace."
rightRail = "The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results."
topBar = "The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>."
pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages."
activeFilesView = "Use Active Files to see everything you have open and pick what to work on."
wrapUp = "That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour."
[onboarding.welcomeModal]
title = "Welcome to Stirling PDF!"
description = "Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
@ -5283,6 +5305,10 @@ download = "Download →"
showMeAround = "Show me around"
skipTheTour = "Skip the tour"
[onboarding.tourOverview]
title = "Tour Overview"
body = "Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need."
[onboarding.serverLicense]
skip = "Skip for now"
seePlans = "See Plans →"
@ -5821,6 +5847,8 @@ notAvailable = "Audit system not available"
notAvailableMessage = "The audit system is not configured or not available."
disabled = "Audit logging is disabled"
disabledMessage = "Enable audit logging in your application configuration to track system events."
enterpriseRequired = "Enterprise License Required"
enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics."
[audit.error]
title = "Error loading audit system"

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
interface TextInputWithFontProps {
@ -13,8 +12,12 @@ interface TextInputWithFontProps {
textColor?: string;
onTextColorChange?: (color: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
label: string;
placeholder: string;
fontLabel: string;
fontSizeLabel: string;
fontSizePlaceholder: string;
colorLabel?: string;
onAnyChange?: () => void;
}
@ -30,9 +33,12 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
disabled = false,
label,
placeholder,
fontLabel,
fontSizeLabel,
fontSizePlaceholder,
colorLabel,
onAnyChange
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
@ -66,8 +72,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
return (
<Stack gap="sm">
<TextInput
label={label || t('sign.text.name', 'Signer name')}
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
label={label}
placeholder={placeholder}
value={text}
onChange={(e) => {
onTextChange(e.target.value);
@ -79,7 +85,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{/* Font Selection */}
<Select
label={t('sign.text.fontLabel', 'Font')}
label={fontLabel}
value={fontFamily}
onChange={(value) => {
onFontFamilyChange(value || 'Helvetica');
@ -107,8 +113,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
>
<Combobox.Target>
<TextInput
label={t('sign.text.fontSizeLabel', 'Font size')}
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
label={fontSizeLabel}
placeholder={fontSizePlaceholder}
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
@ -155,7 +161,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{onTextColorChange && (
<Box>
<TextInput
label={t('sign.text.colorLabel', 'Text colour')}
label={colorLabel}
value={colorInput}
placeholder="#000000"
disabled={disabled}

View File

@ -1,57 +0,0 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
import { TextInputWithFont } from '@app/components/annotation/shared/TextInputWithFont';
interface TextToolProps {
onTextChange?: (text: string) => void;
disabled?: boolean;
}
export const TextTool: React.FC<TextToolProps> = ({
onTextChange,
disabled = false
}) => {
const [text, setText] = useState('');
const [fontSize, setFontSize] = useState(16);
const [fontFamily, setFontFamily] = useState('Helvetica');
const handleTextChange = (newText: string) => {
setText(newText);
onTextChange?.(newText);
};
const handleSignatureDataChange = (data: string | null) => {
if (data) {
onTextChange?.(data);
}
};
const toolConfig = {
enableTextInput: true,
showPlaceButton: true,
placeButtonText: "Place Text"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={handleSignatureDataChange}
disabled={disabled}
>
<Stack gap="sm">
<TextInputWithFont
text={text}
onTextChange={handleTextChange}
fontSize={fontSize}
onFontSizeChange={setFontSize}
fontFamily={fontFamily}
onFontFamilyChange={setFontFamily}
disabled={disabled}
label="Text Content"
placeholder="Enter text to place on the PDF"
/>
</Stack>
</BaseAnnotationTool>
);
};

View File

@ -47,11 +47,11 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
) {
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
}
// Translate the label (it's a translation key)
const label = button.label ?? '';
if (!label) return '';
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
const fallback = label.split('.').pop() || label;
return t(label, fallback);
@ -105,4 +105,4 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group>
);
}
}

View File

@ -5,7 +5,6 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { isAuthRoute } from '@app/constants/routes';
import { dispatchTourState } from '@app/constants/events';
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
@ -20,10 +19,12 @@ import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig';
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useServerExperience } from '@app/hooks/useServerExperience';
import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import apiClient from '@app/services/apiClient';
import '@app/components/onboarding/OnboardingTour.css';
export default function Onboarding() {
@ -39,6 +40,11 @@ export default function Onboarding() {
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
const { config, refetch: refetchConfig } = useAppConfig();
const [analyticsError, setAnalyticsError] = useState<string | null>(null);
const [analyticsLoading, setAnalyticsLoading] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
const [analyticsModalDismissed, setAnalyticsModalDismissed] = useState(false);
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
actions.updateRuntimeState({ selectedRole: role });
@ -50,7 +56,34 @@ export default function Onboarding() {
window.location.href = '/login';
}, [actions]);
const handleButtonAction = useCallback((action: ButtonAction) => {
// Check if we should show analytics modal before onboarding
useEffect(() => {
if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) {
setShowAnalyticsModal(true);
}
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
const handleAnalyticsChoice = useCallback(async (enableAnalytics: boolean) => {
if (analyticsLoading) return;
setAnalyticsLoading(true);
setAnalyticsError(null);
try {
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
await refetchConfig();
setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true);
setAnalyticsLoading(false);
} catch (error) {
setAnalyticsError(error instanceof Error ? error.message : 'Unknown error');
setAnalyticsLoading(false);
}
}, [analyticsLoading, refetchConfig]);
const handleButtonAction = useCallback(async (action: ButtonAction) => {
switch (action) {
case 'next':
case 'complete-close':
@ -69,35 +102,43 @@ export default function Onboarding() {
case 'security-next':
if (!runtimeState.selectedRole) return;
if (runtimeState.selectedRole !== 'admin') {
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
actions.updateRuntimeState({ tourType: 'whatsnew' });
setIsTourOpen(true);
}
actions.complete();
break;
case 'launch-admin':
actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
actions.complete();
actions.updateRuntimeState({ tourType: 'admin' });
setIsTourOpen(true);
break;
case 'launch-tools':
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
actions.complete();
actions.updateRuntimeState({ tourType: 'whatsnew' });
setIsTourOpen(true);
break;
case 'launch-auto': {
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
actions.updateRuntimeState({ tourRequested: true, tourType });
actions.complete();
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew';
actions.updateRuntimeState({ tourType });
setIsTourOpen(true);
break;
}
case 'skip-to-license':
markStepSeen('tour');
actions.updateRuntimeState({ tourRequested: false });
actions.complete();
break;
case 'skip-tour':
actions.complete();
break;
case 'see-plans':
actions.complete();
navigate('/settings/adminPlan');
break;
case 'enable-analytics':
await handleAnalyticsChoice(true);
break;
case 'disable-analytics':
await handleAnalyticsChoice(false);
break;
}
}, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
}, [actions, handleAnalyticsChoice, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
const [isTourOpen, setIsTourOpen] = useState(false);
@ -117,10 +158,7 @@ export default function Onboarding() {
backToAllTools: tourOrch.backToAllTools,
selectCropTool: tourOrch.selectCropTool,
loadSampleFile: tourOrch.loadSampleFile,
switchToViewer: tourOrch.switchToViewer,
switchToPageEditor: tourOrch.switchToPageEditor,
switchToActiveFiles: tourOrch.switchToActiveFiles,
selectFirstFile: tourOrch.selectFirstFile,
pinFile: tourOrch.pinFile,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
@ -130,6 +168,24 @@ export default function Onboarding() {
[t, tourOrch, closeFilesModal, openFilesModal]
);
const whatsNewStepsConfig = useMemo(
() => createWhatsNewStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
openFilesModal,
loadSampleFile: tourOrch.loadSampleFile,
switchToViewer: tourOrch.switchToViewer,
switchToPageEditor: tourOrch.switchToPageEditor,
switchToActiveFiles: tourOrch.switchToActiveFiles,
selectFirstFile: tourOrch.selectFirstFile,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal]
);
const adminStepsConfig = useMemo(
() => createAdminStepsConfig({
t,
@ -144,21 +200,19 @@ export default function Onboarding() {
);
const tourSteps = useMemo<StepType[]>(() => {
const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
return Object.values(config);
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
useEffect(() => {
if (currentStep?.id === 'tour' && !isTourOpen) {
markStepSeen('tour');
setIsTourOpen(true);
switch (runtimeState.tourType) {
case 'admin':
return Object.values(adminStepsConfig);
case 'whatsnew':
return Object.values(whatsNewStepsConfig);
default:
return Object.values(userStepsConfig);
}
}, [currentStep, isTourOpen, activeFlow]);
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]);
useEffect(() => {
if (externalTourRequested) {
actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
markStepSeen('tour');
actions.updateRuntimeState({ tourType: requestedTourType });
setIsTourOpen(true);
clearTourRequest();
}
@ -176,9 +230,9 @@ export default function Onboarding() {
} else {
tourOrch.restoreWorkbenchState();
}
markStepSeen('tour');
if (currentStep?.id === 'tour') actions.complete();
}, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]);
// Advance to next onboarding step after tour completes
actions.complete();
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]);
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
@ -216,8 +270,10 @@ export default function Onboarding() {
firstLoginUsername: runtimeState.firstLoginUsername,
onPasswordChanged: handlePasswordChanged,
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
analyticsError,
analyticsLoading,
});
}, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
const modalSlideCount = useMemo(() => {
return activeFlow.filter((step) => step.type === 'modal-slide').length;
@ -237,8 +293,45 @@ export default function Onboarding() {
return null;
}
// Show analytics modal before onboarding if needed
if (showAnalyticsModal) {
const slideDefinition = SLIDE_DEFINITIONS['analytics-choice'];
const slideContent = slideDefinition.createSlide({
osLabel: '',
osUrl: '',
selectedRole: null,
onRoleSelect: () => {},
analyticsError,
analyticsLoading,
});
return (
<OnboardingModalSlide
slideDefinition={slideDefinition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={() => {}} // No skip allowed
onAction={async (action) => {
if (action === 'enable-analytics') {
await handleAnalyticsChoice(true);
} else if (action === 'disable-analytics') {
await handleAnalyticsChoice(false);
}
}}
allowDismiss={false}
/>
);
}
if (showLicenseSlide) {
const slideDefinition = SLIDE_DEFINITIONS['server-license'];
const baseSlideDefinition = SLIDE_DEFINITIONS['server-license'];
// Remove back button for external license notice
const slideDefinition = {
...baseSlideDefinition,
buttons: baseSlideDefinition.buttons.filter(btn => btn.key !== 'license-back')
};
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
const slideContent = slideDefinition.createSlide({
osLabel: '',
@ -250,7 +343,7 @@ export default function Onboarding() {
licenseNotice: effectiveLicenseNotice,
loginEnabled: serverExperience.loginEnabled,
});
return (
<OnboardingModalSlide
slideDefinition={slideDefinition}
@ -271,40 +364,34 @@ export default function Onboarding() {
);
}
// Always render the tour component (it controls its own visibility with isOpen)
const tourComponent = (
<OnboardingTour
isOpen={isTourOpen}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
// If no active onboarding, just show the tour (which may or may not be open)
if (isLoading || !isActive || !currentStep) {
return (
<OnboardingTour
isOpen={isTourOpen}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
return tourComponent;
}
// If tour is open, hide the onboarding modal and just show the tour
if (isTourOpen) {
return tourComponent;
}
// Render the current onboarding step
switch (currentStep.type) {
case 'tool-prompt':
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
case 'tour':
return (
<OnboardingTour
isOpen={true}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
case 'analytics-modal':
return <AdminAnalyticsChoiceModal opened={true} onClose={actions.complete} />;
case 'modal-slide':
if (!currentSlideDefinition || !currentSlideContent) return null;
return (

View File

@ -6,8 +6,9 @@
*/
import React from 'react';
import { Modal, Stack } from '@mantine/core';
import { Modal, Stack, ActionIcon } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import CloseIcon from '@mui/icons-material/Close';
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
@ -28,6 +29,7 @@ interface OnboardingModalSlideProps {
currentModalSlideIndex: number;
onSkip: () => void;
onAction: (action: ButtonAction) => void;
allowDismiss?: boolean;
}
export default function OnboardingModalSlide({
@ -38,6 +40,7 @@ export default function OnboardingModalSlide({
currentModalSlideIndex,
onSkip,
onAction,
allowDismiss = true,
}: OnboardingModalSlideProps) {
const renderHero = () => {
@ -62,6 +65,9 @@ export default function OnboardingModalSlide({
{slideDefinition.hero.type === 'lock' && (
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'analytics' && (
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
{slideDefinition.hero.type === 'logo' && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
@ -75,6 +81,7 @@ export default function OnboardingModalSlide({
opened={true}
onClose={onSkip}
closeOnClickOutside={false}
closeOnEscape={allowDismiss}
centered
size="lg"
radius="lg"
@ -93,6 +100,31 @@ export default function OnboardingModalSlide({
isActive
slideKey={slideContent.key}
/>
{allowDismiss && (
<ActionIcon
onClick={onSkip}
radius="md"
size={36}
style={{
position: 'absolute',
top: 16,
right: 16,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(4px)',
zIndex: 10,
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
},
}}
>
<CloseIcon fontSize="small" />
</ActionIcon>
)}
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
{renderHero()}
</div>
@ -114,7 +146,9 @@ export default function OnboardingModalSlide({
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
{modalSlideCount > 1 && (
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
)}
<div className={styles.buttonContainer}>
<SlideButtons

View File

@ -49,7 +49,7 @@ interface CloseArgs {
interface OnboardingTourProps {
tourSteps: StepType[];
tourType: 'admin' | 'tools';
tourType: 'admin' | 'tools' | 'whatsnew';
isRTL: boolean;
t: TFunction;
isOpen: boolean;

View File

@ -4,6 +4,8 @@ import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckS
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide';
import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types';
export type SlideId =
@ -12,9 +14,11 @@ export type SlideId =
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'server-license';
| 'server-license'
| 'tour-overview'
| 'analytics-choice';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics';
export type ButtonAction =
| 'next'
@ -27,7 +31,10 @@ export type ButtonAction =
| 'launch-tools'
| 'launch-auto'
| 'see-plans'
| 'skip-to-license';
| 'skip-to-license'
| 'skip-tour'
| 'enable-analytics'
| 'disable-analytics';
export interface FlowState {
selectedRole: 'admin' | 'user' | null;
@ -52,6 +59,8 @@ export interface SlideFactoryParams {
firstLoginUsername?: string;
onPasswordChanged?: () => void;
usingDefaultCredentials?: boolean;
analyticsError?: string | null;
analyticsLoading?: boolean;
}
export interface HeroDefinition {
@ -79,9 +88,9 @@ export interface SlideDefinition {
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'first-login': {
id: 'first-login',
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
FirstLoginSlide({
username: firstLoginUsername || '',
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
FirstLoginSlide({
username: firstLoginUsername || '',
onPasswordChanged: onPasswordChanged || (() => {}),
usingDefaultCredentials: usingDefaultCredentials || false,
}),
@ -194,6 +203,13 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
hero: { type: 'dual-icon' },
buttons: [
{
key: 'license-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'license-close',
type: 'button',
@ -212,5 +228,58 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
},
],
},
'tour-overview': {
id: 'tour-overview',
createSlide: () => TourOverviewSlide(),
hero: { type: 'rocket' },
buttons: [
{
key: 'tour-overview-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'tour-overview-skip',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'skip-tour',
},
{
key: 'tour-overview-show',
type: 'button',
label: 'onboarding.buttons.showMeAround',
variant: 'primary',
group: 'right',
action: 'launch-tools',
},
],
},
'analytics-choice': {
id: 'analytics-choice',
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
hero: { type: 'analytics' },
buttons: [
{
key: 'analytics-disable',
type: 'button',
label: 'no',
variant: 'secondary',
group: 'left',
action: 'disable-analytics',
},
{
key: 'analytics-enable',
type: 'button',
label: 'yes',
variant: 'primary',
group: 'right',
action: 'enable-analytics',
},
],
},
};

View File

@ -5,21 +5,20 @@ export type OnboardingStepId =
| 'security-check'
| 'admin-overview'
| 'tool-layout'
| 'tour'
| 'tour-overview'
| 'server-license'
| 'analytics-choice';
export type OnboardingStepType =
| 'modal-slide'
| 'tool-prompt'
| 'tour'
| 'analytics-modal';
| 'tool-prompt';
export interface OnboardingRuntimeState {
selectedRole: 'admin' | 'user' | null;
tourRequested: boolean;
tourType: 'admin' | 'tools';
tourType: 'admin' | 'tools' | 'whatsnew';
isDesktopApp: boolean;
desktopSlideEnabled: boolean;
analyticsNotConfigured: boolean;
analyticsEnabled: boolean;
licenseNotice: {
@ -42,13 +41,13 @@ export interface OnboardingStep {
id: OnboardingStepId;
type: OnboardingStepType;
condition: (ctx: OnboardingConditionContext) => boolean;
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license';
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice';
}
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
selectedRole: null,
tourRequested: false,
tourType: 'tools',
tourType: 'whatsnew',
isDesktopApp: false,
analyticsNotConfigured: false,
analyticsEnabled: false,
@ -61,6 +60,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
requiresPasswordChange: false,
firstLoginUsername: '',
usingDefaultCredentials: false,
desktopSlideEnabled: true,
};
export const ONBOARDING_STEPS: OnboardingStep[] = [
@ -76,18 +76,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
slideId: 'welcome',
condition: () => true,
},
{
id: 'desktop-install',
type: 'modal-slide',
slideId: 'desktop-install',
condition: (ctx) => !ctx.isDesktopApp,
},
{
id: 'security-check',
type: 'modal-slide',
slideId: 'security-check',
condition: (ctx) => !ctx.loginEnabled && !ctx.isDesktopApp,
},
{
id: 'admin-overview',
type: 'modal-slide',
@ -95,14 +83,27 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
condition: (ctx) => ctx.effectiveIsAdmin,
},
{
id: 'tool-layout',
type: 'tool-prompt',
condition: () => true,
id: 'desktop-install',
type: 'modal-slide',
slideId: 'desktop-install',
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
},
{
id: 'tour',
type: 'tour',
condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin,
id: 'security-check',
type: 'modal-slide',
slideId: 'security-check',
condition: () => false,
},
{
id: 'tool-layout',
type: 'tool-prompt',
condition: () => false,
},
{
id: 'tour-overview',
type: 'modal-slide',
slideId: 'tour-overview',
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin',
},
{
id: 'server-license',
@ -110,11 +111,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
slideId: 'server-license',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
},
{
id: 'analytics-choice',
type: 'analytics-modal',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.analyticsNotConfigured,
},
];
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {

View File

@ -1,94 +1,71 @@
import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
const STORAGE_PREFIX = 'onboarding';
const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`;
const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`;
export function getStorageKey(stepId: OnboardingStepId): string {
return `${STORAGE_PREFIX}::${stepId}`;
}
export function hasSeenStep(stepId: OnboardingStepId): boolean {
export function isOnboardingCompleted(): boolean {
if (typeof window === 'undefined') return false;
try {
return localStorage.getItem(getStorageKey(stepId)) === 'true';
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true';
} catch {
return false;
}
}
export function markStepSeen(stepId: OnboardingStepId): void {
export function markOnboardingCompleted(): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(getStorageKey(stepId), 'true');
localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true');
} catch (error) {
console.error('[onboardingStorage] Error marking step as seen:', error);
console.error('[onboardingStorage] Error marking onboarding as completed:', error);
}
}
export function resetStepSeen(stepId: OnboardingStepId): void {
export function resetOnboardingProgress(): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(getStorageKey(stepId));
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
} catch (error) {
console.error('[onboardingStorage] Error resetting step seen:', error);
console.error('[onboardingStorage] Error resetting onboarding progress:', error);
}
}
export function resetAllOnboardingProgress(): void {
if (typeof window === 'undefined') return;
export function hasShownToursTooltip(): boolean {
if (typeof window === 'undefined') return false;
try {
const prefix = `${STORAGE_PREFIX}::`;
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(prefix)) keysToRemove.push(key);
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch (error) {
console.error('[onboardingStorage] Error resetting all onboarding progress:', error);
return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true';
} catch {
return false;
}
}
export function getOnboardingStorageState(): Record<string, boolean> {
const state: Record<string, boolean> = {};
ONBOARDING_STEPS.forEach((step) => {
state[step.id] = hasSeenStep(step.id);
});
return state;
export function markToursTooltipShown(): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(TOURS_TOOLTIP_KEY, 'true');
} catch (error) {
console.error('[onboardingStorage] Error marking tours tooltip as shown:', error);
}
}
export function migrateFromLegacyPreferences(): void {
if (typeof window === 'undefined') return;
const migrationKey = `${STORAGE_PREFIX}::migrated`;
try {
// Skip if already migrated
if (localStorage.getItem(migrationKey) === 'true') return;
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
if (prefsRaw) {
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
// Migrate based on legacy flags
if (prefs.hasSeenIntroOnboarding === true) {
markStepSeen('welcome');
markStepSeen('desktop-install');
markStepSeen('security-check');
markStepSeen('admin-overview');
// If user had completed onboarding in old system, mark new system as complete
if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) {
markOnboardingCompleted();
}
if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) {
markStepSeen('tool-layout');
}
if (prefs.hasCompletedOnboarding === true) {
markStepSeen('tour');
markStepSeen('analytics-choice');
markStepSeen('server-license');
}
}
// Mark migration complete
localStorage.setItem(migrationKey, 'true');
} catch {

View File

@ -12,8 +12,8 @@ import {
DEFAULT_RUNTIME_STATE,
} from '@app/components/onboarding/orchestrator/onboardingConfig';
import {
hasSeenStep,
markStepSeen,
isOnboardingCompleted,
markOnboardingCompleted,
migrateFromLegacyPreferences,
} from '@app/components/onboarding/orchestrator/onboardingStorage';
import { accountService } from '@app/services/accountService';
@ -35,12 +35,15 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
if (typeof window === 'undefined') {
return baseState;
}
try {
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools';
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew')
? sessionTourType
: 'whatsnew';
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
return {
...baseState,
tourRequested,
@ -54,7 +57,7 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
if (typeof window === 'undefined') return;
try {
if (state.tourRequested !== undefined) {
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
@ -76,7 +79,7 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
function clearRuntimeStateSession(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
sessionStorage.removeItem(SESSION_TOUR_TYPE);
@ -145,7 +148,7 @@ export function useOnboardingOrchestrator(
const location = useLocation();
const bypassOnboarding = useBypassOnboarding();
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
getInitialRuntimeState(defaultState)
);
const [isPaused, setIsPaused] = useState(false);
@ -166,6 +169,7 @@ export function useOnboardingOrchestrator(
...prev,
analyticsEnabled: config?.enableAnalytics === true,
analyticsNotConfigured: config?.enableAnalytics == null,
desktopSlideEnabled: config?.enableDesktopInstallSlide ?? true,
licenseNotice: {
totalUsers: serverExperience.totalUsers,
freeTierLimit: serverExperience.freeTierLimit,
@ -221,7 +225,7 @@ export function useOnboardingOrchestrator(
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
...serverExperience,
...runtimeState,
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
}), [serverExperience, runtimeState]);
@ -235,53 +239,44 @@ export function useOnboardingOrchestrator(
// Wait for config AND admin status before calculating initial step
const adminStatusResolved = !configLoading && (
config?.enableLogin === false ||
config?.enableLogin === undefined ||
config?.enableLogin === false ||
config?.enableLogin === undefined ||
config?.isAdmin !== undefined
);
useEffect(() => {
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
if (configLoading || !adminStatusResolved) return;
let firstUnseenIndex = -1;
for (let i = 0; i < activeFlow.length; i++) {
// Special case: first-login step should always be considered "unseen" if requiresPasswordChange is true
const isFirstLoginStep = activeFlow[i].id === 'first-login';
const shouldTreatAsUnseen = isFirstLoginStep ? runtimeState.requiresPasswordChange : !hasSeenStep(activeFlow[i].id);
if (shouldTreatAsUnseen) {
firstUnseenIndex = i;
break;
}
}
// Force reset index when password change is required (overrides initialIndexSet)
if (runtimeState.requiresPasswordChange && firstUnseenIndex === 0) {
// If there are no steps to show, mark initialized/completed baseline
if (activeFlow.length === 0) {
setCurrentStepIndex(0);
initialIndexSet.current = true;
} else if (firstUnseenIndex === -1) {
return;
}
// If onboarding has been completed, don't show it
if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) {
setCurrentStepIndex(activeFlow.length);
initialIndexSet.current = true;
} else if (!initialIndexSet.current) {
setCurrentStepIndex(firstUnseenIndex);
return;
}
// Start from the beginning
if (!initialIndexSet.current) {
setCurrentStepIndex(0);
initialIndexSet.current = true;
}
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
const totalSteps = activeFlow.length;
const allStepsAlreadySeen = useMemo(() => {
if (activeFlow.length === 0) return false;
return activeFlow.every(step => hasSeenStep(step.id));
}, [activeFlow]);
const isComplete = isInitialized && initialIndexSet.current &&
(currentStepIndex >= totalSteps || allStepsAlreadySeen);
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen)
? activeFlow[currentStepIndex]
const isComplete = isInitialized &&
(totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps)
? activeFlow[currentStepIndex]
: null;
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
useEffect(() => {
@ -293,35 +288,33 @@ export function useOnboardingOrchestrator(
}, [isComplete]);
const next = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
const nextIndex = currentStepIndex + 1;
if (nextIndex >= totalSteps) {
// Reached the end, mark onboarding as completed
markOnboardingCompleted();
}
setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]);
const prev = useCallback(() => {
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
}, []);
const skip = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
// Skip marks the entire onboarding as completed
markOnboardingCompleted();
setCurrentStepIndex(totalSteps);
}, [totalSteps]);
const complete = useCallback(() => {
if (currentStep) markStepSeen(currentStep.id);
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
}, [currentStep, totalSteps]);
useEffect(() => {
if (!currentStep || isLoading) {
return;
const nextIndex = currentStepIndex + 1;
if (nextIndex >= totalSteps) {
// Reached the end, mark onboarding as completed
markOnboardingCompleted();
}
// Special case: never auto-complete first-login step if requiresPasswordChange is true
const isFirstLoginStep = currentStep.id === 'first-login';
setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]);
if (!isFirstLoginStep && hasSeenStep(currentStep.id)) {
complete();
}
}, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]);
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);

View File

@ -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,
},
};
}

View File

@ -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,
},
};
}

View File

@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage';
const SESSION_KEY = 'onboarding::bypass-all';
const PARAM_KEY = 'bypassOnboarding';
@ -34,13 +33,12 @@ function setStoredBypass(enabled: boolean): void {
/**
* Detects the `bypassOnboarding` query parameter and stores it in session storage
* so that onboarding remains disabled while the app is open. Also marks all steps
* as seen to ensure any dependent UI elements remain hidden.
* so that onboarding remains disabled while the app is open. Also marks onboarding
* as completed to ensure any dependent UI elements remain hidden.
*/
export function useBypassOnboarding(): boolean {
const location = useLocation();
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
const stepsMarkedRef = useRef(false);
const shouldBypassFromSearch = useMemo(() => {
try {
@ -57,14 +55,9 @@ export function useBypassOnboarding(): boolean {
setBypassOnboarding(nextBypass);
if (nextBypass) {
setStoredBypass(true);
markOnboardingCompleted();
}
}, [shouldBypassFromSearch]);
useEffect(() => {
if (!bypassOnboarding || stepsMarkedRef.current) return;
stepsMarkedRef.current = true;
ONBOARDING_STEPS.forEach((step) => markStepSeen(step.id));
}, [bypassOnboarding]);
return bypassOnboarding;
}

View File

@ -51,14 +51,14 @@ export function useTourRequest(): {
clearTourRequest: () => void;
} {
const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
const [requestedTourType, setRequestedTourType] = useState<TourType>('whatsnew');
useEffect(() => {
if (typeof window === 'undefined') return;
const handleTourRequest = (event: Event) => {
const { detail } = event as CustomEvent<StartTourPayload>;
setRequestedTourType(detail?.tourType ?? 'tools');
setRequestedTourType(detail?.tourType ?? 'whatsnew');
setTourRequested(true);
};

View File

@ -8,12 +8,8 @@ export enum TourStep {
FILES_BUTTON,
FILE_SOURCES,
WORKBENCH,
VIEW_SWITCHER,
VIEWER,
PAGE_EDITOR,
ACTIVE_FILES,
FILE_CHECKBOX,
SELECT_CONTROLS,
CROP_SETTINGS,
RUN_BUTTON,
RESULTS,
@ -28,10 +24,7 @@ interface UserStepActions {
backToAllTools: () => void;
selectCropTool: () => void;
loadSampleFile: () => void;
switchToViewer: () => void;
switchToPageEditor: () => void;
switchToActiveFiles: () => void;
selectFirstFile: () => void;
pinFile: () => void;
modifyCropSettings: () => void;
executeTool: () => void;
@ -50,10 +43,7 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
backToAllTools,
selectCropTool,
loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles,
selectFirstFile,
pinFile,
modifyCropSettings,
executeTool,
@ -108,26 +98,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
position: 'center',
padding: 0,
},
[TourStep.VIEW_SWITCHER]: {
selector: '[data-tour="view-switcher"]',
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
position: 'bottom',
padding: 0,
},
[TourStep.VIEWER]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
position: 'center',
padding: 0,
action: () => switchToViewer(),
},
[TourStep.PAGE_EDITOR]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
position: 'center',
padding: 0,
action: () => switchToPageEditor(),
},
[TourStep.ACTIVE_FILES]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
@ -141,14 +111,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
position: 'top',
padding: 10,
},
[TourStep.SELECT_CONTROLS]: {
selector: '[data-tour="right-rail-controls"]',
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
position: 'left',
padding: 5,
action: () => selectFirstFile(),
},
[TourStep.CROP_SETTINGS]: {
selector: '[data-tour="crop-settings"]',
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),

View File

@ -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,
},
};
}

View File

@ -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>
);
}

View File

@ -95,6 +95,22 @@ export default function Footer({
>
{t('legal.terms', 'Terms and Conditions')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href="https://discord.gg/Cn8pWhQRxZ"
>
{t('footer.discord', 'Discord')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/Stirling-Tools/Stirling-PDF"
>
{t('footer.issues', 'GitHub')}
</a>
<a
className="footer-link px-3"
target="_blank"

View File

@ -173,7 +173,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
label: `${name} (${code})`,
label: name,
}));
// Hide the language selector if there's only one language option

View File

@ -11,6 +11,7 @@ import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import { ButtonConfig } from '@app/types/sidebar';
import '@app/components/shared/quickAccessBar/QuickAccessBar.css';
import { Tooltip } from '@app/components/shared/Tooltip';
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
import AppConfigModal from '@app/components/shared/AppConfigModal';
@ -18,6 +19,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import { requestStartTour } from '@app/constants/events';
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
import { useToursTooltip } from '@app/components/shared/quickAccessBar/useToursTooltip';
import {
isNavButtonActive,
@ -41,6 +43,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
const {
tooltipOpen,
manualCloseOnly,
showCloseButton,
toursMenuOpen,
setToursMenuOpen,
handleTooltipOpenChange,
} = useToursTooltip();
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
@ -166,8 +176,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const bottomButtons: ButtonConfig[] = [
{
id: 'help',
name: t("quickAccess.help", "Help"),
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
name: t("quickAccess.tours", "Tours"),
icon: <LocalIcon icon="explore-rounded" width="1.25rem" height="1.25rem" />,
isRound: true,
size: 'md',
type: 'action',
@ -192,6 +202,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div
ref={ref}
data-sidebar="quick-access"
data-tour="quick-access-bar"
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
>
{/* Fixed header outside scrollable area */}
@ -247,58 +258,83 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// Handle help button with menu or direct action
if (buttonConfig.id === 'help') {
const isAdmin = config?.isAdmin === true;
const toursTooltipContent = isAdmin
? t('quickAccess.toursTooltip.admin', 'Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.')
: t('quickAccess.toursTooltip.user', 'Watch walkthroughs here: Tools tour and the New V2 layout tour.');
const tourItems = [
{
key: 'whatsnew',
icon: <LocalIcon icon="auto-awesome-rounded" width="1.25rem" height="1.25rem" />,
title: t("quickAccess.helpMenu.whatsNewTour", "See what's new in V2"),
description: t("quickAccess.helpMenu.whatsNewTourDesc", "Tour the updated layout"),
onClick: () => requestStartTour('whatsnew'),
},
{
key: 'tools',
icon: <LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />,
title: t("quickAccess.helpMenu.toolsTour", "Tools Tour"),
description: t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do"),
onClick: () => requestStartTour('tools'),
},
...(isAdmin ? [{
key: 'admin',
icon: <LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />,
title: t("quickAccess.helpMenu.adminTour", "Admin Tour"),
description: t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features"),
onClick: () => requestStartTour('admin'),
}] : []),
];
// If not admin, just show button that starts tools tour directly
if (!isAdmin) {
return (
<div
key={buttonConfig.id}
data-tour="help-button"
onClick={() => requestStartTour('tools')}
const helpButtonNode = (
<div data-tour="help-button">
<Menu
position={isRTL ? 'left' : 'right'}
offset={10}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
opened={toursMenuOpen}
onChange={setToursMenuOpen}
>
{renderNavButton(buttonConfig, index)}
</div>
);
}
// If admin, show menu with both options
return (
<div key={buttonConfig.id} data-tour="help-button">
<Menu position={isRTL ? 'left' : 'right'} offset={10} zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}>
<Menu.Target>
<div>{renderNavButton(buttonConfig, index)}</div>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => requestStartTour('tools')}
>
<div>
<div style={{ fontWeight: 500 }}>
{t("quickAccess.helpMenu.toolsTour", "Tools Tour")}
{tourItems.map((item) => (
<Menu.Item
key={item.key}
leftSection={item.icon}
onClick={item.onClick}
>
<div>
<div style={{ fontWeight: 500 }}>
{item.title}
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{item.description}
</div>
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do")}
</div>
</div>
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => requestStartTour('admin')}
>
<div>
<div style={{ fontWeight: 500 }}>
{t("quickAccess.helpMenu.adminTour", "Admin Tour")}
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features")}
</div>
</div>
</Menu.Item>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</div>
);
return (
<Tooltip
position="right"
arrow
offset={8}
open={tooltipOpen}
manualCloseOnly={manualCloseOnly}
showCloseButton={showCloseButton}
closeOnOutside={false}
openOnFocus={false}
content={toursTooltipContent}
onOpenChange={handleTooltipOpenChange}
>
{helpButtonNode}
</Tooltip>
);
}
const buttonNode = renderNavButton(buttonConfig, index);

View File

@ -33,6 +33,10 @@ export interface TooltipProps {
disabled?: boolean;
/** If false, tooltip will not open on focus (hover only) */
openOnFocus?: boolean;
/** If true, tooltip stays open until explicitly closed (ignores hover/blur/esc/outside) */
manualCloseOnly?: boolean;
/** Show a close button even when not pinned */
showCloseButton?: boolean;
}
export const Tooltip: React.FC<TooltipProps> = ({
@ -55,6 +59,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
closeOnOutside = true,
disabled = false,
openOnFocus = true,
manualCloseOnly = false,
showCloseButton = false,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
@ -81,6 +87,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const isControlled = controlledOpen !== undefined;
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
const allowAutoClose = !manualCloseOnly;
const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => {
const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr';
@ -132,11 +139,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
}
// Not pinned and configured to close on outside
if (closeOnOutside && !insideTooltip && !insideTrigger) {
if (allowAutoClose && closeOnOutside && !insideTooltip && !insideTrigger) {
setOpen(false);
}
},
[isPinned, closeOnOutside, setOpen]
[isPinned, closeOnOutside, setOpen, allowAutoClose]
);
useEffect(() => {
@ -200,10 +207,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
}
clearTimers();
if (!isPinned) setOpen(false);
if (allowAutoClose && !isPinned) setOpen(false);
(children.props as any)?.onPointerLeave?.(e);
},
[clearTimers, isPinned, setOpen, children.props]
[clearTimers, isPinned, setOpen, children.props, allowAutoClose]
);
const handleMouseDown = useCallback(
@ -257,15 +264,16 @@ export const Tooltip: React.FC<TooltipProps> = ({
return;
}
clearTimers();
if (!isPinned) setOpen(false);
if (allowAutoClose && !isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e);
},
[isPinned, setOpen, children.props, clearTimers]
[isPinned, setOpen, children.props, allowAutoClose, clearTimers]
);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (manualCloseOnly) return;
if (e.key === 'Escape') setOpen(false);
}, [setOpen]);
}, [setOpen, manualCloseOnly]);
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
const handleTooltipPointerEnter = useCallback(() => {
@ -276,9 +284,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
if (!isPinned) setOpen(false);
if (allowAutoClose && !isPinned) setOpen(false);
},
[isPinned, setOpen]
[isPinned, setOpen, allowAutoClose]
);
// Enhance child with handlers and ref
@ -301,6 +309,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
});
const shouldShowTooltip = open;
const shouldShowCloseButton = showCloseButton || isPinned;
const tooltipElement = shouldShowTooltip ? (
<div
@ -325,7 +334,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
>
{isPinned && (
{shouldShowCloseButton && (
<button
className={styles['tooltip-pin-button']}
onClick={(e) => {
@ -363,7 +372,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
<span className={styles['tooltip-title']}>{header.title}</span>
</div>
)}
<TooltipContent content={content} tips={tips} />
<TooltipContent content={content} tips={tips} extraRightPadding={shouldShowCloseButton ? 48 : 0} />
</div>
) : null;

View File

@ -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,
};
}

View File

@ -39,7 +39,7 @@
background: var(--bg-raised);
padding: 0.25rem;
border-radius: 0.25rem;
border: 0.0625rem solid var(--primary-color, #3b82f6);
border: 0.0625rem solid var(--border-default);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
z-index: 1;
@ -60,6 +60,13 @@
border-color: #ef4444 !important;
}
.tooltip-pin-button:focus,
.tooltip-pin-button:focus-visible {
outline: none;
border-color: var(--border-default) !important;
background-color: var(--bg-raised) !important;
}
/* Tooltip Header */
.tooltip-header {
display: flex;
@ -91,7 +98,7 @@
/* Tooltip Body */
.tooltip-body {
padding: 1rem !important;
padding: 1rem;
color: var(--text-primary) !important;
font-size: 0.875rem !important;
line-height: 1.6 !important;

View File

@ -5,18 +5,20 @@ import { TooltipTip } from '@app/types/tips';
interface TooltipContentProps {
content?: React.ReactNode;
tips?: TooltipTip[];
extraRightPadding?: number;
}
export const TooltipContent: React.FC<TooltipContentProps> = ({
content,
tips,
extraRightPadding = 0,
}) => {
return (
<div
className={`${styles['tooltip-body']}`}
style={{
color: 'var(--text-primary)',
padding: '16px',
padding: `16px ${16 + extraRightPadding}px 16px 16px`,
fontSize: '14px',
lineHeight: '1.6'
}}

View File

@ -853,6 +853,12 @@ const SignSettings = ({
textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
label={translate('text.name', 'Text')}
placeholder={translate('text.placeholder', 'Enter text')}
fontLabel={translate('text.fontLabel', 'Font')}
fontSizeLabel={translate('text.fontSizeLabel', 'Font size')}
fontSizePlaceholder={translate('text.fontSizePlaceholder', 'Type or select font size (8-200)')}
colorLabel={translate('text.colorLabel', 'Text colour')}
onAnyChange={() => {
setPlacementManuallyPaused(false);
lastAppliedPlacementKey.current = null;

View File

@ -25,7 +25,7 @@ export interface UpgradeBannerAlertPayload {
freeTierLimit?: number;
}
export type TourType = 'admin' | 'tools';
export type TourType = 'admin' | 'tools' | 'whatsnew';
export interface StartTourPayload {
tourType: TourType;

View File

@ -29,6 +29,7 @@ export interface AppConfig {
enableAnalytics?: boolean | null;
enablePosthog?: boolean | null;
enableScarf?: boolean | null;
enableDesktopInstallSlide?: boolean;
premiumEnabled?: boolean;
premiumKey?: string;
termsAndConditions?: string;

View File

@ -111,6 +111,8 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
const loadSampleFile = useCallback(async () => {
try {
// Hide the modal immediately so the tour targets are visible while we load
closeFilesModal();
const response = await fetch(`${BASE_PATH}/samples/Sample.pdf`);
const blob = await response.blob();
const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' });

View File

@ -64,7 +64,10 @@ export function useServerExperience(): ServerExperienceValue {
const loginEnabled = config?.enableLogin !== false;
const configIsAdmin = Boolean(config?.isAdmin);
const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
// For no-login servers, treat everyone as a regular user (no effective admin)
// Commented out the previous self-reported admin path to avoid elevating users.
// const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
const effectiveIsAdmin = loginEnabled ? configIsAdmin : false;
const hasPaidLicense = config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE';
const setSelfReportedAdmin = useCallback((value: boolean) => {

View File

@ -49,7 +49,9 @@ const auditService = {
* Get audit system status
*/
async getSystemStatus(): Promise<AuditSystemStatus> {
const response = await apiClient.get<any>('/api/v1/proprietary/ui-data/audit-dashboard');
const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-dashboard', {
suppressErrorToast: true,
});
const data = response.data;
// Map V1 response to expected format

View File

@ -13,7 +13,7 @@ import {
UPGRADE_BANNER_ALERT_EVENT,
} from '@core/constants/events';
import { useServerExperience } from '@app/hooks/useServerExperience';
import { hasSeenStep } from '@core/components/onboarding/orchestrator/onboardingStorage';
import { isOnboardingCompleted } from '@core/components/onboarding/orchestrator/onboardingStorage';
const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt';
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
@ -26,6 +26,7 @@ const UpgradeBanner: React.FC = () => {
const onAuthRoute = isAuthRoute(location.pathname);
const { openCheckout } = useCheckout();
const {
loginEnabled,
totalUsers,
userCountResolved,
userCountLoading,
@ -34,9 +35,11 @@ const UpgradeBanner: React.FC = () => {
licenseLoading,
freeTierLimit,
overFreeTierLimit,
weeklyActiveUsers,
scenarioKey,
} = useServerExperience();
const onboardingComplete = hasSeenStep('welcome');
const onboardingComplete = isOnboardingCompleted();
console.log('onboardingComplete', onboardingComplete);
const [friendlyVisible, setFriendlyVisible] = useState(() => {
if (typeof window === 'undefined') return false;
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
@ -296,8 +299,18 @@ const UpgradeBanner: React.FC = () => {
);
};
const suppressForNoLogin =
!loginEnabled ||
(!loginEnabled && (weeklyActiveUsers ?? Number.POSITIVE_INFINITY) > 5);
// Don't show on auth routes or if neither banner type should show
if (onAuthRoute || (!friendlyVisible && !shouldEvaluateUrgent)) {
// Also suppress entirely for no-login servers (treat them as regular users only)
// and, per request, never surface upgrade messaging there when WAU > 5.
if (
onAuthRoute ||
suppressForNoLogin ||
(!friendlyVisible && !shouldEvaluateUrgent)
) {
return null;
}

View File

@ -29,8 +29,14 @@ const AdminAuditSection: React.FC = () => {
setError(null);
const status = await auditService.getSystemStatus();
setSystemStatus(status);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load audit system status');
} catch (err: any) {
// Check if this is a permission/license error (403/404)
const status = err?.response?.status;
if (status === 403 || status === 404) {
setError('enterprise-license-required');
} else {
setError(err instanceof Error ? err.message : 'Failed to load audit system status');
}
} finally {
setLoading(false);
}
@ -63,6 +69,16 @@ const AdminAuditSection: React.FC = () => {
}
if (error) {
if (error === 'enterprise-license-required') {
return (
<Alert color="blue" title={t('audit.enterpriseRequired', 'Enterprise License Required')}>
{t(
'audit.enterpriseRequiredMessage',
'The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics.'
)}
</Alert>
);
}
return (
<Alert color="red" title={t('audit.error.title', 'Error loading audit system')}>
{error}

View File

@ -51,6 +51,7 @@ const BASE_NO_LOGIN_CONFIG: AppConfig = {
appVersion: '2.1.3',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,
serverPort: 8080,
premiumEnabled: false,
runningProOrHigher: false,