mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Shorten onbaording (#5198)
Also added `enableDesktopInstallSlide` flag in `settings.yml` to hide the download for desktop page in the onboarding. --------- Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
371d816ce7
commit
0064c1866e
@@ -47,11 +47,11 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
||||
) {
|
||||
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
|
||||
}
|
||||
|
||||
|
||||
// Translate the label (it's a translation key)
|
||||
const label = button.label ?? '';
|
||||
if (!label) return '';
|
||||
|
||||
|
||||
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
|
||||
const fallback = label.split('.').pop() || label;
|
||||
return t(label, fallback);
|
||||
@@ -105,4 +105,4 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { isAuthRoute } from '@app/constants/routes';
|
||||
import { dispatchTourState } from '@app/constants/events';
|
||||
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
|
||||
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
|
||||
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
|
||||
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
|
||||
@@ -20,10 +19,12 @@ import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
||||
import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig';
|
||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
|
||||
export default function Onboarding() {
|
||||
@@ -39,6 +40,11 @@ export default function Onboarding() {
|
||||
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
|
||||
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
|
||||
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
|
||||
const { config, refetch: refetchConfig } = useAppConfig();
|
||||
const [analyticsError, setAnalyticsError] = useState<string | null>(null);
|
||||
const [analyticsLoading, setAnalyticsLoading] = useState(false);
|
||||
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
|
||||
const [analyticsModalDismissed, setAnalyticsModalDismissed] = useState(false);
|
||||
|
||||
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
|
||||
actions.updateRuntimeState({ selectedRole: role });
|
||||
@@ -50,7 +56,34 @@ export default function Onboarding() {
|
||||
window.location.href = '/login';
|
||||
}, [actions]);
|
||||
|
||||
const handleButtonAction = useCallback((action: ButtonAction) => {
|
||||
// Check if we should show analytics modal before onboarding
|
||||
useEffect(() => {
|
||||
if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) {
|
||||
setShowAnalyticsModal(true);
|
||||
}
|
||||
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
|
||||
|
||||
const handleAnalyticsChoice = useCallback(async (enableAnalytics: boolean) => {
|
||||
if (analyticsLoading) return;
|
||||
setAnalyticsLoading(true);
|
||||
setAnalyticsError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enabled', enableAnalytics.toString());
|
||||
|
||||
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
|
||||
await refetchConfig();
|
||||
setShowAnalyticsModal(false);
|
||||
setAnalyticsModalDismissed(true);
|
||||
setAnalyticsLoading(false);
|
||||
} catch (error) {
|
||||
setAnalyticsError(error instanceof Error ? error.message : 'Unknown error');
|
||||
setAnalyticsLoading(false);
|
||||
}
|
||||
}, [analyticsLoading, refetchConfig]);
|
||||
|
||||
const handleButtonAction = useCallback(async (action: ButtonAction) => {
|
||||
switch (action) {
|
||||
case 'next':
|
||||
case 'complete-close':
|
||||
@@ -69,35 +102,43 @@ export default function Onboarding() {
|
||||
case 'security-next':
|
||||
if (!runtimeState.selectedRole) return;
|
||||
if (runtimeState.selectedRole !== 'admin') {
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
||||
actions.updateRuntimeState({ tourType: 'whatsnew' });
|
||||
setIsTourOpen(true);
|
||||
}
|
||||
actions.complete();
|
||||
break;
|
||||
case 'launch-admin':
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
|
||||
actions.complete();
|
||||
actions.updateRuntimeState({ tourType: 'admin' });
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
case 'launch-tools':
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
||||
actions.complete();
|
||||
actions.updateRuntimeState({ tourType: 'whatsnew' });
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
case 'launch-auto': {
|
||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType });
|
||||
actions.complete();
|
||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew';
|
||||
actions.updateRuntimeState({ tourType });
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
}
|
||||
case 'skip-to-license':
|
||||
markStepSeen('tour');
|
||||
actions.updateRuntimeState({ tourRequested: false });
|
||||
actions.complete();
|
||||
break;
|
||||
case 'skip-tour':
|
||||
actions.complete();
|
||||
break;
|
||||
case 'see-plans':
|
||||
actions.complete();
|
||||
navigate('/settings/adminPlan');
|
||||
break;
|
||||
case 'enable-analytics':
|
||||
await handleAnalyticsChoice(true);
|
||||
break;
|
||||
case 'disable-analytics':
|
||||
await handleAnalyticsChoice(false);
|
||||
break;
|
||||
}
|
||||
}, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
|
||||
}, [actions, handleAnalyticsChoice, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
|
||||
|
||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||
const [isTourOpen, setIsTourOpen] = useState(false);
|
||||
@@ -117,10 +158,7 @@ export default function Onboarding() {
|
||||
backToAllTools: tourOrch.backToAllTools,
|
||||
selectCropTool: tourOrch.selectCropTool,
|
||||
loadSampleFile: tourOrch.loadSampleFile,
|
||||
switchToViewer: tourOrch.switchToViewer,
|
||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
selectFirstFile: tourOrch.selectFirstFile,
|
||||
pinFile: tourOrch.pinFile,
|
||||
modifyCropSettings: tourOrch.modifyCropSettings,
|
||||
executeTool: tourOrch.executeTool,
|
||||
@@ -130,6 +168,24 @@ export default function Onboarding() {
|
||||
[t, tourOrch, closeFilesModal, openFilesModal]
|
||||
);
|
||||
|
||||
const whatsNewStepsConfig = useMemo(
|
||||
() => createWhatsNewStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveWorkbenchState: tourOrch.saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools: tourOrch.backToAllTools,
|
||||
openFilesModal,
|
||||
loadSampleFile: tourOrch.loadSampleFile,
|
||||
switchToViewer: tourOrch.switchToViewer,
|
||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
selectFirstFile: tourOrch.selectFirstFile,
|
||||
},
|
||||
}),
|
||||
[t, tourOrch, closeFilesModal, openFilesModal]
|
||||
);
|
||||
|
||||
const adminStepsConfig = useMemo(
|
||||
() => createAdminStepsConfig({
|
||||
t,
|
||||
@@ -144,21 +200,19 @@ export default function Onboarding() {
|
||||
);
|
||||
|
||||
const tourSteps = useMemo<StepType[]>(() => {
|
||||
const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
|
||||
return Object.values(config);
|
||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep?.id === 'tour' && !isTourOpen) {
|
||||
markStepSeen('tour');
|
||||
setIsTourOpen(true);
|
||||
switch (runtimeState.tourType) {
|
||||
case 'admin':
|
||||
return Object.values(adminStepsConfig);
|
||||
case 'whatsnew':
|
||||
return Object.values(whatsNewStepsConfig);
|
||||
default:
|
||||
return Object.values(userStepsConfig);
|
||||
}
|
||||
}, [currentStep, isTourOpen, activeFlow]);
|
||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalTourRequested) {
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
|
||||
markStepSeen('tour');
|
||||
actions.updateRuntimeState({ tourType: requestedTourType });
|
||||
setIsTourOpen(true);
|
||||
clearTourRequest();
|
||||
}
|
||||
@@ -176,9 +230,9 @@ export default function Onboarding() {
|
||||
} else {
|
||||
tourOrch.restoreWorkbenchState();
|
||||
}
|
||||
markStepSeen('tour');
|
||||
if (currentStep?.id === 'tour') actions.complete();
|
||||
}, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]);
|
||||
// Advance to next onboarding step after tour completes
|
||||
actions.complete();
|
||||
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]);
|
||||
|
||||
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
|
||||
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
||||
@@ -216,8 +270,10 @@ export default function Onboarding() {
|
||||
firstLoginUsername: runtimeState.firstLoginUsername,
|
||||
onPasswordChanged: handlePasswordChanged,
|
||||
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
|
||||
analyticsError,
|
||||
analyticsLoading,
|
||||
});
|
||||
}, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
|
||||
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
|
||||
|
||||
const modalSlideCount = useMemo(() => {
|
||||
return activeFlow.filter((step) => step.type === 'modal-slide').length;
|
||||
@@ -237,8 +293,45 @@ export default function Onboarding() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show analytics modal before onboarding if needed
|
||||
if (showAnalyticsModal) {
|
||||
const slideDefinition = SLIDE_DEFINITIONS['analytics-choice'];
|
||||
const slideContent = slideDefinition.createSlide({
|
||||
osLabel: '',
|
||||
osUrl: '',
|
||||
selectedRole: null,
|
||||
onRoleSelect: () => {},
|
||||
analyticsError,
|
||||
analyticsLoading,
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={slideDefinition}
|
||||
slideContent={slideContent}
|
||||
runtimeState={runtimeState}
|
||||
modalSlideCount={1}
|
||||
currentModalSlideIndex={0}
|
||||
onSkip={() => {}} // No skip allowed
|
||||
onAction={async (action) => {
|
||||
if (action === 'enable-analytics') {
|
||||
await handleAnalyticsChoice(true);
|
||||
} else if (action === 'disable-analytics') {
|
||||
await handleAnalyticsChoice(false);
|
||||
}
|
||||
}}
|
||||
allowDismiss={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showLicenseSlide) {
|
||||
const slideDefinition = SLIDE_DEFINITIONS['server-license'];
|
||||
const baseSlideDefinition = SLIDE_DEFINITIONS['server-license'];
|
||||
// Remove back button for external license notice
|
||||
const slideDefinition = {
|
||||
...baseSlideDefinition,
|
||||
buttons: baseSlideDefinition.buttons.filter(btn => btn.key !== 'license-back')
|
||||
};
|
||||
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
|
||||
const slideContent = slideDefinition.createSlide({
|
||||
osLabel: '',
|
||||
@@ -250,7 +343,7 @@ export default function Onboarding() {
|
||||
licenseNotice: effectiveLicenseNotice,
|
||||
loginEnabled: serverExperience.loginEnabled,
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={slideDefinition}
|
||||
@@ -271,40 +364,34 @@ export default function Onboarding() {
|
||||
);
|
||||
}
|
||||
|
||||
// Always render the tour component (it controls its own visibility with isOpen)
|
||||
const tourComponent = (
|
||||
<OnboardingTour
|
||||
isOpen={isTourOpen}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
|
||||
// If no active onboarding, just show the tour (which may or may not be open)
|
||||
if (isLoading || !isActive || !currentStep) {
|
||||
return (
|
||||
<OnboardingTour
|
||||
isOpen={isTourOpen}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
return tourComponent;
|
||||
}
|
||||
|
||||
// If tour is open, hide the onboarding modal and just show the tour
|
||||
if (isTourOpen) {
|
||||
return tourComponent;
|
||||
}
|
||||
|
||||
// Render the current onboarding step
|
||||
switch (currentStep.type) {
|
||||
case 'tool-prompt':
|
||||
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
|
||||
|
||||
case 'tour':
|
||||
return (
|
||||
<OnboardingTour
|
||||
isOpen={true}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'analytics-modal':
|
||||
return <AdminAnalyticsChoiceModal opened={true} onClose={actions.complete} />;
|
||||
|
||||
case 'modal-slide':
|
||||
if (!currentSlideDefinition || !currentSlideContent) return null;
|
||||
return (
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Stack } from '@mantine/core';
|
||||
import { Modal, Stack, ActionIcon } from '@mantine/core';
|
||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
@@ -28,6 +29,7 @@ interface OnboardingModalSlideProps {
|
||||
currentModalSlideIndex: number;
|
||||
onSkip: () => void;
|
||||
onAction: (action: ButtonAction) => void;
|
||||
allowDismiss?: boolean;
|
||||
}
|
||||
|
||||
export default function OnboardingModalSlide({
|
||||
@@ -38,6 +40,7 @@ export default function OnboardingModalSlide({
|
||||
currentModalSlideIndex,
|
||||
onSkip,
|
||||
onAction,
|
||||
allowDismiss = true,
|
||||
}: OnboardingModalSlideProps) {
|
||||
|
||||
const renderHero = () => {
|
||||
@@ -62,6 +65,9 @@ export default function OnboardingModalSlide({
|
||||
{slideDefinition.hero.type === 'lock' && (
|
||||
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'analytics' && (
|
||||
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
||||
{slideDefinition.hero.type === 'logo' && (
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||
@@ -75,6 +81,7 @@ export default function OnboardingModalSlide({
|
||||
opened={true}
|
||||
onClose={onSkip}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={allowDismiss}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
@@ -93,6 +100,31 @@ export default function OnboardingModalSlide({
|
||||
isActive
|
||||
slideKey={slideContent.key}
|
||||
/>
|
||||
{allowDismiss && (
|
||||
<ActionIcon
|
||||
onClick={onSkip}
|
||||
radius="md"
|
||||
size={36}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
|
||||
{renderHero()}
|
||||
</div>
|
||||
@@ -114,7 +146,9 @@ export default function OnboardingModalSlide({
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
||||
{modalSlideCount > 1 && (
|
||||
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
||||
)}
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<SlideButtons
|
||||
|
||||
@@ -49,7 +49,7 @@ interface CloseArgs {
|
||||
|
||||
interface OnboardingTourProps {
|
||||
tourSteps: StepType[];
|
||||
tourType: 'admin' | 'tools';
|
||||
tourType: 'admin' | 'tools' | 'whatsnew';
|
||||
isRTL: boolean;
|
||||
t: TFunction;
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -4,6 +4,8 @@ import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckS
|
||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
|
||||
import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide';
|
||||
import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide';
|
||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||
|
||||
export type SlideId =
|
||||
@@ -12,9 +14,11 @@ export type SlideId =
|
||||
| 'desktop-install'
|
||||
| 'security-check'
|
||||
| 'admin-overview'
|
||||
| 'server-license';
|
||||
| 'server-license'
|
||||
| 'tour-overview'
|
||||
| 'analytics-choice';
|
||||
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics';
|
||||
|
||||
export type ButtonAction =
|
||||
| 'next'
|
||||
@@ -27,7 +31,10 @@ export type ButtonAction =
|
||||
| 'launch-tools'
|
||||
| 'launch-auto'
|
||||
| 'see-plans'
|
||||
| 'skip-to-license';
|
||||
| 'skip-to-license'
|
||||
| 'skip-tour'
|
||||
| 'enable-analytics'
|
||||
| 'disable-analytics';
|
||||
|
||||
export interface FlowState {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
@@ -52,6 +59,8 @@ export interface SlideFactoryParams {
|
||||
firstLoginUsername?: string;
|
||||
onPasswordChanged?: () => void;
|
||||
usingDefaultCredentials?: boolean;
|
||||
analyticsError?: string | null;
|
||||
analyticsLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface HeroDefinition {
|
||||
@@ -79,9 +88,9 @@ export interface SlideDefinition {
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
'first-login': {
|
||||
id: 'first-login',
|
||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||
FirstLoginSlide({
|
||||
username: firstLoginUsername || '',
|
||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||
FirstLoginSlide({
|
||||
username: firstLoginUsername || '',
|
||||
onPasswordChanged: onPasswordChanged || (() => {}),
|
||||
usingDefaultCredentials: usingDefaultCredentials || false,
|
||||
}),
|
||||
@@ -194,6 +203,13 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
|
||||
hero: { type: 'dual-icon' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'license-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'license-close',
|
||||
type: 'button',
|
||||
@@ -212,5 +228,58 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
'tour-overview': {
|
||||
id: 'tour-overview',
|
||||
createSlide: () => TourOverviewSlide(),
|
||||
hero: { type: 'rocket' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'tour-overview-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'tour-overview-skip',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.skipForNow',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'skip-tour',
|
||||
},
|
||||
{
|
||||
key: 'tour-overview-show',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.showMeAround',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'launch-tools',
|
||||
},
|
||||
],
|
||||
},
|
||||
'analytics-choice': {
|
||||
id: 'analytics-choice',
|
||||
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
|
||||
hero: { type: 'analytics' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'analytics-disable',
|
||||
type: 'button',
|
||||
label: 'no',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'disable-analytics',
|
||||
},
|
||||
{
|
||||
key: 'analytics-enable',
|
||||
type: 'button',
|
||||
label: 'yes',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'enable-analytics',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,21 +5,20 @@ export type OnboardingStepId =
|
||||
| 'security-check'
|
||||
| 'admin-overview'
|
||||
| 'tool-layout'
|
||||
| 'tour'
|
||||
| 'tour-overview'
|
||||
| 'server-license'
|
||||
| 'analytics-choice';
|
||||
|
||||
export type OnboardingStepType =
|
||||
| 'modal-slide'
|
||||
| 'tool-prompt'
|
||||
| 'tour'
|
||||
| 'analytics-modal';
|
||||
| 'tool-prompt';
|
||||
|
||||
export interface OnboardingRuntimeState {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
tourRequested: boolean;
|
||||
tourType: 'admin' | 'tools';
|
||||
tourType: 'admin' | 'tools' | 'whatsnew';
|
||||
isDesktopApp: boolean;
|
||||
desktopSlideEnabled: boolean;
|
||||
analyticsNotConfigured: boolean;
|
||||
analyticsEnabled: boolean;
|
||||
licenseNotice: {
|
||||
@@ -42,13 +41,13 @@ export interface OnboardingStep {
|
||||
id: OnboardingStepId;
|
||||
type: OnboardingStepType;
|
||||
condition: (ctx: OnboardingConditionContext) => boolean;
|
||||
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license';
|
||||
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice';
|
||||
}
|
||||
|
||||
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||
selectedRole: null,
|
||||
tourRequested: false,
|
||||
tourType: 'tools',
|
||||
tourType: 'whatsnew',
|
||||
isDesktopApp: false,
|
||||
analyticsNotConfigured: false,
|
||||
analyticsEnabled: false,
|
||||
@@ -61,6 +60,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||
requiresPasswordChange: false,
|
||||
firstLoginUsername: '',
|
||||
usingDefaultCredentials: false,
|
||||
desktopSlideEnabled: true,
|
||||
};
|
||||
|
||||
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
@@ -76,18 +76,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
slideId: 'welcome',
|
||||
condition: () => true,
|
||||
},
|
||||
{
|
||||
id: 'desktop-install',
|
||||
type: 'modal-slide',
|
||||
slideId: 'desktop-install',
|
||||
condition: (ctx) => !ctx.isDesktopApp,
|
||||
},
|
||||
{
|
||||
id: 'security-check',
|
||||
type: 'modal-slide',
|
||||
slideId: 'security-check',
|
||||
condition: (ctx) => !ctx.loginEnabled && !ctx.isDesktopApp,
|
||||
},
|
||||
{
|
||||
id: 'admin-overview',
|
||||
type: 'modal-slide',
|
||||
@@ -95,14 +83,27 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
condition: (ctx) => ctx.effectiveIsAdmin,
|
||||
},
|
||||
{
|
||||
id: 'tool-layout',
|
||||
type: 'tool-prompt',
|
||||
condition: () => true,
|
||||
id: 'desktop-install',
|
||||
type: 'modal-slide',
|
||||
slideId: 'desktop-install',
|
||||
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
|
||||
},
|
||||
{
|
||||
id: 'tour',
|
||||
type: 'tour',
|
||||
condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin,
|
||||
id: 'security-check',
|
||||
type: 'modal-slide',
|
||||
slideId: 'security-check',
|
||||
condition: () => false,
|
||||
},
|
||||
{
|
||||
id: 'tool-layout',
|
||||
type: 'tool-prompt',
|
||||
condition: () => false,
|
||||
},
|
||||
{
|
||||
id: 'tour-overview',
|
||||
type: 'modal-slide',
|
||||
slideId: 'tour-overview',
|
||||
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin',
|
||||
},
|
||||
{
|
||||
id: 'server-license',
|
||||
@@ -110,11 +111,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
slideId: 'server-license',
|
||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
||||
},
|
||||
{
|
||||
id: 'analytics-choice',
|
||||
type: 'analytics-modal',
|
||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.analyticsNotConfigured,
|
||||
},
|
||||
];
|
||||
|
||||
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
|
||||
|
||||
@@ -1,94 +1,71 @@
|
||||
import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
|
||||
const STORAGE_PREFIX = 'onboarding';
|
||||
const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`;
|
||||
const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`;
|
||||
|
||||
export function getStorageKey(stepId: OnboardingStepId): string {
|
||||
return `${STORAGE_PREFIX}::${stepId}`;
|
||||
}
|
||||
|
||||
export function hasSeenStep(stepId: OnboardingStepId): boolean {
|
||||
export function isOnboardingCompleted(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return localStorage.getItem(getStorageKey(stepId)) === 'true';
|
||||
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function markStepSeen(stepId: OnboardingStepId): void {
|
||||
export function markOnboardingCompleted(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(stepId), 'true');
|
||||
localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true');
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error marking step as seen:', error);
|
||||
console.error('[onboardingStorage] Error marking onboarding as completed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetStepSeen(stepId: OnboardingStepId): void {
|
||||
export function resetOnboardingProgress(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.removeItem(getStorageKey(stepId));
|
||||
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error resetting step seen:', error);
|
||||
console.error('[onboardingStorage] Error resetting onboarding progress:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAllOnboardingProgress(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
export function hasShownToursTooltip(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const prefix = `${STORAGE_PREFIX}::`;
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(prefix)) keysToRemove.push(key);
|
||||
}
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error resetting all onboarding progress:', error);
|
||||
return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getOnboardingStorageState(): Record<string, boolean> {
|
||||
const state: Record<string, boolean> = {};
|
||||
ONBOARDING_STEPS.forEach((step) => {
|
||||
state[step.id] = hasSeenStep(step.id);
|
||||
});
|
||||
return state;
|
||||
export function markToursTooltipShown(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(TOURS_TOOLTIP_KEY, 'true');
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error marking tours tooltip as shown:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateFromLegacyPreferences(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
const migrationKey = `${STORAGE_PREFIX}::migrated`;
|
||||
|
||||
|
||||
try {
|
||||
// Skip if already migrated
|
||||
if (localStorage.getItem(migrationKey) === 'true') return;
|
||||
|
||||
|
||||
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
|
||||
if (prefsRaw) {
|
||||
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
||||
|
||||
// Migrate based on legacy flags
|
||||
if (prefs.hasSeenIntroOnboarding === true) {
|
||||
markStepSeen('welcome');
|
||||
markStepSeen('desktop-install');
|
||||
markStepSeen('security-check');
|
||||
markStepSeen('admin-overview');
|
||||
|
||||
// If user had completed onboarding in old system, mark new system as complete
|
||||
if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) {
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
|
||||
if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) {
|
||||
markStepSeen('tool-layout');
|
||||
}
|
||||
|
||||
if (prefs.hasCompletedOnboarding === true) {
|
||||
markStepSeen('tour');
|
||||
markStepSeen('analytics-choice');
|
||||
markStepSeen('server-license');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Mark migration complete
|
||||
localStorage.setItem(migrationKey, 'true');
|
||||
} catch {
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
DEFAULT_RUNTIME_STATE,
|
||||
} from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
import {
|
||||
hasSeenStep,
|
||||
markStepSeen,
|
||||
isOnboardingCompleted,
|
||||
markOnboardingCompleted,
|
||||
migrateFromLegacyPreferences,
|
||||
} from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import { accountService } from '@app/services/accountService';
|
||||
@@ -35,12 +35,15 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
|
||||
if (typeof window === 'undefined') {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
|
||||
const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools';
|
||||
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
|
||||
const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew')
|
||||
? sessionTourType
|
||||
: 'whatsnew';
|
||||
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
|
||||
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
tourRequested,
|
||||
@@ -54,7 +57,7 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
|
||||
|
||||
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
try {
|
||||
if (state.tourRequested !== undefined) {
|
||||
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
|
||||
@@ -76,7 +79,7 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||
|
||||
function clearRuntimeStateSession(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
|
||||
sessionStorage.removeItem(SESSION_TOUR_TYPE);
|
||||
@@ -145,7 +148,7 @@ export function useOnboardingOrchestrator(
|
||||
const location = useLocation();
|
||||
const bypassOnboarding = useBypassOnboarding();
|
||||
|
||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||
getInitialRuntimeState(defaultState)
|
||||
);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
@@ -166,6 +169,7 @@ export function useOnboardingOrchestrator(
|
||||
...prev,
|
||||
analyticsEnabled: config?.enableAnalytics === true,
|
||||
analyticsNotConfigured: config?.enableAnalytics == null,
|
||||
desktopSlideEnabled: config?.enableDesktopInstallSlide ?? true,
|
||||
licenseNotice: {
|
||||
totalUsers: serverExperience.totalUsers,
|
||||
freeTierLimit: serverExperience.freeTierLimit,
|
||||
@@ -221,7 +225,7 @@ export function useOnboardingOrchestrator(
|
||||
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
|
||||
...serverExperience,
|
||||
...runtimeState,
|
||||
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
|
||||
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
|
||||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
|
||||
}), [serverExperience, runtimeState]);
|
||||
|
||||
@@ -235,53 +239,44 @@ export function useOnboardingOrchestrator(
|
||||
|
||||
// Wait for config AND admin status before calculating initial step
|
||||
const adminStatusResolved = !configLoading && (
|
||||
config?.enableLogin === false ||
|
||||
config?.enableLogin === undefined ||
|
||||
config?.enableLogin === false ||
|
||||
config?.enableLogin === undefined ||
|
||||
config?.isAdmin !== undefined
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
|
||||
if (configLoading || !adminStatusResolved) return;
|
||||
|
||||
let firstUnseenIndex = -1;
|
||||
for (let i = 0; i < activeFlow.length; i++) {
|
||||
// Special case: first-login step should always be considered "unseen" if requiresPasswordChange is true
|
||||
const isFirstLoginStep = activeFlow[i].id === 'first-login';
|
||||
const shouldTreatAsUnseen = isFirstLoginStep ? runtimeState.requiresPasswordChange : !hasSeenStep(activeFlow[i].id);
|
||||
|
||||
if (shouldTreatAsUnseen) {
|
||||
firstUnseenIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Force reset index when password change is required (overrides initialIndexSet)
|
||||
if (runtimeState.requiresPasswordChange && firstUnseenIndex === 0) {
|
||||
// If there are no steps to show, mark initialized/completed baseline
|
||||
if (activeFlow.length === 0) {
|
||||
setCurrentStepIndex(0);
|
||||
initialIndexSet.current = true;
|
||||
} else if (firstUnseenIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If onboarding has been completed, don't show it
|
||||
if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) {
|
||||
setCurrentStepIndex(activeFlow.length);
|
||||
initialIndexSet.current = true;
|
||||
} else if (!initialIndexSet.current) {
|
||||
setCurrentStepIndex(firstUnseenIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start from the beginning
|
||||
if (!initialIndexSet.current) {
|
||||
setCurrentStepIndex(0);
|
||||
initialIndexSet.current = true;
|
||||
}
|
||||
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
|
||||
|
||||
const totalSteps = activeFlow.length;
|
||||
|
||||
const allStepsAlreadySeen = useMemo(() => {
|
||||
if (activeFlow.length === 0) return false;
|
||||
return activeFlow.every(step => hasSeenStep(step.id));
|
||||
}, [activeFlow]);
|
||||
|
||||
const isComplete = isInitialized && initialIndexSet.current &&
|
||||
(currentStepIndex >= totalSteps || allStepsAlreadySeen);
|
||||
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen)
|
||||
? activeFlow[currentStepIndex]
|
||||
|
||||
const isComplete = isInitialized &&
|
||||
(totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
|
||||
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps)
|
||||
? activeFlow[currentStepIndex]
|
||||
: null;
|
||||
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
|
||||
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
|
||||
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
|
||||
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -293,35 +288,33 @@ export function useOnboardingOrchestrator(
|
||||
}, [isComplete]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex >= totalSteps) {
|
||||
// Reached the end, mark onboarding as completed
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
setCurrentStepIndex(nextIndex);
|
||||
}, [currentStepIndex, totalSteps]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const skip = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
// Skip marks the entire onboarding as completed
|
||||
markOnboardingCompleted();
|
||||
setCurrentStepIndex(totalSteps);
|
||||
}, [totalSteps]);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStep || isLoading) {
|
||||
return;
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex >= totalSteps) {
|
||||
// Reached the end, mark onboarding as completed
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
// Special case: never auto-complete first-login step if requiresPasswordChange is true
|
||||
const isFirstLoginStep = currentStep.id === 'first-login';
|
||||
setCurrentStepIndex(nextIndex);
|
||||
}, [currentStepIndex, totalSteps]);
|
||||
|
||||
if (!isFirstLoginStep && hasSeenStep(currentStep.id)) {
|
||||
complete();
|
||||
}
|
||||
}, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]);
|
||||
|
||||
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
||||
persistRuntimeState(updates);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { Button } from '@mantine/core';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import i18n from '@app/i18n';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
interface AnalyticsChoiceSlideProps {
|
||||
analyticsError?: string | null;
|
||||
}
|
||||
|
||||
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig {
|
||||
return {
|
||||
key: 'analytics-choice',
|
||||
title: i18n.t('analytics.title', 'Do you want to help make Stirling PDF better?'),
|
||||
body: (
|
||||
<div className={styles.bodyCopyInner}>
|
||||
<Trans
|
||||
i18nKey="analytics.paragraph1"
|
||||
defaults="Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents."
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<Trans
|
||||
i18nKey="analytics.paragraph2"
|
||||
defaults="Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better."
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<div style={{ textAlign: 'right', marginTop: 0 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://docs.stirlingpdf.com/analytics-telemetry/', '_blank')}
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{i18n.t('analytics.learnMore', 'Learn more about our analytics')}
|
||||
</Button>
|
||||
</div>
|
||||
{analyticsError && (
|
||||
<div style={{ color: 'var(--mantine-color-red-6)', marginTop: 12 }}>
|
||||
{analyticsError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#0EA5E9', '#6366F1'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import i18n from '@app/i18n';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
export default function TourOverviewSlide(): SlideConfig {
|
||||
return {
|
||||
key: 'tour-overview',
|
||||
title: i18n.t('onboarding.tourOverview.title', 'Tour Overview'),
|
||||
body: (
|
||||
<span className={styles.bodyCopyInner}>
|
||||
<Trans
|
||||
i18nKey="onboarding.tourOverview.body"
|
||||
defaults="Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need."
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#2563EB', '#7C3AED'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
|
||||
const SESSION_KEY = 'onboarding::bypass-all';
|
||||
const PARAM_KEY = 'bypassOnboarding';
|
||||
@@ -34,13 +33,12 @@ function setStoredBypass(enabled: boolean): void {
|
||||
|
||||
/**
|
||||
* Detects the `bypassOnboarding` query parameter and stores it in session storage
|
||||
* so that onboarding remains disabled while the app is open. Also marks all steps
|
||||
* as seen to ensure any dependent UI elements remain hidden.
|
||||
* so that onboarding remains disabled while the app is open. Also marks onboarding
|
||||
* as completed to ensure any dependent UI elements remain hidden.
|
||||
*/
|
||||
export function useBypassOnboarding(): boolean {
|
||||
const location = useLocation();
|
||||
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
|
||||
const stepsMarkedRef = useRef(false);
|
||||
|
||||
const shouldBypassFromSearch = useMemo(() => {
|
||||
try {
|
||||
@@ -57,14 +55,9 @@ export function useBypassOnboarding(): boolean {
|
||||
setBypassOnboarding(nextBypass);
|
||||
if (nextBypass) {
|
||||
setStoredBypass(true);
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
}, [shouldBypassFromSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bypassOnboarding || stepsMarkedRef.current) return;
|
||||
stepsMarkedRef.current = true;
|
||||
ONBOARDING_STEPS.forEach((step) => markStepSeen(step.id));
|
||||
}, [bypassOnboarding]);
|
||||
|
||||
return bypassOnboarding;
|
||||
}
|
||||
|
||||
@@ -51,14 +51,14 @@ export function useTourRequest(): {
|
||||
clearTourRequest: () => void;
|
||||
} {
|
||||
const [tourRequested, setTourRequested] = useState(false);
|
||||
const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
|
||||
const [requestedTourType, setRequestedTourType] = useState<TourType>('whatsnew');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleTourRequest = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<StartTourPayload>;
|
||||
setRequestedTourType(detail?.tourType ?? 'tools');
|
||||
setRequestedTourType(detail?.tourType ?? 'whatsnew');
|
||||
setTourRequested(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,12 +8,8 @@ export enum TourStep {
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
@@ -28,10 +24,7 @@ interface UserStepActions {
|
||||
backToAllTools: () => void;
|
||||
selectCropTool: () => void;
|
||||
loadSampleFile: () => void;
|
||||
switchToViewer: () => void;
|
||||
switchToPageEditor: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
selectFirstFile: () => void;
|
||||
pinFile: () => void;
|
||||
modifyCropSettings: () => void;
|
||||
executeTool: () => void;
|
||||
@@ -50,10 +43,7 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
@@ -108,26 +98,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEW_SWITCHER]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
|
||||
position: 'bottom',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEWER]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToViewer(),
|
||||
},
|
||||
[TourStep.PAGE_EDITOR]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToPageEditor(),
|
||||
},
|
||||
[TourStep.ACTIVE_FILES]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
|
||||
@@ -141,14 +111,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.SELECT_CONTROLS]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
|
||||
position: 'left',
|
||||
padding: 5,
|
||||
action: () => selectFirstFile(),
|
||||
},
|
||||
[TourStep.CROP_SETTINGS]: {
|
||||
selector: '[data-tour="crop-settings"]',
|
||||
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { StepType } from '@reactour/tour';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise<void> {
|
||||
if (typeof document === 'undefined') return;
|
||||
const start = Date.now();
|
||||
// Immediate hit
|
||||
if (document.querySelector(selector)) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (document.querySelector(selector) || Date.now() - start >= timeoutMs) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, intervalMs);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
|
||||
if (typeof document === 'undefined') return;
|
||||
const start = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
const isVisible = !!el && el.getClientRects().length > 0;
|
||||
if (isVisible || Date.now() - start >= timeoutMs) {
|
||||
// Nudge Reactour to recalc positions in case layout shifted
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, intervalMs);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
export enum WhatsNewTourStep {
|
||||
QUICK_ACCESS,
|
||||
LEFT_PANEL,
|
||||
FILE_UPLOAD,
|
||||
RIGHT_RAIL,
|
||||
TOP_BAR,
|
||||
PAGE_EDITOR_VIEW,
|
||||
ACTIVE_FILES_VIEW,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
interface WhatsNewStepActions {
|
||||
saveWorkbenchState: () => void;
|
||||
closeFilesModal: () => void;
|
||||
backToAllTools: () => void;
|
||||
openFilesModal: () => void;
|
||||
loadSampleFile: () => Promise<void> | void;
|
||||
switchToViewer: () => void;
|
||||
switchToPageEditor: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
selectFirstFile: () => void;
|
||||
}
|
||||
|
||||
interface CreateWhatsNewStepsConfigArgs {
|
||||
t: TFunction;
|
||||
actions: WhatsNewStepActions;
|
||||
}
|
||||
|
||||
export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools,
|
||||
openFilesModal,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
} = actions;
|
||||
|
||||
return {
|
||||
[WhatsNewTourStep.QUICK_ACCESS]: {
|
||||
selector: '[data-tour="quick-access-bar"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.quickAccess',
|
||||
'Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours.'
|
||||
),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
saveWorkbenchState();
|
||||
closeFilesModal();
|
||||
backToAllTools();
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.LEFT_PANEL]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.leftPanel',
|
||||
'The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly.'
|
||||
),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[WhatsNewTourStep.FILE_UPLOAD]: {
|
||||
selector: '[data-tour="files-button"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.fileUpload',
|
||||
'Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace.'
|
||||
),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: async () => {
|
||||
openFilesModal();
|
||||
await waitForElement('[data-tour="file-sources"]', 5000, 100);
|
||||
},
|
||||
actionAfter: async () => {
|
||||
await Promise.resolve(loadSampleFile());
|
||||
closeFilesModal();
|
||||
switchToViewer();
|
||||
// wait for file render and top controls to mount
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.RIGHT_RAIL]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t(
|
||||
'onboarding.whatsNew.rightRail',
|
||||
'The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.'
|
||||
),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: async () => {
|
||||
await waitForElement('[data-tour="right-rail-controls"]', 7000, 100);
|
||||
selectFirstFile();
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.TOP_BAR]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.topBar',
|
||||
'The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>.'
|
||||
),
|
||||
position: 'bottom',
|
||||
padding: 8,
|
||||
// Ensure the switcher has mounted before this step renders
|
||||
action: async () => {
|
||||
switchToViewer();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.pageEditorView',
|
||||
'Switch to the Page Editor to reorder, rotate, or delete pages.'
|
||||
),
|
||||
position: 'bottom',
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
switchToPageEditor();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.activeFilesView',
|
||||
'Use Active Files to see everything you have open and pick what to work on.'
|
||||
),
|
||||
position: 'bottom',
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
switchToActiveFiles();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.wrapUp',
|
||||
'That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour.'
|
||||
),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Button,
|
||||
Text,
|
||||
Title,
|
||||
Anchor,
|
||||
useMantineTheme,
|
||||
useComputedColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import { Z_ANALYTICS_MODAL } from '@app/styles/zIndex';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface AdminAnalyticsChoiceModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnalyticsChoiceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { refetch } = useAppConfig();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const theme = useMantineTheme();
|
||||
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
|
||||
const isDark = computedColorScheme === 'dark';
|
||||
const privacyHighlightStyles = {
|
||||
color: isDark ? '#FFFFFF' : theme.colors.blue[7],
|
||||
padding: `${theme.spacing.xs} ${theme.spacing.sm}`,
|
||||
borderRadius: theme.radius.md,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center' as const,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
letterSpacing: 0.3,
|
||||
};
|
||||
|
||||
const handleChoice = async (enableAnalytics: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enabled', enableAnalytics.toString());
|
||||
|
||||
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
|
||||
|
||||
// Refetch config to apply new settings without page reload
|
||||
await refetch();
|
||||
|
||||
// Close the modal after successful save
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable = () => {
|
||||
handleChoice(true);
|
||||
};
|
||||
|
||||
const handleDisable = () => {
|
||||
handleChoice(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {}} // Prevent closing
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
zIndex={Z_ANALYTICS_MODAL}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Title order={2}>{t('analytics.title', 'Do you want make Stirling PDF better?')}</Title>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product.')}
|
||||
</Text>
|
||||
<Text size="sm" style={privacyHighlightStyles}>
|
||||
• {t('analytics.privacyAssurance', 'We do not track any personal information or the contents of your files.')} •
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '}
|
||||
<Anchor
|
||||
href="https://docs.stirlingpdf.com/analytics-telemetry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
{t('analytics.learnMore', 'Learn more')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Text c="red" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
onClick={handleEnable}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
size="md"
|
||||
>
|
||||
{t('analytics.enable', 'Enable analytics')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleDisable}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
>
|
||||
{t('analytics.disable', 'Disable analytics')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,22 @@ export default function Footer({
|
||||
>
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://discord.gg/Cn8pWhQRxZ"
|
||||
>
|
||||
{t('footer.discord', 'Discord')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
>
|
||||
{t('footer.issues', 'GitHub')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
|
||||
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
|
||||
import { ButtonConfig } from '@app/types/sidebar';
|
||||
import '@app/components/shared/quickAccessBar/QuickAccessBar.css';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
|
||||
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||
@@ -18,6 +19,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||
import { requestStartTour } from '@app/constants/events';
|
||||
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
|
||||
import { useToursTooltip } from '@app/components/shared/quickAccessBar/useToursTooltip';
|
||||
|
||||
import {
|
||||
isNavButtonActive,
|
||||
@@ -41,6 +43,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
tooltipOpen,
|
||||
manualCloseOnly,
|
||||
showCloseButton,
|
||||
toursMenuOpen,
|
||||
setToursMenuOpen,
|
||||
handleTooltipOpenChange,
|
||||
} = useToursTooltip();
|
||||
|
||||
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||
|
||||
@@ -166,8 +176,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const bottomButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'help',
|
||||
name: t("quickAccess.help", "Help"),
|
||||
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
|
||||
name: t("quickAccess.tours", "Tours"),
|
||||
icon: <LocalIcon icon="explore-rounded" width="1.25rem" height="1.25rem" />,
|
||||
isRound: true,
|
||||
size: 'md',
|
||||
type: 'action',
|
||||
@@ -192,6 +202,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="quick-access"
|
||||
data-tour="quick-access-bar"
|
||||
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||
>
|
||||
{/* Fixed header outside scrollable area */}
|
||||
@@ -247,58 +258,83 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
// Handle help button with menu or direct action
|
||||
if (buttonConfig.id === 'help') {
|
||||
const isAdmin = config?.isAdmin === true;
|
||||
const toursTooltipContent = isAdmin
|
||||
? t('quickAccess.toursTooltip.admin', 'Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.')
|
||||
: t('quickAccess.toursTooltip.user', 'Watch walkthroughs here: Tools tour and the New V2 layout tour.');
|
||||
const tourItems = [
|
||||
{
|
||||
key: 'whatsnew',
|
||||
icon: <LocalIcon icon="auto-awesome-rounded" width="1.25rem" height="1.25rem" />,
|
||||
title: t("quickAccess.helpMenu.whatsNewTour", "See what's new in V2"),
|
||||
description: t("quickAccess.helpMenu.whatsNewTourDesc", "Tour the updated layout"),
|
||||
onClick: () => requestStartTour('whatsnew'),
|
||||
},
|
||||
{
|
||||
key: 'tools',
|
||||
icon: <LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />,
|
||||
title: t("quickAccess.helpMenu.toolsTour", "Tools Tour"),
|
||||
description: t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do"),
|
||||
onClick: () => requestStartTour('tools'),
|
||||
},
|
||||
...(isAdmin ? [{
|
||||
key: 'admin',
|
||||
icon: <LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
title: t("quickAccess.helpMenu.adminTour", "Admin Tour"),
|
||||
description: t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features"),
|
||||
onClick: () => requestStartTour('admin'),
|
||||
}] : []),
|
||||
];
|
||||
|
||||
// If not admin, just show button that starts tools tour directly
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div
|
||||
key={buttonConfig.id}
|
||||
data-tour="help-button"
|
||||
onClick={() => requestStartTour('tools')}
|
||||
const helpButtonNode = (
|
||||
<div data-tour="help-button">
|
||||
<Menu
|
||||
position={isRTL ? 'left' : 'right'}
|
||||
offset={10}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
opened={toursMenuOpen}
|
||||
onChange={setToursMenuOpen}
|
||||
>
|
||||
{renderNavButton(buttonConfig, index)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If admin, show menu with both options
|
||||
return (
|
||||
<div key={buttonConfig.id} data-tour="help-button">
|
||||
<Menu position={isRTL ? 'left' : 'right'} offset={10} zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}>
|
||||
<Menu.Target>
|
||||
<div>{renderNavButton(buttonConfig, index)}</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
|
||||
onClick={() => requestStartTour('tools')}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t("quickAccess.helpMenu.toolsTour", "Tools Tour")}
|
||||
{tourItems.map((item) => (
|
||||
<Menu.Item
|
||||
key={item.key}
|
||||
leftSection={item.icon}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{item.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
|
||||
{t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do")}
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
|
||||
onClick={() => requestStartTour('admin')}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t("quickAccess.helpMenu.adminTour", "Admin Tour")}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
|
||||
{t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features")}
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
position="right"
|
||||
arrow
|
||||
offset={8}
|
||||
open={tooltipOpen}
|
||||
manualCloseOnly={manualCloseOnly}
|
||||
showCloseButton={showCloseButton}
|
||||
closeOnOutside={false}
|
||||
openOnFocus={false}
|
||||
content={toursTooltipContent}
|
||||
onOpenChange={handleTooltipOpenChange}
|
||||
>
|
||||
{helpButtonNode}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const buttonNode = renderNavButton(buttonConfig, index);
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface TooltipProps {
|
||||
disabled?: boolean;
|
||||
/** If false, tooltip will not open on focus (hover only) */
|
||||
openOnFocus?: boolean;
|
||||
/** If true, tooltip stays open until explicitly closed (ignores hover/blur/esc/outside) */
|
||||
manualCloseOnly?: boolean;
|
||||
/** Show a close button even when not pinned */
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
@@ -55,6 +59,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
closeOnOutside = true,
|
||||
disabled = false,
|
||||
openOnFocus = true,
|
||||
manualCloseOnly = false,
|
||||
showCloseButton = false,
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
@@ -81,6 +87,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
||||
const allowAutoClose = !manualCloseOnly;
|
||||
|
||||
const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => {
|
||||
const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr';
|
||||
@@ -132,11 +139,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
}
|
||||
|
||||
// Not pinned and configured to close on outside
|
||||
if (closeOnOutside && !insideTooltip && !insideTrigger) {
|
||||
if (allowAutoClose && closeOnOutside && !insideTooltip && !insideTrigger) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[isPinned, closeOnOutside, setOpen]
|
||||
[isPinned, closeOnOutside, setOpen, allowAutoClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -200,10 +207,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
if (!isPinned) setOpen(false);
|
||||
if (allowAutoClose && !isPinned) setOpen(false);
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
},
|
||||
[clearTimers, isPinned, setOpen, children.props]
|
||||
[clearTimers, isPinned, setOpen, children.props, allowAutoClose]
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
@@ -257,15 +264,16 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
return;
|
||||
}
|
||||
clearTimers();
|
||||
if (!isPinned) setOpen(false);
|
||||
if (allowAutoClose && !isPinned) setOpen(false);
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
},
|
||||
[isPinned, setOpen, children.props, clearTimers]
|
||||
[isPinned, setOpen, children.props, allowAutoClose, clearTimers]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (manualCloseOnly) return;
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}, [setOpen]);
|
||||
}, [setOpen, manualCloseOnly]);
|
||||
|
||||
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
|
||||
const handleTooltipPointerEnter = useCallback(() => {
|
||||
@@ -276,9 +284,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
(e: React.PointerEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
|
||||
if (!isPinned) setOpen(false);
|
||||
if (allowAutoClose && !isPinned) setOpen(false);
|
||||
},
|
||||
[isPinned, setOpen]
|
||||
[isPinned, setOpen, allowAutoClose]
|
||||
);
|
||||
|
||||
// Enhance child with handlers and ref
|
||||
@@ -301,6 +309,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
});
|
||||
|
||||
const shouldShowTooltip = open;
|
||||
const shouldShowCloseButton = showCloseButton || isPinned;
|
||||
|
||||
const tooltipElement = shouldShowTooltip ? (
|
||||
<div
|
||||
@@ -325,7 +334,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
|
||||
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
|
||||
>
|
||||
{isPinned && (
|
||||
{shouldShowCloseButton && (
|
||||
<button
|
||||
className={styles['tooltip-pin-button']}
|
||||
onClick={(e) => {
|
||||
@@ -363,7 +372,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
<span className={styles['tooltip-title']}>{header.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<TooltipContent content={content} tips={tips} />
|
||||
<TooltipContent content={content} tips={tips} extraRightPadding={shouldShowCloseButton ? 48 : 0} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { TOUR_STATE_EVENT, type TourStatePayload } from '@app/constants/events';
|
||||
import { isOnboardingCompleted, hasShownToursTooltip, markToursTooltipShown } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
|
||||
export interface ToursTooltipState {
|
||||
tooltipOpen: boolean | undefined;
|
||||
manualCloseOnly: boolean;
|
||||
showCloseButton: boolean;
|
||||
toursMenuOpen: boolean;
|
||||
setToursMenuOpen: (open: boolean) => void;
|
||||
handleTooltipOpenChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates all the logic for the tours tooltip:
|
||||
* - Shows automatically after onboarding/tour completes (once per user)
|
||||
* - Hides while the tours menu is open
|
||||
* - After dismissal, reverts to hover-only tooltip
|
||||
*/
|
||||
export function useToursTooltip(): ToursTooltipState {
|
||||
const [showToursTooltip, setShowToursTooltip] = useState(false);
|
||||
const [toursMenuOpen, setToursMenuOpen] = useState(false);
|
||||
const tourWasOpenRef = useRef(false);
|
||||
|
||||
// Auto-show when a tour ends (fires once per user)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleTourStateChange = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<TourStatePayload>;
|
||||
const wasOpen = tourWasOpenRef.current;
|
||||
tourWasOpenRef.current = detail.isOpen;
|
||||
|
||||
if (wasOpen && !detail.isOpen && !hasShownToursTooltip()) {
|
||||
setShowToursTooltip(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(TOUR_STATE_EVENT, handleTourStateChange);
|
||||
return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange);
|
||||
}, []);
|
||||
|
||||
// Show once after onboarding is complete
|
||||
useEffect(() => {
|
||||
if (isOnboardingCompleted() && !hasShownToursTooltip()) {
|
||||
setShowToursTooltip(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismissToursTooltip = useCallback(() => {
|
||||
markToursTooltipShown();
|
||||
setShowToursTooltip(false);
|
||||
}, []);
|
||||
|
||||
const hasBeenDismissed = hasShownToursTooltip();
|
||||
|
||||
const handleTooltipOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) {
|
||||
if (!hasBeenDismissed) {
|
||||
handleDismissToursTooltip();
|
||||
}
|
||||
} else if (!hasBeenDismissed && !toursMenuOpen) {
|
||||
setShowToursTooltip(true);
|
||||
}
|
||||
},
|
||||
[hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip]
|
||||
);
|
||||
|
||||
const tooltipOpen = toursMenuOpen ? false : hasBeenDismissed ? undefined : showToursTooltip;
|
||||
|
||||
return {
|
||||
tooltipOpen,
|
||||
manualCloseOnly: !hasBeenDismissed,
|
||||
showCloseButton: !hasBeenDismissed,
|
||||
toursMenuOpen,
|
||||
setToursMenuOpen,
|
||||
handleTooltipOpenChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
background: var(--bg-raised);
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 0.0625rem solid var(--primary-color, #3b82f6);
|
||||
border: 0.0625rem solid var(--border-default);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
z-index: 1;
|
||||
@@ -60,6 +60,13 @@
|
||||
border-color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.tooltip-pin-button:focus,
|
||||
.tooltip-pin-button:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--border-default) !important;
|
||||
background-color: var(--bg-raised) !important;
|
||||
}
|
||||
|
||||
/* Tooltip Header */
|
||||
.tooltip-header {
|
||||
display: flex;
|
||||
@@ -91,7 +98,7 @@
|
||||
|
||||
/* Tooltip Body */
|
||||
.tooltip-body {
|
||||
padding: 1rem !important;
|
||||
padding: 1rem;
|
||||
color: var(--text-primary) !important;
|
||||
font-size: 0.875rem !important;
|
||||
line-height: 1.6 !important;
|
||||
|
||||
@@ -5,18 +5,20 @@ import { TooltipTip } from '@app/types/tips';
|
||||
interface TooltipContentProps {
|
||||
content?: React.ReactNode;
|
||||
tips?: TooltipTip[];
|
||||
extraRightPadding?: number;
|
||||
}
|
||||
|
||||
export const TooltipContent: React.FC<TooltipContentProps> = ({
|
||||
content,
|
||||
tips,
|
||||
extraRightPadding = 0,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`${styles['tooltip-body']}`}
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
padding: '16px',
|
||||
padding: `16px ${16 + extraRightPadding}px 16px 16px`,
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6'
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user