mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Merge branch 'main' into codex/update-demo-page-for-admin-users
This commit is contained in:
commit
7e7bad4f86
17
.github/workflows/PR-Auto-Deploy-V2.yml
vendored
17
.github/workflows/PR-Auto-Deploy-V2.yml
vendored
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -47,11 +47,11 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
||||
) {
|
||||
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
|
||||
}
|
||||
|
||||
|
||||
// Translate the label (it's a translation key)
|
||||
const label = button.label ?? '';
|
||||
if (!label) return '';
|
||||
|
||||
|
||||
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
|
||||
const fallback = label.split('.').pop() || label;
|
||||
return t(label, fallback);
|
||||
@ -105,4 +105,4 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { isAuthRoute } from '@app/constants/routes';
|
||||
import { dispatchTourState } from '@app/constants/events';
|
||||
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
|
||||
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
|
||||
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
|
||||
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
|
||||
@ -20,10 +19,12 @@ import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
||||
import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig';
|
||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
|
||||
export default function Onboarding() {
|
||||
@ -39,6 +40,11 @@ export default function Onboarding() {
|
||||
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
|
||||
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
|
||||
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
|
||||
const { config, refetch: refetchConfig } = useAppConfig();
|
||||
const [analyticsError, setAnalyticsError] = useState<string | null>(null);
|
||||
const [analyticsLoading, setAnalyticsLoading] = useState(false);
|
||||
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
|
||||
const [analyticsModalDismissed, setAnalyticsModalDismissed] = useState(false);
|
||||
|
||||
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
|
||||
actions.updateRuntimeState({ selectedRole: role });
|
||||
@ -50,7 +56,34 @@ export default function Onboarding() {
|
||||
window.location.href = '/login';
|
||||
}, [actions]);
|
||||
|
||||
const handleButtonAction = useCallback((action: ButtonAction) => {
|
||||
// Check if we should show analytics modal before onboarding
|
||||
useEffect(() => {
|
||||
if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) {
|
||||
setShowAnalyticsModal(true);
|
||||
}
|
||||
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
|
||||
|
||||
const handleAnalyticsChoice = useCallback(async (enableAnalytics: boolean) => {
|
||||
if (analyticsLoading) return;
|
||||
setAnalyticsLoading(true);
|
||||
setAnalyticsError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enabled', enableAnalytics.toString());
|
||||
|
||||
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
|
||||
await refetchConfig();
|
||||
setShowAnalyticsModal(false);
|
||||
setAnalyticsModalDismissed(true);
|
||||
setAnalyticsLoading(false);
|
||||
} catch (error) {
|
||||
setAnalyticsError(error instanceof Error ? error.message : 'Unknown error');
|
||||
setAnalyticsLoading(false);
|
||||
}
|
||||
}, [analyticsLoading, refetchConfig]);
|
||||
|
||||
const handleButtonAction = useCallback(async (action: ButtonAction) => {
|
||||
switch (action) {
|
||||
case 'next':
|
||||
case 'complete-close':
|
||||
@ -69,35 +102,43 @@ export default function Onboarding() {
|
||||
case 'security-next':
|
||||
if (!runtimeState.selectedRole) return;
|
||||
if (runtimeState.selectedRole !== 'admin') {
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
||||
actions.updateRuntimeState({ tourType: 'whatsnew' });
|
||||
setIsTourOpen(true);
|
||||
}
|
||||
actions.complete();
|
||||
break;
|
||||
case 'launch-admin':
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
|
||||
actions.complete();
|
||||
actions.updateRuntimeState({ tourType: 'admin' });
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
case 'launch-tools':
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
||||
actions.complete();
|
||||
actions.updateRuntimeState({ tourType: 'whatsnew' });
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
case 'launch-auto': {
|
||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType });
|
||||
actions.complete();
|
||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew';
|
||||
actions.updateRuntimeState({ tourType });
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
}
|
||||
case 'skip-to-license':
|
||||
markStepSeen('tour');
|
||||
actions.updateRuntimeState({ tourRequested: false });
|
||||
actions.complete();
|
||||
break;
|
||||
case 'skip-tour':
|
||||
actions.complete();
|
||||
break;
|
||||
case 'see-plans':
|
||||
actions.complete();
|
||||
navigate('/settings/adminPlan');
|
||||
break;
|
||||
case 'enable-analytics':
|
||||
await handleAnalyticsChoice(true);
|
||||
break;
|
||||
case 'disable-analytics':
|
||||
await handleAnalyticsChoice(false);
|
||||
break;
|
||||
}
|
||||
}, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
|
||||
}, [actions, handleAnalyticsChoice, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
|
||||
|
||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||
const [isTourOpen, setIsTourOpen] = useState(false);
|
||||
@ -117,10 +158,7 @@ export default function Onboarding() {
|
||||
backToAllTools: tourOrch.backToAllTools,
|
||||
selectCropTool: tourOrch.selectCropTool,
|
||||
loadSampleFile: tourOrch.loadSampleFile,
|
||||
switchToViewer: tourOrch.switchToViewer,
|
||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
selectFirstFile: tourOrch.selectFirstFile,
|
||||
pinFile: tourOrch.pinFile,
|
||||
modifyCropSettings: tourOrch.modifyCropSettings,
|
||||
executeTool: tourOrch.executeTool,
|
||||
@ -130,6 +168,24 @@ export default function Onboarding() {
|
||||
[t, tourOrch, closeFilesModal, openFilesModal]
|
||||
);
|
||||
|
||||
const whatsNewStepsConfig = useMemo(
|
||||
() => createWhatsNewStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveWorkbenchState: tourOrch.saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools: tourOrch.backToAllTools,
|
||||
openFilesModal,
|
||||
loadSampleFile: tourOrch.loadSampleFile,
|
||||
switchToViewer: tourOrch.switchToViewer,
|
||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
selectFirstFile: tourOrch.selectFirstFile,
|
||||
},
|
||||
}),
|
||||
[t, tourOrch, closeFilesModal, openFilesModal]
|
||||
);
|
||||
|
||||
const adminStepsConfig = useMemo(
|
||||
() => createAdminStepsConfig({
|
||||
t,
|
||||
@ -144,21 +200,19 @@ export default function Onboarding() {
|
||||
);
|
||||
|
||||
const tourSteps = useMemo<StepType[]>(() => {
|
||||
const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
|
||||
return Object.values(config);
|
||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep?.id === 'tour' && !isTourOpen) {
|
||||
markStepSeen('tour');
|
||||
setIsTourOpen(true);
|
||||
switch (runtimeState.tourType) {
|
||||
case 'admin':
|
||||
return Object.values(adminStepsConfig);
|
||||
case 'whatsnew':
|
||||
return Object.values(whatsNewStepsConfig);
|
||||
default:
|
||||
return Object.values(userStepsConfig);
|
||||
}
|
||||
}, [currentStep, isTourOpen, activeFlow]);
|
||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalTourRequested) {
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
|
||||
markStepSeen('tour');
|
||||
actions.updateRuntimeState({ tourType: requestedTourType });
|
||||
setIsTourOpen(true);
|
||||
clearTourRequest();
|
||||
}
|
||||
@ -176,9 +230,9 @@ export default function Onboarding() {
|
||||
} else {
|
||||
tourOrch.restoreWorkbenchState();
|
||||
}
|
||||
markStepSeen('tour');
|
||||
if (currentStep?.id === 'tour') actions.complete();
|
||||
}, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]);
|
||||
// Advance to next onboarding step after tour completes
|
||||
actions.complete();
|
||||
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]);
|
||||
|
||||
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
|
||||
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
||||
@ -216,8 +270,10 @@ export default function Onboarding() {
|
||||
firstLoginUsername: runtimeState.firstLoginUsername,
|
||||
onPasswordChanged: handlePasswordChanged,
|
||||
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
|
||||
analyticsError,
|
||||
analyticsLoading,
|
||||
});
|
||||
}, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
|
||||
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
|
||||
|
||||
const modalSlideCount = useMemo(() => {
|
||||
return activeFlow.filter((step) => step.type === 'modal-slide').length;
|
||||
@ -237,8 +293,45 @@ export default function Onboarding() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show analytics modal before onboarding if needed
|
||||
if (showAnalyticsModal) {
|
||||
const slideDefinition = SLIDE_DEFINITIONS['analytics-choice'];
|
||||
const slideContent = slideDefinition.createSlide({
|
||||
osLabel: '',
|
||||
osUrl: '',
|
||||
selectedRole: null,
|
||||
onRoleSelect: () => {},
|
||||
analyticsError,
|
||||
analyticsLoading,
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={slideDefinition}
|
||||
slideContent={slideContent}
|
||||
runtimeState={runtimeState}
|
||||
modalSlideCount={1}
|
||||
currentModalSlideIndex={0}
|
||||
onSkip={() => {}} // No skip allowed
|
||||
onAction={async (action) => {
|
||||
if (action === 'enable-analytics') {
|
||||
await handleAnalyticsChoice(true);
|
||||
} else if (action === 'disable-analytics') {
|
||||
await handleAnalyticsChoice(false);
|
||||
}
|
||||
}}
|
||||
allowDismiss={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showLicenseSlide) {
|
||||
const slideDefinition = SLIDE_DEFINITIONS['server-license'];
|
||||
const baseSlideDefinition = SLIDE_DEFINITIONS['server-license'];
|
||||
// Remove back button for external license notice
|
||||
const slideDefinition = {
|
||||
...baseSlideDefinition,
|
||||
buttons: baseSlideDefinition.buttons.filter(btn => btn.key !== 'license-back')
|
||||
};
|
||||
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
|
||||
const slideContent = slideDefinition.createSlide({
|
||||
osLabel: '',
|
||||
@ -250,7 +343,7 @@ export default function Onboarding() {
|
||||
licenseNotice: effectiveLicenseNotice,
|
||||
loginEnabled: serverExperience.loginEnabled,
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={slideDefinition}
|
||||
@ -271,40 +364,34 @@ export default function Onboarding() {
|
||||
);
|
||||
}
|
||||
|
||||
// Always render the tour component (it controls its own visibility with isOpen)
|
||||
const tourComponent = (
|
||||
<OnboardingTour
|
||||
isOpen={isTourOpen}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
|
||||
// If no active onboarding, just show the tour (which may or may not be open)
|
||||
if (isLoading || !isActive || !currentStep) {
|
||||
return (
|
||||
<OnboardingTour
|
||||
isOpen={isTourOpen}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
return tourComponent;
|
||||
}
|
||||
|
||||
// If tour is open, hide the onboarding modal and just show the tour
|
||||
if (isTourOpen) {
|
||||
return tourComponent;
|
||||
}
|
||||
|
||||
// Render the current onboarding step
|
||||
switch (currentStep.type) {
|
||||
case 'tool-prompt':
|
||||
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
|
||||
|
||||
case 'tour':
|
||||
return (
|
||||
<OnboardingTour
|
||||
isOpen={true}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'analytics-modal':
|
||||
return <AdminAnalyticsChoiceModal opened={true} onClose={actions.complete} />;
|
||||
|
||||
case 'modal-slide':
|
||||
if (!currentSlideDefinition || !currentSlideContent) return null;
|
||||
return (
|
||||
|
||||
@ -6,8 +6,9 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Stack } from '@mantine/core';
|
||||
import { Modal, Stack, ActionIcon } from '@mantine/core';
|
||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
@ -28,6 +29,7 @@ interface OnboardingModalSlideProps {
|
||||
currentModalSlideIndex: number;
|
||||
onSkip: () => void;
|
||||
onAction: (action: ButtonAction) => void;
|
||||
allowDismiss?: boolean;
|
||||
}
|
||||
|
||||
export default function OnboardingModalSlide({
|
||||
@ -38,6 +40,7 @@ export default function OnboardingModalSlide({
|
||||
currentModalSlideIndex,
|
||||
onSkip,
|
||||
onAction,
|
||||
allowDismiss = true,
|
||||
}: OnboardingModalSlideProps) {
|
||||
|
||||
const renderHero = () => {
|
||||
@ -62,6 +65,9 @@ export default function OnboardingModalSlide({
|
||||
{slideDefinition.hero.type === 'lock' && (
|
||||
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'analytics' && (
|
||||
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
||||
{slideDefinition.hero.type === 'logo' && (
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||
@ -75,6 +81,7 @@ export default function OnboardingModalSlide({
|
||||
opened={true}
|
||||
onClose={onSkip}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={allowDismiss}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
@ -93,6 +100,31 @@ export default function OnboardingModalSlide({
|
||||
isActive
|
||||
slideKey={slideContent.key}
|
||||
/>
|
||||
{allowDismiss && (
|
||||
<ActionIcon
|
||||
onClick={onSkip}
|
||||
radius="md"
|
||||
size={36}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
|
||||
{renderHero()}
|
||||
</div>
|
||||
@ -114,7 +146,9 @@ export default function OnboardingModalSlide({
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
||||
{modalSlideCount > 1 && (
|
||||
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
||||
)}
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<SlideButtons
|
||||
|
||||
@ -49,7 +49,7 @@ interface CloseArgs {
|
||||
|
||||
interface OnboardingTourProps {
|
||||
tourSteps: StepType[];
|
||||
tourType: 'admin' | 'tools';
|
||||
tourType: 'admin' | 'tools' | 'whatsnew';
|
||||
isRTL: boolean;
|
||||
t: TFunction;
|
||||
isOpen: boolean;
|
||||
|
||||
@ -4,6 +4,8 @@ import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckS
|
||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
|
||||
import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide';
|
||||
import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide';
|
||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||
|
||||
export type SlideId =
|
||||
@ -12,9 +14,11 @@ export type SlideId =
|
||||
| 'desktop-install'
|
||||
| 'security-check'
|
||||
| 'admin-overview'
|
||||
| 'server-license';
|
||||
| 'server-license'
|
||||
| 'tour-overview'
|
||||
| 'analytics-choice';
|
||||
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics';
|
||||
|
||||
export type ButtonAction =
|
||||
| 'next'
|
||||
@ -27,7 +31,10 @@ export type ButtonAction =
|
||||
| 'launch-tools'
|
||||
| 'launch-auto'
|
||||
| 'see-plans'
|
||||
| 'skip-to-license';
|
||||
| 'skip-to-license'
|
||||
| 'skip-tour'
|
||||
| 'enable-analytics'
|
||||
| 'disable-analytics';
|
||||
|
||||
export interface FlowState {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
@ -52,6 +59,8 @@ export interface SlideFactoryParams {
|
||||
firstLoginUsername?: string;
|
||||
onPasswordChanged?: () => void;
|
||||
usingDefaultCredentials?: boolean;
|
||||
analyticsError?: string | null;
|
||||
analyticsLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface HeroDefinition {
|
||||
@ -79,9 +88,9 @@ export interface SlideDefinition {
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
'first-login': {
|
||||
id: 'first-login',
|
||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||
FirstLoginSlide({
|
||||
username: firstLoginUsername || '',
|
||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||
FirstLoginSlide({
|
||||
username: firstLoginUsername || '',
|
||||
onPasswordChanged: onPasswordChanged || (() => {}),
|
||||
usingDefaultCredentials: usingDefaultCredentials || false,
|
||||
}),
|
||||
@ -194,6 +203,13 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
|
||||
hero: { type: 'dual-icon' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'license-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'license-close',
|
||||
type: 'button',
|
||||
@ -212,5 +228,58 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
'tour-overview': {
|
||||
id: 'tour-overview',
|
||||
createSlide: () => TourOverviewSlide(),
|
||||
hero: { type: 'rocket' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'tour-overview-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'tour-overview-skip',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.skipForNow',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'skip-tour',
|
||||
},
|
||||
{
|
||||
key: 'tour-overview-show',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.showMeAround',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'launch-tools',
|
||||
},
|
||||
],
|
||||
},
|
||||
'analytics-choice': {
|
||||
id: 'analytics-choice',
|
||||
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
|
||||
hero: { type: 'analytics' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'analytics-disable',
|
||||
type: 'button',
|
||||
label: 'no',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'disable-analytics',
|
||||
},
|
||||
{
|
||||
key: 'analytics-enable',
|
||||
type: 'button',
|
||||
label: 'yes',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'enable-analytics',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -5,21 +5,20 @@ export type OnboardingStepId =
|
||||
| 'security-check'
|
||||
| 'admin-overview'
|
||||
| 'tool-layout'
|
||||
| 'tour'
|
||||
| 'tour-overview'
|
||||
| 'server-license'
|
||||
| 'analytics-choice';
|
||||
|
||||
export type OnboardingStepType =
|
||||
| 'modal-slide'
|
||||
| 'tool-prompt'
|
||||
| 'tour'
|
||||
| 'analytics-modal';
|
||||
| 'tool-prompt';
|
||||
|
||||
export interface OnboardingRuntimeState {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
tourRequested: boolean;
|
||||
tourType: 'admin' | 'tools';
|
||||
tourType: 'admin' | 'tools' | 'whatsnew';
|
||||
isDesktopApp: boolean;
|
||||
desktopSlideEnabled: boolean;
|
||||
analyticsNotConfigured: boolean;
|
||||
analyticsEnabled: boolean;
|
||||
licenseNotice: {
|
||||
@ -42,13 +41,13 @@ export interface OnboardingStep {
|
||||
id: OnboardingStepId;
|
||||
type: OnboardingStepType;
|
||||
condition: (ctx: OnboardingConditionContext) => boolean;
|
||||
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license';
|
||||
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice';
|
||||
}
|
||||
|
||||
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||
selectedRole: null,
|
||||
tourRequested: false,
|
||||
tourType: 'tools',
|
||||
tourType: 'whatsnew',
|
||||
isDesktopApp: false,
|
||||
analyticsNotConfigured: false,
|
||||
analyticsEnabled: false,
|
||||
@ -61,6 +60,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||
requiresPasswordChange: false,
|
||||
firstLoginUsername: '',
|
||||
usingDefaultCredentials: false,
|
||||
desktopSlideEnabled: true,
|
||||
};
|
||||
|
||||
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
@ -76,18 +76,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
slideId: 'welcome',
|
||||
condition: () => true,
|
||||
},
|
||||
{
|
||||
id: 'desktop-install',
|
||||
type: 'modal-slide',
|
||||
slideId: 'desktop-install',
|
||||
condition: (ctx) => !ctx.isDesktopApp,
|
||||
},
|
||||
{
|
||||
id: 'security-check',
|
||||
type: 'modal-slide',
|
||||
slideId: 'security-check',
|
||||
condition: (ctx) => !ctx.loginEnabled && !ctx.isDesktopApp,
|
||||
},
|
||||
{
|
||||
id: 'admin-overview',
|
||||
type: 'modal-slide',
|
||||
@ -95,14 +83,27 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
condition: (ctx) => ctx.effectiveIsAdmin,
|
||||
},
|
||||
{
|
||||
id: 'tool-layout',
|
||||
type: 'tool-prompt',
|
||||
condition: () => true,
|
||||
id: 'desktop-install',
|
||||
type: 'modal-slide',
|
||||
slideId: 'desktop-install',
|
||||
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
|
||||
},
|
||||
{
|
||||
id: 'tour',
|
||||
type: 'tour',
|
||||
condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin,
|
||||
id: 'security-check',
|
||||
type: 'modal-slide',
|
||||
slideId: 'security-check',
|
||||
condition: () => false,
|
||||
},
|
||||
{
|
||||
id: 'tool-layout',
|
||||
type: 'tool-prompt',
|
||||
condition: () => false,
|
||||
},
|
||||
{
|
||||
id: 'tour-overview',
|
||||
type: 'modal-slide',
|
||||
slideId: 'tour-overview',
|
||||
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin',
|
||||
},
|
||||
{
|
||||
id: 'server-license',
|
||||
@ -110,11 +111,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
slideId: 'server-license',
|
||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
||||
},
|
||||
{
|
||||
id: 'analytics-choice',
|
||||
type: 'analytics-modal',
|
||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.analyticsNotConfigured,
|
||||
},
|
||||
];
|
||||
|
||||
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
|
||||
|
||||
@ -1,94 +1,71 @@
|
||||
import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
|
||||
const STORAGE_PREFIX = 'onboarding';
|
||||
const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`;
|
||||
const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`;
|
||||
|
||||
export function getStorageKey(stepId: OnboardingStepId): string {
|
||||
return `${STORAGE_PREFIX}::${stepId}`;
|
||||
}
|
||||
|
||||
export function hasSeenStep(stepId: OnboardingStepId): boolean {
|
||||
export function isOnboardingCompleted(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return localStorage.getItem(getStorageKey(stepId)) === 'true';
|
||||
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function markStepSeen(stepId: OnboardingStepId): void {
|
||||
export function markOnboardingCompleted(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(stepId), 'true');
|
||||
localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true');
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error marking step as seen:', error);
|
||||
console.error('[onboardingStorage] Error marking onboarding as completed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetStepSeen(stepId: OnboardingStepId): void {
|
||||
export function resetOnboardingProgress(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.removeItem(getStorageKey(stepId));
|
||||
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error resetting step seen:', error);
|
||||
console.error('[onboardingStorage] Error resetting onboarding progress:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAllOnboardingProgress(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
export function hasShownToursTooltip(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const prefix = `${STORAGE_PREFIX}::`;
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(prefix)) keysToRemove.push(key);
|
||||
}
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error resetting all onboarding progress:', error);
|
||||
return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getOnboardingStorageState(): Record<string, boolean> {
|
||||
const state: Record<string, boolean> = {};
|
||||
ONBOARDING_STEPS.forEach((step) => {
|
||||
state[step.id] = hasSeenStep(step.id);
|
||||
});
|
||||
return state;
|
||||
export function markToursTooltipShown(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(TOURS_TOOLTIP_KEY, 'true');
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error marking tours tooltip as shown:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateFromLegacyPreferences(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
const migrationKey = `${STORAGE_PREFIX}::migrated`;
|
||||
|
||||
|
||||
try {
|
||||
// Skip if already migrated
|
||||
if (localStorage.getItem(migrationKey) === 'true') return;
|
||||
|
||||
|
||||
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
|
||||
if (prefsRaw) {
|
||||
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
||||
|
||||
// Migrate based on legacy flags
|
||||
if (prefs.hasSeenIntroOnboarding === true) {
|
||||
markStepSeen('welcome');
|
||||
markStepSeen('desktop-install');
|
||||
markStepSeen('security-check');
|
||||
markStepSeen('admin-overview');
|
||||
|
||||
// If user had completed onboarding in old system, mark new system as complete
|
||||
if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) {
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
|
||||
if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) {
|
||||
markStepSeen('tool-layout');
|
||||
}
|
||||
|
||||
if (prefs.hasCompletedOnboarding === true) {
|
||||
markStepSeen('tour');
|
||||
markStepSeen('analytics-choice');
|
||||
markStepSeen('server-license');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Mark migration complete
|
||||
localStorage.setItem(migrationKey, 'true');
|
||||
} catch {
|
||||
|
||||
@ -12,8 +12,8 @@ import {
|
||||
DEFAULT_RUNTIME_STATE,
|
||||
} from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
import {
|
||||
hasSeenStep,
|
||||
markStepSeen,
|
||||
isOnboardingCompleted,
|
||||
markOnboardingCompleted,
|
||||
migrateFromLegacyPreferences,
|
||||
} from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import { accountService } from '@app/services/accountService';
|
||||
@ -35,12 +35,15 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
|
||||
if (typeof window === 'undefined') {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
|
||||
const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools';
|
||||
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
|
||||
const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew')
|
||||
? sessionTourType
|
||||
: 'whatsnew';
|
||||
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
|
||||
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
tourRequested,
|
||||
@ -54,7 +57,7 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
|
||||
|
||||
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
try {
|
||||
if (state.tourRequested !== undefined) {
|
||||
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
|
||||
@ -76,7 +79,7 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||
|
||||
function clearRuntimeStateSession(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
|
||||
sessionStorage.removeItem(SESSION_TOUR_TYPE);
|
||||
@ -145,7 +148,7 @@ export function useOnboardingOrchestrator(
|
||||
const location = useLocation();
|
||||
const bypassOnboarding = useBypassOnboarding();
|
||||
|
||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||
getInitialRuntimeState(defaultState)
|
||||
);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
@ -166,6 +169,7 @@ export function useOnboardingOrchestrator(
|
||||
...prev,
|
||||
analyticsEnabled: config?.enableAnalytics === true,
|
||||
analyticsNotConfigured: config?.enableAnalytics == null,
|
||||
desktopSlideEnabled: config?.enableDesktopInstallSlide ?? true,
|
||||
licenseNotice: {
|
||||
totalUsers: serverExperience.totalUsers,
|
||||
freeTierLimit: serverExperience.freeTierLimit,
|
||||
@ -221,7 +225,7 @@ export function useOnboardingOrchestrator(
|
||||
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
|
||||
...serverExperience,
|
||||
...runtimeState,
|
||||
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
|
||||
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
|
||||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
|
||||
}), [serverExperience, runtimeState]);
|
||||
|
||||
@ -235,53 +239,44 @@ export function useOnboardingOrchestrator(
|
||||
|
||||
// Wait for config AND admin status before calculating initial step
|
||||
const adminStatusResolved = !configLoading && (
|
||||
config?.enableLogin === false ||
|
||||
config?.enableLogin === undefined ||
|
||||
config?.enableLogin === false ||
|
||||
config?.enableLogin === undefined ||
|
||||
config?.isAdmin !== undefined
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
|
||||
if (configLoading || !adminStatusResolved) return;
|
||||
|
||||
let firstUnseenIndex = -1;
|
||||
for (let i = 0; i < activeFlow.length; i++) {
|
||||
// Special case: first-login step should always be considered "unseen" if requiresPasswordChange is true
|
||||
const isFirstLoginStep = activeFlow[i].id === 'first-login';
|
||||
const shouldTreatAsUnseen = isFirstLoginStep ? runtimeState.requiresPasswordChange : !hasSeenStep(activeFlow[i].id);
|
||||
|
||||
if (shouldTreatAsUnseen) {
|
||||
firstUnseenIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Force reset index when password change is required (overrides initialIndexSet)
|
||||
if (runtimeState.requiresPasswordChange && firstUnseenIndex === 0) {
|
||||
// If there are no steps to show, mark initialized/completed baseline
|
||||
if (activeFlow.length === 0) {
|
||||
setCurrentStepIndex(0);
|
||||
initialIndexSet.current = true;
|
||||
} else if (firstUnseenIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If onboarding has been completed, don't show it
|
||||
if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) {
|
||||
setCurrentStepIndex(activeFlow.length);
|
||||
initialIndexSet.current = true;
|
||||
} else if (!initialIndexSet.current) {
|
||||
setCurrentStepIndex(firstUnseenIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start from the beginning
|
||||
if (!initialIndexSet.current) {
|
||||
setCurrentStepIndex(0);
|
||||
initialIndexSet.current = true;
|
||||
}
|
||||
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
|
||||
|
||||
const totalSteps = activeFlow.length;
|
||||
|
||||
const allStepsAlreadySeen = useMemo(() => {
|
||||
if (activeFlow.length === 0) return false;
|
||||
return activeFlow.every(step => hasSeenStep(step.id));
|
||||
}, [activeFlow]);
|
||||
|
||||
const isComplete = isInitialized && initialIndexSet.current &&
|
||||
(currentStepIndex >= totalSteps || allStepsAlreadySeen);
|
||||
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen)
|
||||
? activeFlow[currentStepIndex]
|
||||
|
||||
const isComplete = isInitialized &&
|
||||
(totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
|
||||
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps)
|
||||
? activeFlow[currentStepIndex]
|
||||
: null;
|
||||
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
|
||||
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
|
||||
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
|
||||
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
@ -293,35 +288,33 @@ export function useOnboardingOrchestrator(
|
||||
}, [isComplete]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex >= totalSteps) {
|
||||
// Reached the end, mark onboarding as completed
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
setCurrentStepIndex(nextIndex);
|
||||
}, [currentStepIndex, totalSteps]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const skip = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
// Skip marks the entire onboarding as completed
|
||||
markOnboardingCompleted();
|
||||
setCurrentStepIndex(totalSteps);
|
||||
}, [totalSteps]);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStep || isLoading) {
|
||||
return;
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex >= totalSteps) {
|
||||
// Reached the end, mark onboarding as completed
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
// Special case: never auto-complete first-login step if requiresPasswordChange is true
|
||||
const isFirstLoginStep = currentStep.id === 'first-login';
|
||||
setCurrentStepIndex(nextIndex);
|
||||
}, [currentStepIndex, totalSteps]);
|
||||
|
||||
if (!isFirstLoginStep && hasSeenStep(currentStep.id)) {
|
||||
complete();
|
||||
}
|
||||
}, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]);
|
||||
|
||||
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
||||
persistRuntimeState(updates);
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { Button } from '@mantine/core';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import i18n from '@app/i18n';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
interface AnalyticsChoiceSlideProps {
|
||||
analyticsError?: string | null;
|
||||
}
|
||||
|
||||
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig {
|
||||
return {
|
||||
key: 'analytics-choice',
|
||||
title: i18n.t('analytics.title', 'Do you want to help make Stirling PDF better?'),
|
||||
body: (
|
||||
<div className={styles.bodyCopyInner}>
|
||||
<Trans
|
||||
i18nKey="analytics.paragraph1"
|
||||
defaults="Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents."
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<Trans
|
||||
i18nKey="analytics.paragraph2"
|
||||
defaults="Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better."
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<div style={{ textAlign: 'right', marginTop: 0 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://docs.stirlingpdf.com/analytics-telemetry/', '_blank')}
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{i18n.t('analytics.learnMore', 'Learn more about our analytics')}
|
||||
</Button>
|
||||
</div>
|
||||
{analyticsError && (
|
||||
<div style={{ color: 'var(--mantine-color-red-6)', marginTop: 12 }}>
|
||||
{analyticsError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#0EA5E9', '#6366F1'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import i18n from '@app/i18n';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
export default function TourOverviewSlide(): SlideConfig {
|
||||
return {
|
||||
key: 'tour-overview',
|
||||
title: i18n.t('onboarding.tourOverview.title', 'Tour Overview'),
|
||||
body: (
|
||||
<span className={styles.bodyCopyInner}>
|
||||
<Trans
|
||||
i18nKey="onboarding.tourOverview.body"
|
||||
defaults="Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need."
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#2563EB', '#7C3AED'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
|
||||
const SESSION_KEY = 'onboarding::bypass-all';
|
||||
const PARAM_KEY = 'bypassOnboarding';
|
||||
@ -34,13 +33,12 @@ function setStoredBypass(enabled: boolean): void {
|
||||
|
||||
/**
|
||||
* Detects the `bypassOnboarding` query parameter and stores it in session storage
|
||||
* so that onboarding remains disabled while the app is open. Also marks all steps
|
||||
* as seen to ensure any dependent UI elements remain hidden.
|
||||
* so that onboarding remains disabled while the app is open. Also marks onboarding
|
||||
* as completed to ensure any dependent UI elements remain hidden.
|
||||
*/
|
||||
export function useBypassOnboarding(): boolean {
|
||||
const location = useLocation();
|
||||
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
|
||||
const stepsMarkedRef = useRef(false);
|
||||
|
||||
const shouldBypassFromSearch = useMemo(() => {
|
||||
try {
|
||||
@ -57,14 +55,9 @@ export function useBypassOnboarding(): boolean {
|
||||
setBypassOnboarding(nextBypass);
|
||||
if (nextBypass) {
|
||||
setStoredBypass(true);
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
}, [shouldBypassFromSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bypassOnboarding || stepsMarkedRef.current) return;
|
||||
stepsMarkedRef.current = true;
|
||||
ONBOARDING_STEPS.forEach((step) => markStepSeen(step.id));
|
||||
}, [bypassOnboarding]);
|
||||
|
||||
return bypassOnboarding;
|
||||
}
|
||||
|
||||
@ -51,14 +51,14 @@ export function useTourRequest(): {
|
||||
clearTourRequest: () => void;
|
||||
} {
|
||||
const [tourRequested, setTourRequested] = useState(false);
|
||||
const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
|
||||
const [requestedTourType, setRequestedTourType] = useState<TourType>('whatsnew');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleTourRequest = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<StartTourPayload>;
|
||||
setRequestedTourType(detail?.tourType ?? 'tools');
|
||||
setRequestedTourType(detail?.tourType ?? 'whatsnew');
|
||||
setTourRequested(true);
|
||||
};
|
||||
|
||||
|
||||
@ -8,12 +8,8 @@ export enum TourStep {
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
@ -28,10 +24,7 @@ interface UserStepActions {
|
||||
backToAllTools: () => void;
|
||||
selectCropTool: () => void;
|
||||
loadSampleFile: () => void;
|
||||
switchToViewer: () => void;
|
||||
switchToPageEditor: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
selectFirstFile: () => void;
|
||||
pinFile: () => void;
|
||||
modifyCropSettings: () => void;
|
||||
executeTool: () => void;
|
||||
@ -50,10 +43,7 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
@ -108,26 +98,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEW_SWITCHER]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
|
||||
position: 'bottom',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEWER]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToViewer(),
|
||||
},
|
||||
[TourStep.PAGE_EDITOR]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToPageEditor(),
|
||||
},
|
||||
[TourStep.ACTIVE_FILES]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
|
||||
@ -141,14 +111,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.SELECT_CONTROLS]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
|
||||
position: 'left',
|
||||
padding: 5,
|
||||
action: () => selectFirstFile(),
|
||||
},
|
||||
[TourStep.CROP_SETTINGS]: {
|
||||
selector: '[data-tour="crop-settings"]',
|
||||
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
|
||||
|
||||
197
frontend/src/core/components/onboarding/whatsNewStepsConfig.ts
Normal file
197
frontend/src/core/components/onboarding/whatsNewStepsConfig.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,139 +0,0 @@
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Button,
|
||||
Text,
|
||||
Title,
|
||||
Anchor,
|
||||
useMantineTheme,
|
||||
useComputedColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import { Z_ANALYTICS_MODAL } from '@app/styles/zIndex';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface AdminAnalyticsChoiceModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnalyticsChoiceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { refetch } = useAppConfig();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const theme = useMantineTheme();
|
||||
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
|
||||
const isDark = computedColorScheme === 'dark';
|
||||
const privacyHighlightStyles = {
|
||||
color: isDark ? '#FFFFFF' : theme.colors.blue[7],
|
||||
padding: `${theme.spacing.xs} ${theme.spacing.sm}`,
|
||||
borderRadius: theme.radius.md,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center' as const,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
letterSpacing: 0.3,
|
||||
};
|
||||
|
||||
const handleChoice = async (enableAnalytics: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enabled', enableAnalytics.toString());
|
||||
|
||||
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
|
||||
|
||||
// Refetch config to apply new settings without page reload
|
||||
await refetch();
|
||||
|
||||
// Close the modal after successful save
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable = () => {
|
||||
handleChoice(true);
|
||||
};
|
||||
|
||||
const handleDisable = () => {
|
||||
handleChoice(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {}} // Prevent closing
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
zIndex={Z_ANALYTICS_MODAL}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Title order={2}>{t('analytics.title', 'Do you want make Stirling PDF better?')}</Title>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product.')}
|
||||
</Text>
|
||||
<Text size="sm" style={privacyHighlightStyles}>
|
||||
• {t('analytics.privacyAssurance', 'We do not track any personal information or the contents of your files.')} •
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '}
|
||||
<Anchor
|
||||
href="https://docs.stirlingpdf.com/analytics-telemetry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
{t('analytics.learnMore', 'Learn more')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Text c="red" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
onClick={handleEnable}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
size="md"
|
||||
>
|
||||
{t('analytics.enable', 'Enable analytics')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleDisable}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
>
|
||||
{t('analytics.disable', 'Disable analytics')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -95,6 +95,22 @@ export default function Footer({
|
||||
>
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://discord.gg/Cn8pWhQRxZ"
|
||||
>
|
||||
{t('footer.discord', 'Discord')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
>
|
||||
{t('footer.issues', 'GitHub')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -33,6 +33,10 @@ export interface TooltipProps {
|
||||
disabled?: boolean;
|
||||
/** If false, tooltip will not open on focus (hover only) */
|
||||
openOnFocus?: boolean;
|
||||
/** If true, tooltip stays open until explicitly closed (ignores hover/blur/esc/outside) */
|
||||
manualCloseOnly?: boolean;
|
||||
/** Show a close button even when not pinned */
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
@ -55,6 +59,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
closeOnOutside = true,
|
||||
disabled = false,
|
||||
openOnFocus = true,
|
||||
manualCloseOnly = false,
|
||||
showCloseButton = false,
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
@ -81,6 +87,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
||||
const allowAutoClose = !manualCloseOnly;
|
||||
|
||||
const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => {
|
||||
const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr';
|
||||
@ -132,11 +139,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
}
|
||||
|
||||
// Not pinned and configured to close on outside
|
||||
if (closeOnOutside && !insideTooltip && !insideTrigger) {
|
||||
if (allowAutoClose && closeOnOutside && !insideTooltip && !insideTrigger) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[isPinned, closeOnOutside, setOpen]
|
||||
[isPinned, closeOnOutside, setOpen, allowAutoClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -200,10 +207,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
if (!isPinned) setOpen(false);
|
||||
if (allowAutoClose && !isPinned) setOpen(false);
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
},
|
||||
[clearTimers, isPinned, setOpen, children.props]
|
||||
[clearTimers, isPinned, setOpen, children.props, allowAutoClose]
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
@ -257,15 +264,16 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
return;
|
||||
}
|
||||
clearTimers();
|
||||
if (!isPinned) setOpen(false);
|
||||
if (allowAutoClose && !isPinned) setOpen(false);
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
},
|
||||
[isPinned, setOpen, children.props, clearTimers]
|
||||
[isPinned, setOpen, children.props, allowAutoClose, clearTimers]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (manualCloseOnly) return;
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}, [setOpen]);
|
||||
}, [setOpen, manualCloseOnly]);
|
||||
|
||||
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
|
||||
const handleTooltipPointerEnter = useCallback(() => {
|
||||
@ -276,9 +284,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
(e: React.PointerEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
|
||||
if (!isPinned) setOpen(false);
|
||||
if (allowAutoClose && !isPinned) setOpen(false);
|
||||
},
|
||||
[isPinned, setOpen]
|
||||
[isPinned, setOpen, allowAutoClose]
|
||||
);
|
||||
|
||||
// Enhance child with handlers and ref
|
||||
@ -301,6 +309,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
});
|
||||
|
||||
const shouldShowTooltip = open;
|
||||
const shouldShowCloseButton = showCloseButton || isPinned;
|
||||
|
||||
const tooltipElement = shouldShowTooltip ? (
|
||||
<div
|
||||
@ -325,7 +334,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
|
||||
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
|
||||
>
|
||||
{isPinned && (
|
||||
{shouldShowCloseButton && (
|
||||
<button
|
||||
className={styles['tooltip-pin-button']}
|
||||
onClick={(e) => {
|
||||
@ -363,7 +372,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
<span className={styles['tooltip-title']}>{header.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<TooltipContent content={content} tips={tips} />
|
||||
<TooltipContent content={content} tips={tips} extraRightPadding={shouldShowCloseButton ? 48 : 0} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { TOUR_STATE_EVENT, type TourStatePayload } from '@app/constants/events';
|
||||
import { isOnboardingCompleted, hasShownToursTooltip, markToursTooltipShown } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
|
||||
export interface ToursTooltipState {
|
||||
tooltipOpen: boolean | undefined;
|
||||
manualCloseOnly: boolean;
|
||||
showCloseButton: boolean;
|
||||
toursMenuOpen: boolean;
|
||||
setToursMenuOpen: (open: boolean) => void;
|
||||
handleTooltipOpenChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates all the logic for the tours tooltip:
|
||||
* - Shows automatically after onboarding/tour completes (once per user)
|
||||
* - Hides while the tours menu is open
|
||||
* - After dismissal, reverts to hover-only tooltip
|
||||
*/
|
||||
export function useToursTooltip(): ToursTooltipState {
|
||||
const [showToursTooltip, setShowToursTooltip] = useState(false);
|
||||
const [toursMenuOpen, setToursMenuOpen] = useState(false);
|
||||
const tourWasOpenRef = useRef(false);
|
||||
|
||||
// Auto-show when a tour ends (fires once per user)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleTourStateChange = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<TourStatePayload>;
|
||||
const wasOpen = tourWasOpenRef.current;
|
||||
tourWasOpenRef.current = detail.isOpen;
|
||||
|
||||
if (wasOpen && !detail.isOpen && !hasShownToursTooltip()) {
|
||||
setShowToursTooltip(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(TOUR_STATE_EVENT, handleTourStateChange);
|
||||
return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange);
|
||||
}, []);
|
||||
|
||||
// Show once after onboarding is complete
|
||||
useEffect(() => {
|
||||
if (isOnboardingCompleted() && !hasShownToursTooltip()) {
|
||||
setShowToursTooltip(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismissToursTooltip = useCallback(() => {
|
||||
markToursTooltipShown();
|
||||
setShowToursTooltip(false);
|
||||
}, []);
|
||||
|
||||
const hasBeenDismissed = hasShownToursTooltip();
|
||||
|
||||
const handleTooltipOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) {
|
||||
if (!hasBeenDismissed) {
|
||||
handleDismissToursTooltip();
|
||||
}
|
||||
} else if (!hasBeenDismissed && !toursMenuOpen) {
|
||||
setShowToursTooltip(true);
|
||||
}
|
||||
},
|
||||
[hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip]
|
||||
);
|
||||
|
||||
const tooltipOpen = toursMenuOpen ? false : hasBeenDismissed ? undefined : showToursTooltip;
|
||||
|
||||
return {
|
||||
tooltipOpen,
|
||||
manualCloseOnly: !hasBeenDismissed,
|
||||
showCloseButton: !hasBeenDismissed,
|
||||
toursMenuOpen,
|
||||
setToursMenuOpen,
|
||||
handleTooltipOpenChange,
|
||||
};
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
background: var(--bg-raised);
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 0.0625rem solid var(--primary-color, #3b82f6);
|
||||
border: 0.0625rem solid var(--border-default);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
z-index: 1;
|
||||
@ -60,6 +60,13 @@
|
||||
border-color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.tooltip-pin-button:focus,
|
||||
.tooltip-pin-button:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--border-default) !important;
|
||||
background-color: var(--bg-raised) !important;
|
||||
}
|
||||
|
||||
/* Tooltip Header */
|
||||
.tooltip-header {
|
||||
display: flex;
|
||||
@ -91,7 +98,7 @@
|
||||
|
||||
/* Tooltip Body */
|
||||
.tooltip-body {
|
||||
padding: 1rem !important;
|
||||
padding: 1rem;
|
||||
color: var(--text-primary) !important;
|
||||
font-size: 0.875rem !important;
|
||||
line-height: 1.6 !important;
|
||||
|
||||
@ -5,18 +5,20 @@ import { TooltipTip } from '@app/types/tips';
|
||||
interface TooltipContentProps {
|
||||
content?: React.ReactNode;
|
||||
tips?: TooltipTip[];
|
||||
extraRightPadding?: number;
|
||||
}
|
||||
|
||||
export const TooltipContent: React.FC<TooltipContentProps> = ({
|
||||
content,
|
||||
tips,
|
||||
extraRightPadding = 0,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`${styles['tooltip-body']}`}
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
padding: '16px',
|
||||
padding: `16px ${16 + extraRightPadding}px 16px 16px`,
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6'
|
||||
}}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -25,7 +25,7 @@ export interface UpgradeBannerAlertPayload {
|
||||
freeTierLimit?: number;
|
||||
}
|
||||
|
||||
export type TourType = 'admin' | 'tools';
|
||||
export type TourType = 'admin' | 'tools' | 'whatsnew';
|
||||
|
||||
export interface StartTourPayload {
|
||||
tourType: TourType;
|
||||
|
||||
@ -29,6 +29,7 @@ export interface AppConfig {
|
||||
enableAnalytics?: boolean | null;
|
||||
enablePosthog?: boolean | null;
|
||||
enableScarf?: boolean | null;
|
||||
enableDesktopInstallSlide?: boolean;
|
||||
premiumEnabled?: boolean;
|
||||
premiumKey?: string;
|
||||
termsAndConditions?: string;
|
||||
|
||||
@ -111,6 +111,8 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
|
||||
|
||||
const loadSampleFile = useCallback(async () => {
|
||||
try {
|
||||
// Hide the modal immediately so the tour targets are visible while we load
|
||||
closeFilesModal();
|
||||
const response = await fetch(`${BASE_PATH}/samples/Sample.pdf`);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' });
|
||||
|
||||
@ -64,7 +64,10 @@ export function useServerExperience(): ServerExperienceValue {
|
||||
|
||||
const loginEnabled = config?.enableLogin !== false;
|
||||
const configIsAdmin = Boolean(config?.isAdmin);
|
||||
const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
|
||||
// For no-login servers, treat everyone as a regular user (no effective admin)
|
||||
// Commented out the previous self-reported admin path to avoid elevating users.
|
||||
// const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
|
||||
const effectiveIsAdmin = loginEnabled ? configIsAdmin : false;
|
||||
const hasPaidLicense = config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE';
|
||||
|
||||
const setSelfReportedAdmin = useCallback((value: boolean) => {
|
||||
|
||||
@ -49,7 +49,9 @@ const auditService = {
|
||||
* Get audit system status
|
||||
*/
|
||||
async getSystemStatus(): Promise<AuditSystemStatus> {
|
||||
const response = await apiClient.get<any>('/api/v1/proprietary/ui-data/audit-dashboard');
|
||||
const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-dashboard', {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
const data = response.data;
|
||||
|
||||
// Map V1 response to expected format
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
UPGRADE_BANNER_ALERT_EVENT,
|
||||
} from '@core/constants/events';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
import { hasSeenStep } from '@core/components/onboarding/orchestrator/onboardingStorage';
|
||||
import { isOnboardingCompleted } from '@core/components/onboarding/orchestrator/onboardingStorage';
|
||||
|
||||
const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt';
|
||||
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@ -26,6 +26,7 @@ const UpgradeBanner: React.FC = () => {
|
||||
const onAuthRoute = isAuthRoute(location.pathname);
|
||||
const { openCheckout } = useCheckout();
|
||||
const {
|
||||
loginEnabled,
|
||||
totalUsers,
|
||||
userCountResolved,
|
||||
userCountLoading,
|
||||
@ -34,9 +35,11 @@ const UpgradeBanner: React.FC = () => {
|
||||
licenseLoading,
|
||||
freeTierLimit,
|
||||
overFreeTierLimit,
|
||||
weeklyActiveUsers,
|
||||
scenarioKey,
|
||||
} = useServerExperience();
|
||||
const onboardingComplete = hasSeenStep('welcome');
|
||||
const onboardingComplete = isOnboardingCompleted();
|
||||
console.log('onboardingComplete', onboardingComplete);
|
||||
const [friendlyVisible, setFriendlyVisible] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
|
||||
@ -296,8 +299,18 @@ const UpgradeBanner: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const suppressForNoLogin =
|
||||
!loginEnabled ||
|
||||
(!loginEnabled && (weeklyActiveUsers ?? Number.POSITIVE_INFINITY) > 5);
|
||||
|
||||
// Don't show on auth routes or if neither banner type should show
|
||||
if (onAuthRoute || (!friendlyVisible && !shouldEvaluateUrgent)) {
|
||||
// Also suppress entirely for no-login servers (treat them as regular users only)
|
||||
// and, per request, never surface upgrade messaging there when WAU > 5.
|
||||
if (
|
||||
onAuthRoute ||
|
||||
suppressForNoLogin ||
|
||||
(!friendlyVisible && !shouldEvaluateUrgent)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user