Add frontend autoformatting and set CI to require formatted code for all languages (#6052)

# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
This commit is contained in:
James Brunton
2026-04-10 17:41:19 +01:00
committed by GitHub
parent 33b2b5827a
commit a3e45bc182
1359 changed files with 57784 additions and 57460 deletions
@@ -118,7 +118,6 @@
}
}
.heroIconsContainer {
display: flex;
gap: 32px;
@@ -141,7 +140,9 @@
border: none;
padding: 0;
cursor: pointer;
transition: transform 0.2s ease, opacity 0.2s ease;
transition:
transform 0.2s ease,
opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
@@ -181,7 +182,14 @@
}
.iconLabel {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-family:
"Inter",
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Arial,
sans-serif;
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
@@ -266,7 +274,7 @@
opacity: 1;
border: 1px solid rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.9);
color: #1F2933;
color: #1f2933;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.7);
}
@@ -282,7 +290,14 @@
/* Title styles */
.titleText {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-family:
"Inter",
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Arial,
sans-serif;
font-weight: 600;
font-size: 22px;
color: var(--onboarding-title);
@@ -290,7 +305,14 @@
/* Body text styles */
.bodyText {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-family:
"Inter",
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Arial,
sans-serif;
font-size: 16px;
color: var(--onboarding-body);
line-height: 1.5;
@@ -314,8 +336,8 @@
}
.v2Badge {
background: #DBEFFF;
color: #2A4BFF;
background: #dbefff;
color: #2a4bff;
padding: 4px 12px;
border-radius: 6px;
font-size: 14px;
@@ -1,10 +1,10 @@
import React from 'react';
import { Button, Group, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { useTranslation } from 'react-i18next';
import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig';
import type { LicenseNotice } from '@app/types/types';
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
import React from "react";
import { Button, Group, ActionIcon } from "@mantine/core";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import { useTranslation } from "react-i18next";
import { ButtonDefinition, type FlowState } from "@app/components/onboarding/onboardingFlowConfig";
import type { LicenseNotice } from "@app/types/types";
import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
interface SlideButtonsProps {
slideDefinition: {
@@ -18,49 +18,49 @@ interface SlideButtonsProps {
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
const { t } = useTranslation();
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
const buttonStyles = (variant: ButtonDefinition['variant']) =>
variant === 'primary'
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
variant === "primary"
? {
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
background: "var(--onboarding-primary-button-bg)",
color: "var(--onboarding-primary-button-text)",
},
}
: {
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
};
const resolveButtonLabel = (button: ButtonDefinition) => {
// Special case: override "See Plans" with "Upgrade now" when over limit
if (
button.type === 'button' &&
slideDefinition.id === 'server-license' &&
button.action === 'see-plans' &&
button.type === "button" &&
slideDefinition.id === "server-license" &&
button.action === "see-plans" &&
licenseNotice.isOverLimit
) {
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
return t("onboarding.serverLicense.upgrade", "Upgrade now →");
}
// Translate the label (it's a translation key)
const label = button.label ?? '';
if (!label) return '';
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;
const fallback = label.split(".").pop() || label;
return t(label, fallback);
};
const renderButton = (button: ButtonDefinition) => {
const disabled = button.disabledWhen?.(flowState) ?? false;
if (button.type === 'icon') {
if (button.type === "icon") {
return (
<ActionIcon
key={button.key}
@@ -70,18 +70,18 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
disabled={disabled}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
}}
>
{button.icon === 'chevron-left' && <ChevronLeftIcon fontSize="small" />}
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
</ActionIcon>
);
}
const variant = button.variant ?? 'secondary';
const variant = button.variant ?? "secondary";
const label = resolveButtonLabel(button);
return (
@@ -1,33 +1,30 @@
import { useEffect, useMemo, useCallback, useState } from 'react';
import { type StepType } from '@reactour/tour';
import { useTranslation } from 'react-i18next';
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 { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
import {
useServerLicenseRequest,
useTourRequest,
} from '@app/components/onboarding/useOnboardingEffects';
import { useOnboardingDownload } from '@app/components/onboarding/useOnboardingDownload';
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
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 { useAppConfig } from '@app/contexts/AppConfigContext';
import apiClient from '@app/services/apiClient';
import '@app/components/onboarding/OnboardingTour.css';
import { useAccountLogout } from '@app/extensions/accountLogout';
import { useAuth } from '@app/auth/UseSession';
import { useEffect, useMemo, useCallback, useState } from "react";
import { type StepType } from "@reactour/tour";
import { useTranslation } from "react-i18next";
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 { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from "@app/components/onboarding/OnboardingTour";
import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide";
import { useServerLicenseRequest, useTourRequest } from "@app/components/onboarding/useOnboardingEffects";
import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload";
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
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 { useAppConfig } from "@app/contexts/AppConfigContext";
import apiClient from "@app/services/apiClient";
import "@app/components/onboarding/OnboardingTour.css";
import { useAccountLogout } from "@app/extensions/accountLogout";
import { useAuth } from "@app/auth/UseSession";
export default function Onboarding() {
const { t } = useTranslation();
@@ -52,13 +49,16 @@ export default function Onboarding() {
const accountLogout = useAccountLogout();
const { signOut } = useAuth();
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
actions.updateRuntimeState({ selectedRole: role });
serverExperience.setSelfReportedAdmin(role === 'admin');
}, [actions, serverExperience]);
const handleRoleSelect = useCallback(
(role: "admin" | "user" | null) => {
actions.updateRuntimeState({ selectedRole: role });
serverExperience.setSelfReportedAdmin(role === "admin");
},
[actions, serverExperience],
);
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
window.location.assign("/login");
}, []);
const handlePasswordChanged = useCallback(async () => {
@@ -80,84 +80,97 @@ export default function Onboarding() {
}
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
const handleAnalyticsChoice = useCallback(async (enableAnalytics: boolean) => {
if (analyticsLoading) return;
setAnalyticsLoading(true);
setAnalyticsError(null);
const handleAnalyticsChoice = useCallback(
async (enableAnalytics: boolean) => {
if (analyticsLoading) return;
setAnalyticsLoading(true);
setAnalyticsError(null);
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
const formData = new FormData();
formData.append("enabled", enableAnalytics.toString());
try {
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
await refetchConfig();
setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true);
} catch (error) {
setAnalyticsError(error instanceof Error ? error.message : 'Unknown error');
} finally {
setAnalyticsLoading(false);
}
}, [analyticsLoading, refetchConfig]);
const handleButtonAction = useCallback(async (action: ButtonAction) => {
switch (action) {
case 'next':
case 'complete-close':
actions.complete();
break;
case 'prev':
actions.prev();
break;
case 'close':
actions.skip();
break;
case 'download-selected':
handleDownloadSelected();
actions.complete();
break;
case 'security-next':
if (!runtimeState.selectedRole) return;
if (runtimeState.selectedRole !== 'admin') {
actions.updateRuntimeState({ tourType: 'whatsnew' });
setIsTourOpen(true);
}
actions.complete();
break;
case 'launch-admin':
actions.updateRuntimeState({ tourType: 'admin' });
setIsTourOpen(true);
break;
case 'launch-tools':
actions.updateRuntimeState({ tourType: 'whatsnew' });
setIsTourOpen(true);
break;
case 'launch-auto': {
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew';
actions.updateRuntimeState({ tourType });
setIsTourOpen(true);
break;
try {
await apiClient.post("/api/v1/settings/update-enable-analytics", formData);
await refetchConfig();
setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true);
} catch (error) {
setAnalyticsError(error instanceof Error ? error.message : "Unknown error");
} finally {
setAnalyticsLoading(false);
}
case 'skip-to-license':
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, handleAnalyticsChoice, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
},
[analyticsLoading, refetchConfig],
);
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
const handleButtonAction = useCallback(
async (action: ButtonAction) => {
switch (action) {
case "next":
case "complete-close":
actions.complete();
break;
case "prev":
actions.prev();
break;
case "close":
actions.skip();
break;
case "download-selected":
handleDownloadSelected();
actions.complete();
break;
case "security-next":
if (!runtimeState.selectedRole) return;
if (runtimeState.selectedRole !== "admin") {
actions.updateRuntimeState({ tourType: "whatsnew" });
setIsTourOpen(true);
}
actions.complete();
break;
case "launch-admin":
actions.updateRuntimeState({ tourType: "admin" });
setIsTourOpen(true);
break;
case "launch-tools":
actions.updateRuntimeState({ tourType: "whatsnew" });
setIsTourOpen(true);
break;
case "launch-auto": {
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === "admin" ? "admin" : "whatsnew";
actions.updateRuntimeState({ tourType });
setIsTourOpen(true);
break;
}
case "skip-to-license":
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,
handleAnalyticsChoice,
handleDownloadSelected,
navigate,
runtimeState.selectedRole,
serverExperience.effectiveIsAdmin,
],
);
const isRTL = typeof document !== "undefined" ? document.documentElement.dir === "rtl" : false;
const [isTourOpen, setIsTourOpen] = useState(false);
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
@@ -167,60 +180,63 @@ export default function Onboarding() {
const adminTourOrch = useAdminTourOrchestration();
const userStepsConfig = useMemo(
() => createUserStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
selectCropTool: tourOrch.selectCropTool,
loadSampleFile: tourOrch.loadSampleFile,
switchToActiveFiles: tourOrch.switchToActiveFiles,
pinFile: tourOrch.pinFile,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
openFilesModal,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal]
() =>
createUserStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
selectCropTool: tourOrch.selectCropTool,
loadSampleFile: tourOrch.loadSampleFile,
switchToActiveFiles: tourOrch.switchToActiveFiles,
pinFile: tourOrch.pinFile,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
openFilesModal,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal],
);
const whatsNewStepsConfig = useMemo(
() => createWhatsNewStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
openFilesModal,
loadSampleFile: tourOrch.loadSampleFile,
switchToViewer: tourOrch.switchToViewer,
switchToPageEditor: tourOrch.switchToPageEditor,
switchToActiveFiles: tourOrch.switchToActiveFiles,
selectFirstFile: tourOrch.selectFirstFile,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal]
() =>
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,
actions: {
saveAdminState: adminTourOrch.saveAdminState,
openConfigModal: adminTourOrch.openConfigModal,
navigateToSection: adminTourOrch.navigateToSection,
scrollNavToSection: adminTourOrch.scrollNavToSection,
},
}),
[t, adminTourOrch]
() =>
createAdminStepsConfig({
t,
actions: {
saveAdminState: adminTourOrch.saveAdminState,
openConfigModal: adminTourOrch.openConfigModal,
navigateToSection: adminTourOrch.navigateToSection,
scrollNavToSection: adminTourOrch.scrollNavToSection,
},
}),
[t, adminTourOrch],
);
const tourSteps = useMemo<StepType[]>(() => {
switch (runtimeState.tourType) {
case 'admin':
case "admin":
return Object.values(adminStepsConfig);
case 'whatsnew':
case "whatsnew":
return Object.values(whatsNewStepsConfig);
default:
return Object.values(userStepsConfig);
@@ -242,8 +258,8 @@ export default function Onboarding() {
// Handle first-login password change modal
useEffect(() => {
if(runtimeState.requiresPasswordChange === true) {
console.log('[Onboarding] User requires password change on first login.');
if (runtimeState.requiresPasswordChange === true) {
console.log("[Onboarding] User requires password change on first login.");
setFirstLoginModalOpen(true);
} else {
setFirstLoginModalOpen(false);
@@ -252,18 +268,18 @@ export default function Onboarding() {
// Handle MFA setup modal
useEffect(() => {
if(runtimeState.requiresMfaSetup === true) {
console.log('[Onboarding] User requires MFA setup.');
if (runtimeState.requiresMfaSetup === true) {
console.log("[Onboarding] User requires MFA setup.");
setMfaModalOpen(true);
} else {
console.log('[Onboarding] User does not require MFA setup.');
console.log("[Onboarding] User does not require MFA setup.");
setMfaModalOpen(false);
}
}, [runtimeState.requiresMfaSetup]);
const finishTour = useCallback(() => {
setIsTourOpen(false);
if (runtimeState.tourType === 'admin') {
if (runtimeState.tourType === "admin") {
adminTourOrch.restoreAdminState();
} else {
tourOrch.restoreWorkbenchState();
@@ -272,23 +288,29 @@ export default function Onboarding() {
actions.complete();
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]);
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
if (steps && tourCurrentStep === steps.length - 1) {
setIsOpen(false);
finishTour();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
}, [finishTour]);
const handleAdvanceTour = useCallback(
(args: AdvanceArgs) => {
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
if (steps && tourCurrentStep === steps.length - 1) {
setIsOpen(false);
finishTour();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
},
[finishTour],
);
const handleCloseTour = useCallback((args: CloseArgs) => {
args.setIsOpen(false);
finishTour();
}, [finishTour]);
const handleCloseTour = useCallback(
(args: CloseArgs) => {
args.setIsOpen(false);
finishTour();
},
[finishTour],
);
const currentSlideDefinition = useMemo(() => {
if (!currentStep || currentStep.type !== 'modal-slide' || !currentStep.slideId) {
if (!currentStep || currentStep.type !== "modal-slide" || !currentStep.slideId) {
return null;
}
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
@@ -312,15 +334,29 @@ export default function Onboarding() {
analyticsLoading,
onMfaSetupComplete: handleMfaSetupComplete,
});
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged, handleMfaSetupComplete]);
}, [
analyticsError,
analyticsLoading,
currentSlideDefinition,
osInfo,
osOptions,
runtimeState.selectedRole,
runtimeState.licenseNotice,
handleRoleSelect,
serverExperience.loginEnabled,
setSelectedDownloadUrl,
runtimeState.firstLoginUsername,
handlePasswordChanged,
handleMfaSetupComplete,
]);
const modalSlideCount = useMemo(() => {
return activeFlow.filter((step) => step.type === 'modal-slide').length;
return activeFlow.filter((step) => step.type === "modal-slide").length;
}, [activeFlow]);
const currentModalSlideIndex = useMemo(() => {
if (!currentStep || currentStep.type !== 'modal-slide') return 0;
const modalSlides = activeFlow.filter((step) => step.type === 'modal-slide');
if (!currentStep || currentStep.type !== "modal-slide") return 0;
const modalSlides = activeFlow.filter((step) => step.type === "modal-slide");
return modalSlides.findIndex((step) => step.id === currentStep.id);
}, [activeFlow, currentStep]);
@@ -334,10 +370,10 @@ export default function Onboarding() {
// Show analytics modal before onboarding if needed
if (showAnalyticsModal) {
const slideDefinition = SLIDE_DEFINITIONS['analytics-choice'];
const slideDefinition = SLIDE_DEFINITIONS["analytics-choice"];
const slideContent = slideDefinition.createSlide({
osLabel: '',
osUrl: '',
osLabel: "",
osUrl: "",
selectedRole: null,
onRoleSelect: () => {},
analyticsError,
@@ -353,9 +389,9 @@ export default function Onboarding() {
currentModalSlideIndex={0}
onSkip={() => {}} // No skip allowed
onAction={async (action) => {
if (action === 'enable-analytics') {
if (action === "enable-analytics") {
await handleAnalyticsChoice(true);
} else if (action === 'disable-analytics') {
} else if (action === "disable-analytics") {
await handleAnalyticsChoice(false);
}
}}
@@ -365,10 +401,10 @@ export default function Onboarding() {
}
if (firstLoginModalOpen) {
const baseSlideDefinition = SLIDE_DEFINITIONS['first-login'];
const baseSlideDefinition = SLIDE_DEFINITIONS["first-login"];
const slideContent = baseSlideDefinition.createSlide({
osLabel: '',
osUrl: '',
osLabel: "",
osUrl: "",
selectedRole: null,
onRoleSelect: () => {},
firstLoginUsername: runtimeState.firstLoginUsername,
@@ -385,7 +421,7 @@ export default function Onboarding() {
currentModalSlideIndex={0}
onSkip={() => {}}
onAction={async (action) => {
if (action === 'complete-close') {
if (action === "complete-close") {
handlePasswordChanged();
}
}}
@@ -395,11 +431,11 @@ export default function Onboarding() {
}
if (mfaModalOpen) {
console.log('[Onboarding] Rendering MFA setup modal slide.');
const baseSlideDefinition = SLIDE_DEFINITIONS['mfa-setup'];
console.log("[Onboarding] Rendering MFA setup modal slide.");
const baseSlideDefinition = SLIDE_DEFINITIONS["mfa-setup"];
const slideContent = baseSlideDefinition.createSlide({
osLabel: '',
osUrl: '',
osLabel: "",
osUrl: "",
selectedRole: null,
onRoleSelect: () => {},
onMfaSetupComplete: handleMfaSetupComplete,
@@ -414,7 +450,7 @@ export default function Onboarding() {
currentModalSlideIndex={0}
onSkip={() => {}}
onAction={async (action) => {
if (action === 'complete-close') {
if (action === "complete-close") {
handleMfaSetupComplete();
}
}}
@@ -424,16 +460,16 @@ export default function Onboarding() {
}
if (showLicenseSlide) {
const baseSlideDefinition = 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')
buttons: baseSlideDefinition.buttons.filter((btn) => btn.key !== "license-back"),
};
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
const slideContent = slideDefinition.createSlide({
osLabel: '',
osUrl: '',
osLabel: "",
osUrl: "",
osOptions: [],
onDownloadUrlChange: () => {},
selectedRole: null,
@@ -451,9 +487,9 @@ export default function Onboarding() {
currentModalSlideIndex={0}
onSkip={closeLicenseSlide}
onAction={(action) => {
if (action === 'see-plans') {
if (action === "see-plans") {
closeLicenseSlide();
navigate('/settings/adminPlan');
navigate("/settings/adminPlan");
} else {
closeLicenseSlide();
}
@@ -487,10 +523,10 @@ export default function Onboarding() {
// Render the current onboarding step
switch (currentStep.type) {
case 'tool-prompt':
case "tool-prompt":
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
case 'modal-slide':
case "modal-slide":
if (!currentSlideDefinition || !currentSlideContent) return null;
return (
<OnboardingModalSlide
@@ -1,25 +1,25 @@
/**
* OnboardingModalSlide Component
*
*
* Renders a single modal slide in the onboarding flow.
* Handles the hero image, content, stepper, and button actions.
*/
import React from 'react';
import { Modal, Stack, ActionIcon } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import CloseIcon from '@mui/icons-material/Close';
import React from "react";
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';
import type { SlideConfig } from '@app/types/types';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
import { SlideButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
import LocalIcon from '@app/components/shared/LocalIcon';
import { BASE_PATH } from '@app/constants/app';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import type { SlideDefinition, ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
import type { SlideConfig } from "@app/types/types";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
import OnboardingStepper from "@app/components/onboarding/OnboardingStepper";
import { SlideButtons } from "@app/components/onboarding/InitialOnboardingModal/renderButtons";
import LocalIcon from "@app/components/shared/LocalIcon";
import { BASE_PATH } from "@app/constants/app";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
interface OnboardingModalSlideProps {
slideDefinition: SlideDefinition;
@@ -42,9 +42,8 @@ export default function OnboardingModalSlide({
onAction,
allowDismiss = true,
}: OnboardingModalSlideProps) {
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
if (slideDefinition.hero.type === "dual-icon") {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
@@ -56,20 +55,20 @@ export default function OnboardingModalSlide({
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === 'rocket' && (
{slideDefinition.hero.type === "rocket" && (
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'shield' && (
{slideDefinition.hero.type === "shield" && (
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'lock' && (
{slideDefinition.hero.type === "lock" && (
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'analytics' && (
{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' && (
{slideDefinition.hero.type === "diamond" && <DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />}
{slideDefinition.hero.type === "logo" && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
)}
</div>
@@ -88,8 +87,8 @@ export default function OnboardingModalSlide({
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0, maxHeight: '90vh', overflow: 'hidden' },
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)', maxHeight: '90vh' },
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
content: { overflow: "hidden", border: "none", background: "var(--bg-surface)", maxHeight: "90vh" },
}}
>
<Stack gap={0} className={styles.modalContent}>
@@ -106,18 +105,18 @@ export default function OnboardingModalSlide({
radius="md"
size={36}
style={{
position: 'absolute',
position: "absolute",
top: 16,
right: 16,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(4px)',
backgroundColor: "rgba(255, 255, 255, 0.2)",
color: "white",
backdropFilter: "blur(4px)",
zIndex: 10,
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.3)',
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.3)",
},
},
}}
@@ -130,12 +129,9 @@ export default function OnboardingModalSlide({
</div>
</div>
<div className={styles.modalBody} style={{ overflowY: 'auto', maxHeight: 'calc(90vh - 220px)' }}>
<div className={styles.modalBody} style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}>
<Stack gap={16}>
<div
key={`title-${slideContent.key}`}
className={`${styles.title} ${styles.titleText}`}
>
<div key={`title-${slideContent.key}`} className={`${styles.title} ${styles.titleText}`}>
{slideContent.title}
</div>
@@ -146,9 +142,7 @@ export default function OnboardingModalSlide({
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
{modalSlideCount > 1 && (
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
)}
{modalSlideCount > 1 && <OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />}
<div className={styles.buttonContainer}>
<SlideButtons
@@ -164,4 +158,3 @@ export default function OnboardingModalSlide({
</Modal>
);
}
@@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
interface OnboardingStepperProps {
totalSteps: number;
@@ -17,18 +17,16 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
<div
className={className}
style={{
display: 'flex',
display: "flex",
gap: 8,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
}}
>
{items.map((index) => {
const isActive = index === activeStep;
const baseStyles: React.CSSProperties = {
background: isActive
? 'var(--onboarding-step-active)'
: 'var(--onboarding-step-inactive)',
background: isActive ? "var(--onboarding-step-active)" : "var(--onboarding-step-inactive)",
};
return (
@@ -48,5 +46,3 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
}
export default OnboardingStepper;
@@ -18,7 +18,8 @@
}
@keyframes pulse-glow {
0%, 100% {
0%,
100% {
box-shadow:
0 0 0 3px var(--mantine-primary-color-filled),
0 0 20px var(--mantine-primary-color-filled),
@@ -33,13 +34,13 @@
}
/* RTL: mirror step indicator and controls in Reactour popovers */
:root[dir='rtl'] .reactour__popover {
:root[dir="rtl"] .reactour__popover {
direction: rtl;
}
/* Minimal overrides retained for glow only */
:root[dir='rtl'] .reactour__badge {
:root[dir="rtl"] .reactour__badge {
left: auto;
right: 16px;
}
@@ -1,19 +1,19 @@
/**
* OnboardingTour Component
*
*
* Reusable tour wrapper that encapsulates all Reactour configuration.
* Used by the main Onboarding component for both the 'tour' step and
* when the tour is open but onboarding is inactive.
*/
import React from 'react';
import { TourProvider, useTour, type StepType } from '@reactour/tour';
import { CloseButton, ActionIcon } from '@mantine/core';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import CheckIcon from '@mui/icons-material/Check';
import type { TFunction } from 'i18next';
import i18n from '@app/i18n';
import React from "react";
import { TourProvider, useTour, type StepType } from "@reactour/tour";
import { CloseButton, ActionIcon } from "@mantine/core";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import CheckIcon from "@mui/icons-material/Check";
import type { TFunction } from "i18next";
import i18n from "@app/i18n";
/**
* TourContent - Controls the tour visibility
@@ -49,7 +49,7 @@ interface CloseArgs {
interface OnboardingTourProps {
tourSteps: StepType[];
tourType: 'admin' | 'tools' | 'whatsnew';
tourType: "admin" | "tools" | "whatsnew";
isRTL: boolean;
t: TFunction;
isOpen: boolean;
@@ -57,22 +57,14 @@ interface OnboardingTourProps {
onClose: (args: CloseArgs) => void;
}
export default function OnboardingTour({
tourSteps,
tourType,
isRTL,
t,
isOpen,
onAdvance,
onClose,
}: OnboardingTourProps) {
export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen, onAdvance, onClose }: OnboardingTourProps) {
if (!isOpen) return null;
return (
<TourProvider
key={`${tourType}-${i18n.language}`}
steps={tourSteps}
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
maskClassName={tourType === "admin" ? "admin-tour-mask" : undefined}
onClickClose={onClose}
onClickMask={onAdvance}
onClickHighlighted={(e, clickProps) => {
@@ -80,10 +72,10 @@ export default function OnboardingTour({
onAdvance(clickProps);
}}
keyboardHandler={(e, clickProps, status) => {
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
if (e.key === "ArrowRight" && !status?.isRightDisabled && clickProps) {
e.preventDefault();
onAdvance(clickProps);
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
} else if (e.key === "Escape" && !status?.isEscDisabled && clickProps) {
e.preventDefault();
onClose(clickProps);
}
@@ -92,12 +84,12 @@ export default function OnboardingTour({
styles={{
popover: (base) => ({
...base,
backgroundColor: 'var(--mantine-color-body)',
color: 'var(--mantine-color-text)',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '400px',
backgroundColor: "var(--mantine-color-body)",
color: "var(--mantine-color-text)",
borderRadius: "8px",
padding: "20px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
maxWidth: "400px",
}),
maskArea: (base) => ({
...base,
@@ -105,11 +97,11 @@ export default function OnboardingTour({
}),
badge: (base) => ({
...base,
backgroundColor: 'var(--mantine-primary-color-filled)',
backgroundColor: "var(--mantine-primary-color-filled)",
}),
controls: (base) => ({
...base,
justifyContent: 'center',
justifyContent: "center",
}),
}}
highlightedMaskClassName="tour-highlight-glow"
@@ -127,7 +119,7 @@ export default function OnboardingTour({
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
variant="subtle"
size="lg"
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
aria-label={isLast ? t("onboarding.finish", "Finish") : t("onboarding.next", "Next")}
>
{isLast ? <CheckIcon /> : <ArrowIcon />}
</ActionIcon>
@@ -135,10 +127,10 @@ export default function OnboardingTour({
}}
components={{
Close: ({ onClick }) => (
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
<CloseButton onClick={onClick} size="md" style={{ position: "absolute", top: "8px", right: "8px" }} />
),
Content: ({ content }: { content: string }) => (
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
<div style={{ paddingRight: "16px" }} dangerouslySetInnerHTML={{ __html: content }} />
),
}}
>
@@ -148,4 +140,3 @@ export default function OnboardingTour({
}
export type { AdvanceArgs, CloseArgs };
@@ -1,6 +1,6 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
import { addGlowToElements, removeAllGlows } from "@app/components/onboarding/tourGlow";
export enum AdminTourStep {
WELCOME,
@@ -14,7 +14,7 @@ export enum AdminTourStep {
WRAP_UP,
}
interface AdminStepActions {
interface AdminStepActions {
saveAdminState: () => void;
openConfigModal: () => void;
navigateToSection: (section: string) => void;
@@ -32,8 +32,11 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
return {
[AdminTourStep.WELCOME]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
position: 'right',
content: t(
"adminOnboarding.welcome",
"Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators.",
),
position: "right",
padding: 10,
action: () => {
saveAdminState();
@@ -41,17 +44,23 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
},
[AdminTourStep.CONFIG_BUTTON]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
position: 'right',
content: t(
"adminOnboarding.configButton",
"Click the <strong>Config</strong> button to access all system settings and administrative controls.",
),
position: "right",
padding: 10,
actionAfter: () => {
openConfigModal();
},
},
[AdminTourStep.SETTINGS_OVERVIEW]: {
selector: '.modal-nav',
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
position: 'right',
selector: ".modal-nav",
content: t(
"adminOnboarding.settingsOverview",
"This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation.",
),
position: "right",
padding: 0,
action: () => {
removeAllGlows();
@@ -59,41 +68,68 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
},
[AdminTourStep.TEAMS_AND_USERS]: {
selector: '[data-tour="admin-people-nav"]',
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
position: 'right',
highlightedSelectors: [
'[data-tour="admin-people-nav"]',
'[data-tour="admin-teams-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.teamsAndUsers",
"Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('people');
navigateToSection("people");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
addGlowToElements([
'[data-tour="admin-people-nav"]',
'[data-tour="admin-teams-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
selector: '[data-tour="admin-adminGeneral-nav"]',
highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
position: 'right',
highlightedSelectors: [
'[data-tour="admin-adminGeneral-nav"]',
'[data-tour="admin-adminFeatures-nav"]',
'[data-tour="admin-adminEndpoints-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.systemCustomization",
"We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminGeneral');
navigateToSection("adminGeneral");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
addGlowToElements([
'[data-tour="admin-adminGeneral-nav"]',
'[data-tour="admin-adminFeatures-nav"]',
'[data-tour="admin-adminEndpoints-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.DATABASE_SECTION]: {
selector: '[data-tour="admin-adminDatabase-nav"]',
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
position: 'right',
content: t(
"adminOnboarding.databaseSection",
"For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminDatabase');
navigateToSection("adminDatabase");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
@@ -102,38 +138,55 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
[AdminTourStep.CONNECTIONS_SECTION]: {
selector: '[data-tour="admin-adminConnections-nav"]',
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
position: 'right',
content: t(
"adminOnboarding.connectionsSection",
"The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminConnections');
navigateToSection("adminConnections");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
actionAfter: async () => {
await scrollNavToSection('adminAudit');
await scrollNavToSection("adminAudit");
},
},
[AdminTourStep.ADMIN_TOOLS]: {
selector: '[data-tour="admin-adminAudit-nav"]',
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
position: 'right',
highlightedSelectors: [
'[data-tour="admin-adminAudit-nav"]',
'[data-tour="admin-adminUsage-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.adminTools",
"Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminAudit');
navigateToSection("adminAudit");
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
addGlowToElements([
'[data-tour="admin-adminAudit-nav"]',
'[data-tour="admin-adminUsage-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
position: 'right',
content: t(
"adminOnboarding.wrapUp",
"That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
@@ -141,4 +194,3 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
},
};
}
@@ -1,45 +1,45 @@
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
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 MFASetupSlide from '@app/components/onboarding/slides/MFASetupSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types';
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide";
import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide";
import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide";
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 MFASetupSlide from "@app/components/onboarding/slides/MFASetupSlide";
import { SlideConfig, LicenseNotice } from "@app/types/types";
export type SlideId =
| 'first-login'
| 'welcome'
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'server-license'
| 'tour-overview'
| 'analytics-choice'
| 'mfa-setup';
| "first-login"
| "welcome"
| "desktop-install"
| "security-check"
| "admin-overview"
| "server-license"
| "tour-overview"
| "analytics-choice"
| "mfa-setup";
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics';
export type HeroType = "rocket" | "dual-icon" | "shield" | "diamond" | "logo" | "lock" | "analytics";
export type ButtonAction =
| 'next'
| 'prev'
| 'close'
| 'complete-close'
| 'download-selected'
| 'security-next'
| 'launch-admin'
| 'launch-tools'
| 'launch-auto'
| 'see-plans'
| 'skip-to-license'
| 'skip-tour'
| 'enable-analytics'
| 'disable-analytics';
| "next"
| "prev"
| "close"
| "complete-close"
| "download-selected"
| "security-next"
| "launch-admin"
| "launch-tools"
| "launch-auto"
| "see-plans"
| "skip-to-license"
| "skip-tour"
| "enable-analytics"
| "disable-analytics";
export interface FlowState {
selectedRole: 'admin' | 'user' | null;
selectedRole: "admin" | "user" | null;
}
export interface OSOption {
@@ -53,8 +53,8 @@ export interface SlideFactoryParams {
osUrl: string;
osOptions?: OSOption[];
onDownloadUrlChange?: (url: string) => void;
selectedRole: 'admin' | 'user' | null;
onRoleSelect: (role: 'admin' | 'user' | null) => void;
selectedRole: "admin" | "user" | null;
onRoleSelect: (role: "admin" | "user" | null) => void;
licenseNotice?: LicenseNotice;
loginEnabled?: boolean;
// First login params
@@ -72,11 +72,11 @@ export interface HeroDefinition {
export interface ButtonDefinition {
key: string;
type: 'button' | 'icon';
type: "button" | "icon";
label?: string;
icon?: 'chevron-left';
variant?: 'primary' | 'secondary' | 'default';
group: 'left' | 'right';
icon?: "chevron-left";
variant?: "primary" | "secondary" | "default";
group: "left" | "right";
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
}
@@ -89,206 +89,204 @@ export interface SlideDefinition {
}
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'first-login': {
id: 'first-login',
"first-login": {
id: "first-login",
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
FirstLoginSlide({
username: firstLoginUsername || '',
username: firstLoginUsername || "",
onPasswordChanged: onPasswordChanged || (() => {}),
usingDefaultCredentials: usingDefaultCredentials || false,
}),
hero: { type: 'lock' },
hero: { type: "lock" },
buttons: [], // Form has its own submit button
},
'welcome': {
id: 'welcome',
welcome: {
id: "welcome",
createSlide: () => WelcomeSlide(),
hero: { type: 'rocket' },
hero: { type: "rocket" },
buttons: [
{
key: 'welcome-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'next',
key: "welcome-next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "next",
},
],
},
'desktop-install': {
id: 'desktop-install',
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: 'dual-icon' },
"desktop-install": {
id: "desktop-install",
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: "dual-icon" },
buttons: [
{
key: 'desktop-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
key: "desktop-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: 'desktop-skip',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'next',
key: "desktop-skip",
type: "button",
label: "onboarding.buttons.skipForNow",
variant: "secondary",
group: "left",
action: "next",
},
{
key: 'desktop-download',
type: 'button',
label: 'onboarding.buttons.download',
variant: 'primary',
group: 'right',
action: 'download-selected',
key: "desktop-download",
type: "button",
label: "onboarding.buttons.download",
variant: "primary",
group: "right",
action: "download-selected",
},
],
},
'security-check': {
id: 'security-check',
createSlide: ({ selectedRole, onRoleSelect }) =>
SecurityCheckSlide({ selectedRole, onRoleSelect }),
hero: { type: 'shield' },
"security-check": {
id: "security-check",
createSlide: ({ selectedRole, onRoleSelect }) => SecurityCheckSlide({ selectedRole, onRoleSelect }),
hero: { type: "shield" },
buttons: [
{
key: 'security-back',
type: 'button',
label: 'onboarding.buttons.back',
variant: 'secondary',
group: 'left',
action: 'prev',
key: "security-back",
type: "button",
label: "onboarding.buttons.back",
variant: "secondary",
group: "left",
action: "prev",
},
{
key: 'security-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'security-next',
key: "security-next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "security-next",
disabledWhen: (state) => !state.selectedRole,
},
],
},
'admin-overview': {
id: 'admin-overview',
createSlide: ({ licenseNotice, loginEnabled }) =>
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
hero: { type: 'diamond' },
"admin-overview": {
id: "admin-overview",
createSlide: ({ licenseNotice, loginEnabled }) => PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
hero: { type: "diamond" },
buttons: [
{
key: 'admin-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
key: "admin-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: 'admin-show',
type: 'button',
label: 'onboarding.buttons.showMeAround',
variant: 'primary',
group: 'right',
action: 'launch-admin',
key: "admin-show",
type: "button",
label: "onboarding.buttons.showMeAround",
variant: "primary",
group: "right",
action: "launch-admin",
},
{
key: 'admin-skip',
type: 'button',
label: 'onboarding.buttons.skipTheTour',
variant: 'secondary',
group: 'left',
action: 'skip-to-license',
key: "admin-skip",
type: "button",
label: "onboarding.buttons.skipTheTour",
variant: "secondary",
group: "left",
action: "skip-to-license",
},
],
},
'server-license': {
id: 'server-license',
"server-license": {
id: "server-license",
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
hero: { type: 'dual-icon' },
hero: { type: "dual-icon" },
buttons: [
{
key: 'license-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
key: "license-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: 'license-close',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'close',
key: "license-close",
type: "button",
label: "onboarding.buttons.skipForNow",
variant: "secondary",
group: "left",
action: "close",
},
{
key: 'license-see-plans',
type: 'button',
label: 'onboarding.serverLicense.seePlans',
variant: 'primary',
group: 'right',
action: 'see-plans',
key: "license-see-plans",
type: "button",
label: "onboarding.serverLicense.seePlans",
variant: "primary",
group: "right",
action: "see-plans",
},
],
},
'tour-overview': {
id: 'tour-overview',
"tour-overview": {
id: "tour-overview",
createSlide: () => TourOverviewSlide(),
hero: { type: 'rocket' },
hero: { type: "rocket" },
buttons: [
{
key: 'tour-overview-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
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-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',
key: "tour-overview-show",
type: "button",
label: "onboarding.buttons.showMeAround",
variant: "primary",
group: "right",
action: "launch-tools",
},
],
},
'analytics-choice': {
id: 'analytics-choice',
"analytics-choice": {
id: "analytics-choice",
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
hero: { type: 'analytics' },
hero: { type: "analytics" },
buttons: [
{
key: 'analytics-disable',
type: 'button',
label: 'no',
variant: 'secondary',
group: 'left',
action: 'disable-analytics',
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',
key: "analytics-enable",
type: "button",
label: "yes",
variant: "primary",
group: "right",
action: "enable-analytics",
},
],
},
'mfa-setup': {
id: 'mfa-setup',
"mfa-setup": {
id: "mfa-setup",
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }),
hero: { type: 'lock' },
hero: { type: "lock" },
buttons: [], // Form has its own submit button
},
};
@@ -1,23 +1,21 @@
export type OnboardingStepId =
| 'first-login'
| 'welcome'
| 'desktop-install'
| 'security-check'
| 'admin-overview'
| 'tool-layout'
| 'tour-overview'
| 'server-license'
| 'analytics-choice'
| 'mfa-setup';
| "first-login"
| "welcome"
| "desktop-install"
| "security-check"
| "admin-overview"
| "tool-layout"
| "tour-overview"
| "server-license"
| "analytics-choice"
| "mfa-setup";
export type OnboardingStepType =
| 'modal-slide'
| 'tool-prompt';
export type OnboardingStepType = "modal-slide" | "tool-prompt";
export interface OnboardingRuntimeState {
selectedRole: 'admin' | 'user' | null;
selectedRole: "admin" | "user" | null;
tourRequested: boolean;
tourType: 'admin' | 'tools' | 'whatsnew';
tourType: "admin" | "tools" | "whatsnew";
isDesktopApp: boolean;
desktopSlideEnabled: boolean;
analyticsNotConfigured: boolean;
@@ -43,14 +41,23 @@ export interface OnboardingStep {
id: OnboardingStepId;
type: OnboardingStepType;
condition: (ctx: OnboardingConditionContext) => boolean;
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice' | 'mfa-setup';
slideId?:
| "first-login"
| "welcome"
| "desktop-install"
| "security-check"
| "admin-overview"
| "server-license"
| "tour-overview"
| "analytics-choice"
| "mfa-setup";
allowDismiss?: boolean;
}
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
selectedRole: null,
tourRequested: false,
tourType: 'whatsnew',
tourType: "whatsnew",
isDesktopApp: false,
analyticsNotConfigured: false,
analyticsEnabled: false,
@@ -61,7 +68,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
requiresLicense: false,
},
requiresPasswordChange: false,
firstLoginUsername: '',
firstLoginUsername: "",
usingDefaultCredentials: false,
desktopSlideEnabled: true,
requiresMfaSetup: false,
@@ -69,59 +76,59 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
export const ONBOARDING_STEPS: OnboardingStep[] = [
{
id: 'first-login',
type: 'modal-slide',
slideId: 'first-login',
id: "first-login",
type: "modal-slide",
slideId: "first-login",
condition: (ctx) => ctx.requiresPasswordChange,
},
{
id: 'welcome',
type: 'modal-slide',
slideId: 'welcome',
id: "welcome",
type: "modal-slide",
slideId: "welcome",
// Desktop has its own onboarding modal (DesktopOnboardingModal)
condition: (ctx) => !ctx.isDesktopApp,
},
{
id: 'admin-overview',
type: 'modal-slide',
slideId: 'admin-overview',
id: "admin-overview",
type: "modal-slide",
slideId: "admin-overview",
condition: (ctx) => ctx.effectiveIsAdmin,
},
{
id: 'desktop-install',
type: 'modal-slide',
slideId: 'desktop-install',
id: "desktop-install",
type: "modal-slide",
slideId: "desktop-install",
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
},
{
id: 'security-check',
type: 'modal-slide',
slideId: 'security-check',
id: "security-check",
type: "modal-slide",
slideId: "security-check",
condition: () => false,
},
{
id: 'tool-layout',
type: 'tool-prompt',
id: "tool-layout",
type: "tool-prompt",
condition: () => false,
},
{
id: 'tour-overview',
type: 'modal-slide',
slideId: 'tour-overview',
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin' && !ctx.isDesktopApp,
id: "tour-overview",
type: "modal-slide",
slideId: "tour-overview",
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
},
{
id: 'server-license',
type: 'modal-slide',
slideId: 'server-license',
id: "server-license",
type: "modal-slide",
slideId: "server-license",
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
},
{
id: 'mfa-setup',
type: 'modal-slide',
slideId: 'mfa-setup',
id: "mfa-setup",
type: "modal-slide",
slideId: "mfa-setup",
condition: (ctx) => ctx.requiresMfaSetup,
}
},
];
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
@@ -131,4 +138,3 @@ export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
export function getStepIndex(id: OnboardingStepId): number {
return ONBOARDING_STEPS.findIndex((step) => step.id === id);
}
@@ -1,62 +1,62 @@
const STORAGE_PREFIX = 'onboarding';
const STORAGE_PREFIX = "onboarding";
const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`;
const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`;
export function isOnboardingCompleted(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true';
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === "true";
} catch {
return false;
}
}
export function markOnboardingCompleted(): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true');
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
} catch (error) {
console.error('[onboardingStorage] Error marking onboarding as completed:', error);
console.error("[onboardingStorage] Error marking onboarding as completed:", error);
}
}
export function resetOnboardingProgress(): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
} catch (error) {
console.error('[onboardingStorage] Error resetting onboarding progress:', error);
console.error("[onboardingStorage] Error resetting onboarding progress:", error);
}
}
export function hasShownToursTooltip(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true';
return localStorage.getItem(TOURS_TOOLTIP_KEY) === "true";
} catch {
return false;
}
}
export function markToursTooltipShown(): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
localStorage.setItem(TOURS_TOOLTIP_KEY, 'true');
localStorage.setItem(TOURS_TOOLTIP_KEY, "true");
} catch (error) {
console.error('[onboardingStorage] Error marking tours tooltip as shown:', error);
console.error("[onboardingStorage] Error marking tours tooltip as shown:", error);
}
}
export function migrateFromLegacyPreferences(): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
const migrationKey = `${STORAGE_PREFIX}::migrated`;
try {
// Skip if already migrated
if (localStorage.getItem(migrationKey) === 'true') return;
if (localStorage.getItem(migrationKey) === "true") return;
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
const prefsRaw = localStorage.getItem("stirlingpdf_preferences");
if (prefsRaw) {
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
@@ -67,7 +67,7 @@ export function migrateFromLegacyPreferences(): void {
}
// Mark migration complete
localStorage.setItem(migrationKey, 'true');
localStorage.setItem(migrationKey, "true");
} catch {
// If migration fails, onboarding will show again - safer than hiding it
}
@@ -1,7 +1,7 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useServerExperience } from '@app/hooks/useServerExperience';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { useServerExperience } from "@app/hooks/useServerExperience";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import {
ONBOARDING_STEPS,
@@ -10,39 +10,40 @@ import {
type OnboardingRuntimeState,
type OnboardingConditionContext,
DEFAULT_RUNTIME_STATE,
} from '@app/components/onboarding/orchestrator/onboardingConfig';
} from "@app/components/onboarding/orchestrator/onboardingConfig";
import {
isOnboardingCompleted,
markOnboardingCompleted,
migrateFromLegacyPreferences,
} from '@app/components/onboarding/orchestrator/onboardingStorage';
import { accountService } from '@app/services/accountService';
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
} from "@app/components/onboarding/orchestrator/onboardingStorage";
import { accountService } from "@app/services/accountService";
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
const SESSION_TOUR_REQUESTED = 'onboarding::session::tour-requested';
const SESSION_TOUR_TYPE = 'onboarding::session::tour-type';
const SESSION_SELECTED_ROLE = 'onboarding::session::selected-role';
const AUTH_ROUTES = ["/login", "/signup", "/auth", "/invite"];
const SESSION_TOUR_REQUESTED = "onboarding::session::tour-requested";
const SESSION_TOUR_TYPE = "onboarding::session::tour-type";
const SESSION_SELECTED_ROLE = "onboarding::session::selected-role";
// Check if user has an auth token (to avoid flash before redirect)
function hasAuthToken(): boolean {
if (typeof window === 'undefined') return false;
return !!localStorage.getItem('stirling_jwt');
if (typeof window === "undefined") return false;
return !!localStorage.getItem("stirling_jwt");
}
// Get initial runtime state from session storage (survives remounts)
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
if (typeof window === 'undefined') {
if (typeof window === "undefined") {
return baseState;
}
try {
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true";
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew')
? sessionTourType
: 'whatsnew';
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
const tourType =
sessionTourType === "admin" || sessionTourType === "tools" || sessionTourType === "whatsnew"
? sessionTourType
: "whatsnew";
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as "admin" | "user" | null;
return {
...baseState,
@@ -56,11 +57,11 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
}
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
if (state.tourRequested !== undefined) {
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? "true" : "false");
}
if (state.tourType !== undefined) {
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
@@ -73,12 +74,12 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
}
}
} catch (error) {
console.error('[useOnboardingOrchestrator] Error persisting runtime state:', error);
console.error("[useOnboardingOrchestrator] Error persisting runtime state:", error);
}
}
function clearRuntimeStateSession(): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
@@ -94,9 +95,9 @@ function parseMfaRequired(settings: string | null | undefined): boolean {
try {
const parsed = JSON.parse(settings) as { mfaRequired?: string };
return parsed.mfaRequired?.toLowerCase() === 'true';
return parsed.mfaRequired?.toLowerCase() === "true";
} catch (error) {
console.warn('[useOnboardingOrchestrator] Failed to parse account settings JSON:', error);
console.warn("[useOnboardingOrchestrator] Failed to parse account settings JSON:", error);
return false;
}
}
@@ -151,18 +152,14 @@ export interface UseOnboardingOrchestratorOptions {
defaultRuntimeState?: OnboardingRuntimeState;
}
export function useOnboardingOrchestrator(
options?: UseOnboardingOrchestratorOptions
): UseOnboardingOrchestratorResult {
export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOptions): UseOnboardingOrchestratorResult {
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
const serverExperience = useServerExperience();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const bypassOnboarding = useBypassOnboarding();
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
getInitialRuntimeState(defaultState)
);
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() => getInitialRuntimeState(defaultState));
const [isPaused, setIsPaused] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
@@ -186,10 +183,10 @@ export function useOnboardingOrchestrator(
totalUsers: serverExperience.totalUsers,
freeTierLimit: serverExperience.freeTierLimit,
isOverLimit: serverExperience.overFreeTierLimit ?? false,
requiresLicense: !serverExperience.hasPaidLicense && (
serverExperience.overFreeTierLimit === true ||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)
),
requiresLicense:
!serverExperience.hasPaidLicense &&
(serverExperience.overFreeTierLimit === true ||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)),
},
}));
}, [
@@ -220,7 +217,7 @@ export function useOnboardingOrchestrator(
requiresMfaSetup: parseMfaRequired(accountData.settings),
}));
} catch (error) {
console.log('[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:', error);
console.log("[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:", error);
// Account endpoint failed - user not logged in or security disabled
}
};
@@ -233,26 +230,25 @@ export function useOnboardingOrchestrator(
const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
const loginEnabled = config?.enableLogin === true;
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
const shouldBlockOnboarding =
bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
const shouldBlockOnboarding = bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
...serverExperience,
...runtimeState,
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
}), [serverExperience, runtimeState]);
const conditionContext = useMemo<OnboardingConditionContext>(
() => ({
...serverExperience,
...runtimeState,
effectiveIsAdmin:
serverExperience.effectiveIsAdmin || (!serverExperience.loginEnabled && runtimeState.selectedRole === "admin"),
}),
[serverExperience, runtimeState],
);
const activeFlow = useMemo(() => {
return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
}, [conditionContext]);
// Wait for config AND admin status before calculating initial step
const adminStatusResolved = !configLoading && (
config?.enableLogin === false ||
config?.enableLogin === undefined ||
config?.isAdmin !== undefined
);
const adminStatusResolved =
!configLoading && (config?.enableLogin === false || config?.enableLogin === undefined || config?.isAdmin !== undefined);
useEffect(() => {
if (configLoading || !adminStatusResolved) return;
@@ -280,14 +276,15 @@ export function useOnboardingOrchestrator(
const totalSteps = activeFlow.length;
const isComplete = isInitialized &&
(totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps)
? activeFlow[currentStepIndex]
: null;
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 ||
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
const isLoading =
configLoading ||
!adminStatusResolved ||
!isInitialized ||
!initialIndexSet.current ||
(currentStepIndex === -1 && activeFlow.length > 0);
useEffect(() => {
if (!configLoading && !isInitialized) setIsInitialized(true);
@@ -325,7 +322,6 @@ export function useOnboardingOrchestrator(
setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]);
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);
setRuntimeState((prev) => ({ ...prev, ...updates }));
@@ -336,13 +332,16 @@ export function useOnboardingOrchestrator(
setCurrentStepIndex(-1);
}, []);
const startStep = useCallback((stepId: OnboardingStepId) => {
const index = activeFlow.findIndex((step) => step.id === stepId);
if (index !== -1) {
setCurrentStepIndex(index);
setIsPaused(false);
}
}, [activeFlow]);
const startStep = useCallback(
(stepId: OnboardingStepId) => {
const index = activeFlow.findIndex((step) => step.id === stepId);
if (index !== -1) {
setCurrentStepIndex(index);
setIsPaused(false);
}
},
[activeFlow],
);
const pause = useCallback(() => setIsPaused(true), []);
const resume = useCallback(() => setIsPaused(false), []);
@@ -1,11 +1,11 @@
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';
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;
@@ -13,8 +13,8 @@ interface AnalyticsChoiceSlideProps {
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?'),
key: "analytics-choice",
title: i18n.t("analytics.title", "Do you want to help make Stirling PDF better?"),
body: (
<div className={styles.bodyCopyInner}>
<Trans
@@ -29,27 +29,22 @@ export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoice
components={{ strong: <strong /> }}
/>
<br />
<div style={{ textAlign: 'right', marginTop: 0 }}>
<div style={{ textAlign: "right", marginTop: 0 }}>
<Button
variant="default"
size="sm"
onClick={() => window.open('https://docs.stirlingpdf.com/analytics-telemetry/', '_blank')}
onClick={() => window.open("https://docs.stirlingpdf.com/analytics-telemetry/", "_blank")}
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
>
{i18n.t('analytics.learnMore', 'Learn more about our analytics')}
{i18n.t("analytics.learnMore", "Learn more about our analytics")}
</Button>
</div>
{analyticsError && (
<div style={{ color: 'var(--mantine-color-red-6)', marginTop: 12 }}>
{analyticsError}
</div>
)}
{analyticsError && <div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>{analyticsError}</div>}
</div>
),
background: {
gradientStops: ['#0EA5E9', '#6366F1'],
gradientStops: ["#0EA5E9", "#6366F1"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,12 +1,12 @@
import React from 'react';
import styles from '@app/components/onboarding/slides/AnimatedSlideBackground.module.css';
import { AnimatedSlideBackgroundProps } from '@app/types/types';
import React from "react";
import styles from "@app/components/onboarding/slides/AnimatedSlideBackground.module.css";
import { AnimatedSlideBackgroundProps } from "@app/types/types";
type CircleStyles = React.CSSProperties & {
'--circle-move-x'?: string;
'--circle-move-y'?: string;
'--circle-duration'?: string;
'--circle-delay'?: string;
"--circle-move-x"?: string;
"--circle-move-y"?: string;
"--circle-duration"?: string;
"--circle-delay"?: string;
};
interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps {
@@ -14,11 +14,7 @@ interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundP
slideKey: string;
}
export default function AnimatedSlideBackground({
gradientStops,
circles,
isActive,
}: AnimatedSlideBackgroundComponentProps) {
export default function AnimatedSlideBackground({ gradientStops, circles, isActive }: AnimatedSlideBackgroundComponentProps) {
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
const [isTransitioning, setIsTransitioning] = React.useState(false);
@@ -31,13 +27,13 @@ export default function AnimatedSlideBackground({
setCurrentGradient(gradientStops);
return;
}
// Only transition if gradient actually changed
if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) {
// Store previous gradient and start transition
setPrevGradient(currentGradient);
setIsTransitioning(true);
// Update to new gradient (will fade in)
setCurrentGradient(gradientStops);
}
@@ -59,8 +55,8 @@ export default function AnimatedSlideBackground({
return (
<div className={styles.hero} key="animated-background">
{prevGradientStyle && isTransitioning && (
<div
className={`${styles.gradientLayer} ${styles.gradientLayerPrevFadeOut}`}
<div
className={`${styles.gradientLayer} ${styles.gradientLayerPrevFadeOut}`}
style={prevGradientStyle}
onTransitionEnd={() => {
setPrevGradient(null);
@@ -69,14 +65,14 @@ export default function AnimatedSlideBackground({
/>
)}
<div
className={`${styles.gradientLayer} ${isActive ? styles.gradientLayerActive : ''}`.trim()}
className={`${styles.gradientLayer} ${isActive ? styles.gradientLayerActive : ""}`.trim()}
style={currentGradientStyle}
/>
{circles.map((circle, index) => {
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
const moveX = position === 'bottom-left' ? amplitude : -amplitude;
const moveY = position === 'bottom-left' ? -amplitude * 0.6 : amplitude * 0.6;
const moveX = position === "bottom-left" ? amplitude : -amplitude;
const moveY = position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
const circleStyle: CircleStyles = {
width: size,
@@ -84,17 +80,17 @@ export default function AnimatedSlideBackground({
background: color,
opacity: opacity ?? 0.9,
filter: blur ? `blur(${blur}px)` : undefined,
'--circle-move-x': `${moveX}px`,
'--circle-move-y': `${moveY}px`,
'--circle-duration': `${duration}s`,
'--circle-delay': `${delay}s`,
"--circle-move-x": `${moveX}px`,
"--circle-move-y": `${moveY}px`,
"--circle-duration": `${duration}s`,
"--circle-delay": `${delay}s`,
};
const defaultOffset = -size / 2;
const offsetX = circle.offsetX ?? 0;
const offsetY = circle.offsetY ?? 0;
if (position === 'bottom-left') {
if (position === "bottom-left") {
circleStyle.left = `${defaultOffset + offsetX}px`;
circleStyle.bottom = `${defaultOffset + offsetY}px`;
} else {
@@ -102,13 +98,7 @@ export default function AnimatedSlideBackground({
circleStyle.top = `${defaultOffset + offsetY}px`;
}
return (
<div
key={`circle-${index}-${position}`}
className={styles.circle}
style={circleStyle}
/>
);
return <div key={`circle-${index}-${position}`} className={styles.circle} style={circleStyle} />;
})}
</div>
);
@@ -1,8 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import { DesktopInstallTitle, type OSOption } from '@app/components/onboarding/slides/DesktopInstallTitle';
import React from "react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { DesktopInstallTitle, type OSOption } from "@app/components/onboarding/slides/DesktopInstallTitle";
export type { OSOption };
@@ -19,8 +19,8 @@ const DesktopInstallBody = () => {
return (
<span>
{t(
'onboarding.desktopInstall.body',
'Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.',
"onboarding.desktopInstall.body",
"Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.",
)}
</span>
);
@@ -32,11 +32,10 @@ export default function DesktopInstallSlide({
osOptions = [],
onDownloadUrlChange,
}: DesktopInstallSlideProps): SlideConfig {
return {
key: 'desktop-install',
key: "desktop-install",
title: (
<DesktopInstallTitle
<DesktopInstallTitle
osLabel={osLabel}
osUrl={osUrl}
osOptions={osOptions || []}
@@ -46,9 +45,8 @@ export default function DesktopInstallSlide({
body: <DesktopInstallBody />,
downloadUrl: osUrl,
background: {
gradientStops: ['#2563EB', '#0EA5E9'],
gradientStops: ["#2563EB", "#0EA5E9"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Menu, ActionIcon } from '@mantine/core';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import React from "react";
import { useTranslation } from "react-i18next";
import { Menu, ActionIcon } from "@mantine/core";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
export interface OSOption {
label: string;
@@ -16,11 +16,11 @@ interface DesktopInstallTitleProps {
onDownloadUrlChange?: (url: string) => void;
}
export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
osLabel,
osUrl,
osOptions,
onDownloadUrlChange
export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
osLabel,
osUrl,
osOptions,
onDownloadUrlChange,
}) => {
const { t } = useTranslation();
const [selectedOsUrl, setSelectedOsUrl] = React.useState<string>(osUrl);
@@ -29,37 +29,41 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
setSelectedOsUrl(osUrl);
}, [osUrl]);
const handleOsSelect = React.useCallback((option: OSOption) => {
setSelectedOsUrl(option.url);
onDownloadUrlChange?.(option.url);
}, [onDownloadUrlChange]);
const handleOsSelect = React.useCallback(
(option: OSOption) => {
setSelectedOsUrl(option.url);
onDownloadUrlChange?.(option.url);
},
[onDownloadUrlChange],
);
const currentOsOption = osOptions.find(opt => opt.url === selectedOsUrl) ||
const currentOsOption =
osOptions.find((opt) => opt.url === selectedOsUrl) ||
(osOptions.length > 0 ? osOptions[0] : { label: osLabel, url: osUrl });
const displayLabel = currentOsOption.label || osLabel;
const title = displayLabel
? t('onboarding.desktopInstall.titleWithOs', 'Download for {{osLabel}}', { osLabel: displayLabel })
: t('onboarding.desktopInstall.title', 'Download');
const title = displayLabel
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", { osLabel: displayLabel })
: t("onboarding.desktopInstall.title", "Download");
// If only one option or no options, don't show dropdown
if (osOptions.length <= 1) {
return <div style={{ textAlign: 'center', width: '100%' }}>{title}</div>;
return <div style={{ textAlign: "center", width: "100%" }}>{title}</div>;
}
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', width: '100%' }}>
<span style={{ whiteSpace: 'nowrap' }}>{title}</span>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", width: "100%" }}>
<span style={{ whiteSpace: "nowrap" }}>{title}</span>
<Menu position="bottom" offset={5} zIndex={10000}>
<Menu.Target>
<ActionIcon
variant="transparent"
size="sm"
style={{
background: 'transparent',
border: 'none',
color: 'inherit',
padding: 0
style={{
background: "transparent",
border: "none",
color: "inherit",
padding: 0,
}}
>
<ExpandMoreIcon fontSize="small" />
@@ -74,11 +78,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
onClick={() => handleOsSelect(option)}
style={{
backgroundColor: isSelected
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
: 'transparent',
color: isSelected
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
: 'inherit',
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
: "transparent",
color: isSelected ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" : "inherit",
}}
>
{option.label}
@@ -90,4 +92,3 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
</div>
);
};
@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import { Stack, PasswordInput, Button, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import LocalIcon from '@app/components/shared/LocalIcon';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import { accountService } from '@app/services/accountService';
import { alert as showToast } from '@app/components/toast';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import React, { useState } from "react";
import { Stack, PasswordInput, Button, Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import LocalIcon from "@app/components/shared/LocalIcon";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { accountService } from "@app/services/accountService";
import { alert as showToast } from "@app/components/toast";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
interface FirstLoginSlideProps {
username: string;
@@ -14,66 +14,66 @@ interface FirstLoginSlideProps {
usingDefaultCredentials?: boolean;
}
const DEFAULT_PASSWORD = 'stirling';
const DEFAULT_PASSWORD = "stirling";
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) {
const { t } = useTranslation();
// If using default credentials, pre-fill with "stirling" - user won't see this field
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : '');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : "");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState("");
const handleSubmit = async () => {
// Validation
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
setError(t('firstLogin.allFieldsRequired', 'All fields are required'));
setError(t("firstLogin.allFieldsRequired", "All fields are required"));
return;
}
if (newPassword !== confirmPassword) {
setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match'));
setError(t("firstLogin.passwordsDoNotMatch", "New passwords do not match"));
return;
}
if (newPassword.length < 8) {
setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters'));
setError(t("firstLogin.passwordTooShort", "Password must be at least 8 characters"));
return;
}
if (newPassword === currentPassword) {
setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password'));
setError(t("firstLogin.passwordMustBeDifferent", "New password must be different from current password"));
return;
}
try {
setLoading(true);
setError('');
setError("");
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
showToast({
alertType: 'success',
title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.')
alertType: "success",
title: t("firstLogin.passwordChangedSuccess", "Password changed successfully! Please log in again."),
});
// Clear form
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
// Wait a moment for the user to see the success message
setTimeout(() => {
onPasswordChanged();
}, 1500);
} catch (err) {
console.error('Failed to change password:', err);
console.error("Failed to change password:", err);
// Extract error message from axios response if available
const axiosError = err as { response?: { data?: { message?: string } } };
setError(
axiosError.response?.data?.message ||
t('firstLogin.passwordChangeFailed', 'Failed to change password. Please check your current password.')
t("firstLogin.passwordChangeFailed", "Failed to change password. Please check your current password."),
);
} finally {
setLoading(false);
@@ -85,25 +85,18 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
<div className={styles.securityCard}>
<Stack gap="md">
<div className={styles.securityAlertRow}>
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: '#3B82F6', flexShrink: 0 }} />
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: "#3B82F6", flexShrink: 0 }} />
<span>
{t(
'firstLogin.welcomeMessage',
'For security reasons, you must change your password on your first login.'
)}
{t("firstLogin.welcomeMessage", "For security reasons, you must change your password on your first login.")}
</span>
</div>
<Text size="sm" fw={500}>
{t('firstLogin.loggedInAs', 'Logged in as')}: <strong>{username}</strong>
{t("firstLogin.loggedInAs", "Logged in as")}: <strong>{username}</strong>
</Text>
{error && (
<Alert
icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />}
color="red"
variant="light"
>
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
{error}
</Alert>
)}
@@ -111,8 +104,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
{/* Only show current password field if not using default credentials */}
{!usingDefaultCredentials && (
<PasswordInput
label={t('firstLogin.currentPassword', 'Current Password')}
placeholder={t('firstLogin.enterCurrentPassword', 'Enter your current password')}
label={t("firstLogin.currentPassword", "Current Password")}
placeholder={t("firstLogin.enterCurrentPassword", "Enter your current password")}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
@@ -123,8 +116,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
)}
<PasswordInput
label={t('firstLogin.newPassword', 'New Password')}
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
label={t("firstLogin.newPassword", "New Password")}
placeholder={t("firstLogin.enterNewPassword", "Enter new password (min 8 characters)")}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
@@ -135,8 +128,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
/>
<PasswordInput
label={t('firstLogin.confirmPassword', 'Confirm New Password')}
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
label={t("firstLogin.confirmPassword", "Confirm New Password")}
placeholder={t("firstLogin.reEnterNewPassword", "Re-enter new password")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
@@ -154,7 +147,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
size="md"
mt="xs"
>
{t('firstLogin.changePassword', 'Change Password')}
{t("firstLogin.changePassword", "Change Password")}
</Button>
</Stack>
</div>
@@ -168,8 +161,8 @@ export default function FirstLoginSlide({
usingDefaultCredentials = false,
}: FirstLoginSlideProps): SlideConfig {
return {
key: 'first-login',
title: 'Set Your Password',
key: "first-login",
title: "Set Your Password",
body: (
<FirstLoginForm
username={username}
@@ -178,9 +171,8 @@ export default function FirstLoginSlide({
/>
),
background: {
gradientStops: ['#059669', '#0891B2'], // Green to teal - security/trust colors
gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -4,7 +4,7 @@ import { QRCodeSVG } from "qrcode.react";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { accountService } from "@app/services/accountService";
import { useAccountLogout } from '@app/extensions/accountLogout';
import { useAccountLogout } from "@app/extensions/accountLogout";
import { useAuth } from "@app/auth/UseSession";
import LocalIcon from "@app/components/shared/LocalIcon";
import { BASE_PATH } from "@app/constants/app";
@@ -59,10 +59,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
}, [fetchMfaSetup]);
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
window.location.assign("/login");
}, []);
const onLogout = useCallback(async() => {
const onLogout = useCallback(async () => {
await accountLogout({ signOut, redirectToLogin });
}, [accountLogout, redirectToLogin, signOut]);
@@ -84,13 +84,13 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again."
axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again.",
);
} finally {
setSubmitting(false);
}
},
[mfaSetupCode, onMfaSetupComplete]
[mfaSetupCode, onMfaSetupComplete],
);
const isReady = Boolean(mfaSetupData);
@@ -179,18 +179,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
>
Regenerate QR code
</Button>
<Button
type="submit"
loading={submitting}
disabled={!isReady || setupComplete || mfaSetupCode.length < 6}
>
<Button type="submit" loading={submitting} disabled={!isReady || setupComplete || mfaSetupCode.length < 6}>
Enable MFA
</Button>
<Button
type="button"
variant="light"
onClick={onLogout}
>
<Button type="button" variant="light" onClick={onLogout}>
Logout
</Button>
</Group>
@@ -1,7 +1,7 @@
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { SlideConfig, LicenseNotice } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import { SlideConfig, LicenseNotice } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
interface PlanOverviewSlideProps {
isAdmin: boolean;
@@ -16,31 +16,23 @@ const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
return (
<>
{isAdmin
? t('onboarding.planOverview.adminTitle', 'Admin Overview')
: t('onboarding.planOverview.userTitle', 'Plan Overview')}
? t("onboarding.planOverview.adminTitle", "Admin Overview")
: t("onboarding.planOverview.userTitle", "Plan Overview")}
</>
);
};
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({
freeTierLimit,
loginEnabled,
}) => {
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({ freeTierLimit, loginEnabled }) => {
const adminBodyKey = loginEnabled
? 'onboarding.planOverview.adminBodyLoginEnabled'
: 'onboarding.planOverview.adminBodyLoginDisabled';
? "onboarding.planOverview.adminBodyLoginEnabled"
: "onboarding.planOverview.adminBodyLoginDisabled";
const defaultValue = loginEnabled
? 'As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.'
: 'Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.';
? "As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
: "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.";
return (
<Trans
i18nKey={adminBodyKey}
values={{ freeTierLimit }}
components={{ strong: <strong /> }}
defaults={defaultValue}
/>
<Trans i18nKey={adminBodyKey} values={{ freeTierLimit }} components={{ strong: <strong /> }} defaults={defaultValue} />
);
};
@@ -49,7 +41,7 @@ const UserOverviewBody: React.FC = () => {
return (
<span>
{t(
'onboarding.planOverview.userBody',
"onboarding.planOverview.userBody",
"Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use.",
)}
</span>
@@ -60,8 +52,7 @@ const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; logi
isAdmin,
freeTierLimit,
loginEnabled,
}) =>
isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />;
}) => (isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />);
export default function PlanOverviewSlide({
isAdmin,
@@ -71,13 +62,12 @@ export default function PlanOverviewSlide({
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
return {
key: isAdmin ? 'admin-overview' : 'plan-overview',
key: isAdmin ? "admin-overview" : "plan-overview",
title: <PlanOverviewTitle isAdmin={isAdmin} />,
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
background: {
gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'],
gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,39 +1,41 @@
import React from 'react';
import { Select } from '@mantine/core';
import { SlideConfig } from '@app/types/types';
import LocalIcon from '@app/components/shared/LocalIcon';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import i18n from '@app/i18n';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import React from "react";
import { Select } from "@mantine/core";
import { SlideConfig } from "@app/types/types";
import LocalIcon from "@app/components/shared/LocalIcon";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import i18n from "@app/i18n";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
interface SecurityCheckSlideProps {
selectedRole: 'admin' | 'user' | null;
onRoleSelect: (role: 'admin' | 'user' | null) => void;
selectedRole: "admin" | "user" | null;
onRoleSelect: (role: "admin" | "user" | null) => void;
}
export default function SecurityCheckSlide({
selectedRole,
onRoleSelect,
}: SecurityCheckSlideProps): SlideConfig {
export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: SecurityCheckSlideProps): SlideConfig {
return {
key: 'security-check',
title: 'Security Check',
key: "security-check",
title: "Security Check",
body: (
<div className={styles.securitySlideContent}>
<div className={styles.securityCard}>
<div className={styles.securityAlertRow}>
<LocalIcon icon="error" width={20} height={20} style={{ color: '#F04438', flexShrink: 0 }} />
<span>{i18n.t('onboarding.securityCheck.message', 'The application has undergone significant changes recently. Your server admin\'s attention may be required. Please confirm your role to continue.')}</span>
<LocalIcon icon="error" width={20} height={20} style={{ color: "#F04438", flexShrink: 0 }} />
<span>
{i18n.t(
"onboarding.securityCheck.message",
"The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue.",
)}
</span>
</div>
<Select
placeholder="Confirm your role"
value={selectedRole}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
{ value: "admin", label: "Admin" },
{ value: "user", label: "User" },
]}
onChange={(value) => onRoleSelect((value as 'admin' | 'user') ?? null)}
onChange={(value) => onRoleSelect((value as "admin" | "user") ?? null)}
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
styles={{
input: {
@@ -46,10 +48,8 @@ export default function SecurityCheckSlide({
</div>
),
background: {
gradientStops: ['#5B21B6', '#2563EB'],
gradientStops: ["#5B21B6", "#2563EB"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,8 +1,8 @@
import React from 'react';
import { Trans } from 'react-i18next';
import { SlideConfig, LicenseNotice } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import i18n from '@app/i18n';
import React from "react";
import { Trans } from "react-i18next";
import { SlideConfig, LicenseNotice } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import i18n from "@app/i18n";
interface ServerLicenseSlideProps {
licenseNotice?: LicenseNotice;
@@ -17,9 +17,9 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
const title = isOverLimit
? i18n.t('onboarding.serverLicense.overLimitTitle', 'Server License Needed')
: i18n.t('onboarding.serverLicense.freeTitle', 'Server License');
const key = isOverLimit ? 'server-license-over-limit' : 'server-license';
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
: i18n.t("onboarding.serverLicense.freeTitle", "Server License");
const key = isOverLimit ? "server-license-over-limit" : "server-license";
const overLimitBody = (
<Trans
@@ -50,10 +50,8 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
title,
body,
background: {
gradientStops: isOverLimit ? ['#F472B6', '#8B5CF6'] : ['#F97316', '#F59E0B'],
gradientStops: isOverLimit ? ["#F472B6", "#8B5CF6"] : ["#F97316", "#F59E0B"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,14 +1,14 @@
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';
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'),
key: "tour-overview",
title: i18n.t("onboarding.tourOverview.title", "Tour Overview"),
body: (
<span className={styles.bodyCopyInner}>
<Trans
@@ -19,9 +19,8 @@ export default function TourOverviewSlide(): SlideConfig {
</span>
),
background: {
gradientStops: ['#2563EB', '#7C3AED'],
gradientStops: ["#2563EB", "#7C3AED"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,15 +1,15 @@
import React from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import React from "react";
import { useTranslation, Trans } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
function WelcomeSlideTitle() {
const { t } = useTranslation();
return (
<span className={styles.welcomeTitleContainer}>
{t('onboarding.welcomeSlide.title', 'Welcome to Stirling')}
{t("onboarding.welcomeSlide.title", "Welcome to Stirling")}
<span className={styles.v2Badge}>V2</span>
</span>
);
@@ -27,13 +27,12 @@ const WelcomeSlideBody = () => (
export default function WelcomeSlide(): SlideConfig {
return {
key: 'welcome',
key: "welcome",
title: <WelcomeSlideTitle />,
body: <WelcomeSlideBody />,
background: {
gradientStops: ['#7C3AED', '#EC4899'],
gradientStops: ["#7C3AED", "#EC4899"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -1,4 +1,4 @@
import { AnimatedCircleConfig } from '@app/types/types';
import { AnimatedCircleConfig } from "@app/types/types";
/**
* Unified circle background configuration used across all onboarding slides.
@@ -6,9 +6,9 @@ import { AnimatedCircleConfig } from '@app/types/types';
*/
export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
{
position: 'bottom-left',
position: "bottom-left",
size: 270,
color: 'rgba(255, 255, 255, 0.25)',
color: "rgba(255, 255, 255, 0.25)",
opacity: 0.9,
amplitude: 24,
duration: 4.5,
@@ -16,9 +16,9 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
offsetY: 14,
},
{
position: 'top-right',
position: "top-right",
size: 300,
color: 'rgba(255, 255, 255, 0.2)',
color: "rgba(255, 255, 255, 0.2)",
opacity: 0.9,
amplitude: 28,
duration: 4.5,
@@ -27,4 +27,3 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
offsetY: 18,
},
];
@@ -3,16 +3,15 @@ export const addGlowToElements = (selectors: string[]) => {
const element = document.querySelector(selector);
if (element) {
if (selector === '[data-tour="settings-content-area"]') {
element.classList.add('tour-content-glow');
element.classList.add("tour-content-glow");
} else {
element.classList.add('tour-nav-glow');
element.classList.add("tour-nav-glow");
}
}
});
};
export const removeAllGlows = () => {
document.querySelectorAll('.tour-content-glow').forEach((el) => el.classList.remove('tour-content-glow'));
document.querySelectorAll('.tour-nav-glow').forEach((el) => el.classList.remove('tour-nav-glow'));
document.querySelectorAll(".tour-content-glow").forEach((el) => el.classList.remove("tour-content-glow"));
document.querySelectorAll(".tour-nav-glow").forEach((el) => el.classList.remove("tour-nav-glow"));
};
@@ -1,28 +1,28 @@
import { useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage';
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { markOnboardingCompleted } from "@app/components/onboarding/orchestrator/onboardingStorage";
const SESSION_KEY = 'onboarding::bypass-all';
const PARAM_KEY = 'bypassOnboarding';
const SESSION_KEY = "onboarding::bypass-all";
const PARAM_KEY = "bypassOnboarding";
function isTruthy(value: string | null): boolean {
return value?.toLowerCase() === 'true';
return value?.toLowerCase() === "true";
}
function readStoredBypass(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window === "undefined") return false;
try {
return sessionStorage.getItem(SESSION_KEY) === 'true';
return sessionStorage.getItem(SESSION_KEY) === "true";
} catch {
return false;
}
}
function setStoredBypass(enabled: boolean): void {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
if (enabled) {
sessionStorage.setItem(SESSION_KEY, 'true');
sessionStorage.setItem(SESSION_KEY, "true");
} else {
sessionStorage.removeItem(SESSION_KEY);
}
@@ -1,12 +1,12 @@
/**
* useOnboardingDownload Hook
*
*
* Encapsulates OS detection and download URL logic for the desktop install slide.
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useOs } from '@app/hooks/useOs';
import { DOWNLOAD_URLS } from '@app/constants/downloads';
import { useState, useEffect, useMemo, useCallback } from "react";
import { useOs } from "@app/hooks/useOs";
import { DOWNLOAD_URLS } from "@app/constants/downloads";
interface OsInfo {
label: string;
@@ -29,30 +29,34 @@ interface UseOnboardingDownloadResult {
export function useOnboardingDownload(): UseOnboardingDownloadResult {
const osType = useOs();
const [selectedDownloadUrl, setSelectedDownloadUrl] = useState<string>('');
const [selectedDownloadUrl, setSelectedDownloadUrl] = useState<string>("");
const osInfo = useMemo<OsInfo>(() => {
switch (osType) {
case 'windows':
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
case 'mac-apple':
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
case 'mac-intel':
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
case 'linux-x64':
case 'linux-arm64':
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
case "windows":
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
case "mac-apple":
return { label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
case "mac-intel":
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
case "linux-x64":
case "linux-arm64":
return { label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS };
default:
return { label: '', url: '' };
return { label: "", url: "" };
}
}, [osType]);
const osOptions = useMemo<OsOption[]>(() => [
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
].filter((opt) => opt.url), []);
const osOptions = useMemo<OsOption[]>(
() =>
[
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
{ label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: "mac-apple" },
{ label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL, value: "mac-intel" },
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
].filter((opt) => opt.url),
[],
);
// Initialize selected URL from detected OS
useEffect(() => {
@@ -64,7 +68,7 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
const handleDownloadSelected = useCallback(() => {
const downloadUrl = selectedDownloadUrl || osInfo.url;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener');
window.open(downloadUrl, "_blank", "noopener");
}
}, [selectedDownloadUrl, osInfo.url]);
@@ -76,4 +80,3 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
handleDownloadSelected,
};
}
@@ -1,27 +1,27 @@
import { useEffect, useCallback, useState } from 'react';
import { useEffect, useCallback, useState } from "react";
import {
SERVER_LICENSE_REQUEST_EVENT,
START_TOUR_EVENT,
type ServerLicenseRequestPayload,
type TourType,
type StartTourPayload,
} from '@app/constants/events';
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
} from "@app/constants/events";
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
export function useServerLicenseRequest(): {
showLicenseSlide: boolean;
licenseNotice: OnboardingRuntimeState['licenseNotice'] | null;
licenseNotice: OnboardingRuntimeState["licenseNotice"] | null;
closeLicenseSlide: () => void;
} {
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState['licenseNotice'] | null>(null);
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState["licenseNotice"] | null>(null);
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
const handleLicenseRequest = (event: Event) => {
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
if (detail?.licenseNotice) {
setLicenseNotice({
totalUsers: detail.licenseNotice.totalUsers ?? null,
@@ -30,7 +30,7 @@ export function useServerLicenseRequest(): {
requiresLicense: true,
});
}
setShowLicenseSlide(true);
};
@@ -51,14 +51,14 @@ export function useTourRequest(): {
clearTourRequest: () => void;
} {
const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] = useState<TourType>('whatsnew');
const [requestedTourType, setRequestedTourType] = useState<TourType>("whatsnew");
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
const handleTourRequest = (event: Event) => {
const { detail } = event as CustomEvent<StartTourPayload>;
setRequestedTourType(detail?.tourType ?? 'whatsnew');
setRequestedTourType(detail?.tourType ?? "whatsnew");
setTourRequested(true);
};
@@ -1,5 +1,5 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
export enum TourStep {
ALL_TOOLS,
@@ -53,8 +53,11 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
return {
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
position: 'center',
content: t(
"onboarding.allTools",
"This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
),
position: "center",
padding: 0,
action: () => {
saveWorkbenchState();
@@ -64,28 +67,40 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
},
[TourStep.SELECT_CROP_TOOL]: {
selector: '[data-tour="tool-button-crop"]',
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
position: 'right',
content: t(
"onboarding.selectCropTool",
"Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
),
position: "right",
padding: 0,
actionAfter: () => selectCropTool(),
},
[TourStep.TOOL_INTERFACE]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
position: 'center',
content: t(
"onboarding.toolInterface",
"This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
),
position: "center",
padding: 0,
},
[TourStep.FILES_BUTTON]: {
selector: '[data-tour="files-button"]',
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
position: 'right',
content: t(
"onboarding.filesButton",
"The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
),
position: "right",
padding: 10,
action: () => openFilesModal(),
},
[TourStep.FILE_SOURCES]: {
selector: '[data-tour="file-sources"]',
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
position: 'right',
content: t(
"onboarding.fileSources",
"You can upload new files or access recent files from here. For the tour, we'll just use a sample file.",
),
position: "right",
padding: 0,
actionAfter: () => {
loadSampleFile();
@@ -94,62 +109,88 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
},
[TourStep.WORKBENCH]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
position: 'center',
content: t(
"onboarding.workbench",
"This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.",
),
position: "center",
padding: 0,
},
[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."),
position: 'center',
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.",
),
position: "center",
padding: 0,
action: () => switchToActiveFiles(),
},
[TourStep.FILE_CHECKBOX]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
position: 'top',
content: t(
"onboarding.fileCheckbox",
"Clicking one of the files selects it for processing. You can select multiple files for batch operations.",
),
position: "top",
padding: 10,
},
[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."),
position: 'left',
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.",
),
position: "left",
padding: 10,
action: () => modifyCropSettings(),
},
[TourStep.RUN_BUTTON]: {
selector: '[data-tour="run-button"]',
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
position: 'top',
content: t(
"onboarding.runButton",
"Once the tool has been configured, this button allows you to run the tool on all the selected PDFs.",
),
position: "top",
padding: 10,
actionAfter: () => executeTool(),
},
[TourStep.RESULTS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
position: 'center',
content: t(
"onboarding.results",
"After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. ",
),
position: "center",
padding: 0,
},
[TourStep.FILE_REPLACEMENT]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
position: 'left',
content: t(
"onboarding.fileReplacement",
"The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools.",
),
position: "left",
padding: 10,
},
[TourStep.PIN_BUTTON]: {
selector: '[data-tour="file-card-pin"]',
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
position: 'left',
content: t(
"onboarding.pinButton",
"You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them.",
),
position: "left",
padding: 10,
action: () => pinFile(),
},
[TourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
position: 'right',
content: t(
"onboarding.wrapUp",
"You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again.",
),
position: "right",
padding: 10,
},
};
}
@@ -1,8 +1,8 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
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;
if (typeof document === "undefined") return;
const start = Date.now();
// Immediate hit
if (document.querySelector(selector)) return;
@@ -20,7 +20,7 @@ async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 1
}
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
if (typeof document === 'undefined') return;
if (typeof document === "undefined") return;
const start = Date.now();
return new Promise((resolve) => {
@@ -29,8 +29,8 @@ async function waitForHighlightable(selector: string, timeoutMs = 7000, interval
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')));
window.dispatchEvent(new Event("resize"));
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
resolve();
return;
}
@@ -85,10 +85,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
[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.'
"onboarding.whatsNew.quickAccess",
"Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours.",
),
position: 'right',
position: "right",
padding: 10,
action: () => {
saveWorkbenchState();
@@ -99,19 +99,19 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
[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.'
"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',
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.'
"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',
position: "right",
padding: 10,
action: async () => {
openFilesModal();
@@ -130,10 +130,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
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.'
"onboarding.whatsNew.rightRail",
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.",
),
position: 'left',
position: "left",
padding: 10,
action: async () => {
await waitForElement('[data-tour="right-rail-controls"]', 7000, 100);
@@ -143,10 +143,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
[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>.'
"onboarding.whatsNew.topBar",
"The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>.",
),
position: 'bottom',
position: "bottom",
padding: 8,
// Ensure the switcher has mounted before this step renders
action: async () => {
@@ -157,11 +157,8 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
},
[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',
content: t("onboarding.whatsNew.pageEditorView", "Switch to the Page Editor to reorder, rotate, or delete pages."),
position: "bottom",
padding: 8,
action: async () => {
switchToPageEditor();
@@ -172,10 +169,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
[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.'
"onboarding.whatsNew.activeFilesView",
"Use Active Files to see everything you have open and pick what to work on.",
),
position: 'bottom',
position: "bottom",
padding: 8,
action: async () => {
switchToActiveFiles();
@@ -186,12 +183,11 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
[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.'
"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',
position: "right",
padding: 10,
},
};
}