mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Chore/v2/onboarding flow cleanup (#5065)
This commit is contained in:
@@ -3,7 +3,7 @@ import { AppProviders } from "@app/components/AppProviders";
|
|||||||
import { AppLayout } from "@app/components/AppLayout";
|
import { AppLayout } from "@app/components/AppLayout";
|
||||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||||
import HomePage from "@app/pages/HomePage";
|
import HomePage from "@app/pages/HomePage";
|
||||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||||
|
|
||||||
// Import global styles
|
// Import global styles
|
||||||
import "@app/styles/tailwind.css";
|
import "@app/styles/tailwind.css";
|
||||||
@@ -19,7 +19,7 @@ export default function App() {
|
|||||||
<AppProviders>
|
<AppProviders>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
<OnboardingTour />
|
<Onboarding />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from
|
|||||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||||
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
|
||||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||||
@@ -77,7 +76,6 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
<RainbowThemeProvider>
|
<RainbowThemeProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BannerProvider>
|
<BannerProvider>
|
||||||
<OnboardingProvider>
|
|
||||||
<AppConfigProvider
|
<AppConfigProvider
|
||||||
retryOptions={appConfigRetryOptions}
|
retryOptions={appConfigRetryOptions}
|
||||||
{...appConfigProviderProps}
|
{...appConfigProviderProps}
|
||||||
@@ -113,7 +111,6 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
</ToolRegistryProvider>
|
</ToolRegistryProvider>
|
||||||
</FileContextProvider>
|
</FileContextProvider>
|
||||||
</AppConfigProvider>
|
</AppConfigProvider>
|
||||||
</OnboardingProvider>
|
|
||||||
</BannerProvider>
|
</BannerProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</RainbowThemeProvider>
|
</RainbowThemeProvider>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { FLOW_SEQUENCES, type SlideId } from '@app/components/onboarding/onboardingFlowConfig';
|
|
||||||
|
|
||||||
export type FlowType = 'login-admin' | 'login-user' | 'no-login' | 'no-login-admin';
|
|
||||||
|
|
||||||
export interface FlowConfig {
|
|
||||||
type: FlowType;
|
|
||||||
ids: SlideId[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveFlow(enableLogin: boolean, isAdmin: boolean, selfReportedAdmin: boolean): FlowConfig {
|
|
||||||
if (!enableLogin) {
|
|
||||||
return selfReportedAdmin
|
|
||||||
? {
|
|
||||||
type: 'no-login-admin',
|
|
||||||
ids: [...FLOW_SEQUENCES.noLoginBase, ...FLOW_SEQUENCES.noLoginAdmin],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: 'no-login',
|
|
||||||
ids: FLOW_SEQUENCES.noLoginBase,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return isAdmin
|
|
||||||
? {
|
|
||||||
type: 'login-admin',
|
|
||||||
ids: FLOW_SEQUENCES.loginAdmin,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: 'login-user',
|
|
||||||
ids: FLOW_SEQUENCES.loginUser,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onb
|
|||||||
import type { LicenseNotice } from '@app/types/types';
|
import type { LicenseNotice } from '@app/types/types';
|
||||||
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||||
|
|
||||||
interface RenderButtonsProps {
|
interface SlideButtonsProps {
|
||||||
slideDefinition: {
|
slideDefinition: {
|
||||||
buttons: ButtonDefinition[];
|
buttons: ButtonDefinition[];
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,7 +16,7 @@ interface RenderButtonsProps {
|
|||||||
onAction: (action: ButtonAction) => void;
|
onAction: (action: ButtonAction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) {
|
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
|
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
|
||||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
|
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
|
||||||
@@ -105,5 +105,4 @@ export function renderButtons({ slideDefinition, licenseNotice, flowState, onAct
|
|||||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { LicenseNotice } from '@app/types/types';
|
|
||||||
|
|
||||||
export interface InitialOnboardingModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
|
|
||||||
onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OnboardingState {
|
|
||||||
step: number;
|
|
||||||
selectedRole: 'admin' | 'user' | null;
|
|
||||||
selfReportedAdmin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_STATE: OnboardingState = {
|
|
||||||
step: 0,
|
|
||||||
selectedRole: null,
|
|
||||||
selfReportedAdmin: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
-381
@@ -1,381 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
|
||||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
|
||||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
|
||||||
import { useOs } from '@app/hooks/useOs';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
SLIDE_DEFINITIONS,
|
|
||||||
type ButtonAction,
|
|
||||||
type FlowState,
|
|
||||||
type SlideId,
|
|
||||||
} from '@app/components/onboarding/onboardingFlowConfig';
|
|
||||||
import type { LicenseNotice } from '@app/types/types';
|
|
||||||
import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver';
|
|
||||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
|
||||||
import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types';
|
|
||||||
import { DOWNLOAD_URLS } from '@app/constants/downloads';
|
|
||||||
|
|
||||||
interface UseInitialOnboardingStateResult {
|
|
||||||
state: OnboardingState;
|
|
||||||
totalSteps: number;
|
|
||||||
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
|
|
||||||
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
|
|
||||||
licenseNotice: LicenseNotice;
|
|
||||||
flowState: FlowState;
|
|
||||||
closeAndMarkSeen: () => void;
|
|
||||||
handleButtonAction: (action: ButtonAction) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInitialOnboardingState({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
onRequestServerLicense,
|
|
||||||
onLicenseNoticeUpdate,
|
|
||||||
}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
|
|
||||||
const { preferences, updatePreference } = usePreferences();
|
|
||||||
const { startTour } = useOnboarding();
|
|
||||||
const {
|
|
||||||
loginEnabled: loginEnabledFromServer,
|
|
||||||
configIsAdmin,
|
|
||||||
totalUsers: serverTotalUsers,
|
|
||||||
userCountResolved: serverUserCountResolved,
|
|
||||||
freeTierLimit,
|
|
||||||
hasPaidLicense,
|
|
||||||
scenarioKey,
|
|
||||||
setSelfReportedAdmin,
|
|
||||||
isNewServer,
|
|
||||||
} = useServerExperience();
|
|
||||||
const osType = useOs();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const selectedDownloadUrlRef = useRef<string>('');
|
|
||||||
|
|
||||||
const [state, setState] = useState<OnboardingState>(DEFAULT_STATE);
|
|
||||||
|
|
||||||
const resetState = useCallback(() => {
|
|
||||||
setState(DEFAULT_STATE);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!opened) {
|
|
||||||
resetState();
|
|
||||||
}
|
|
||||||
}, [opened, resetState]);
|
|
||||||
|
|
||||||
const handleRoleSelect = useCallback(
|
|
||||||
(role: 'admin' | 'user' | null) => {
|
|
||||||
const isAdminSelection = role === 'admin';
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
selectedRole: role,
|
|
||||||
selfReportedAdmin: isAdminSelection,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
if (isAdminSelection) {
|
|
||||||
window.localStorage.setItem('stirling-self-reported-admin', 'true');
|
|
||||||
} else {
|
|
||||||
window.localStorage.removeItem('stirling-self-reported-admin');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelfReportedAdmin(isAdminSelection);
|
|
||||||
},
|
|
||||||
[setSelfReportedAdmin],
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeAndMarkSeen = useCallback(() => {
|
|
||||||
if (!preferences.hasSeenIntroOnboarding) {
|
|
||||||
updatePreference('hasSeenIntroOnboarding', true);
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
|
|
||||||
|
|
||||||
const isAdmin = configIsAdmin;
|
|
||||||
const enableLogin = loginEnabledFromServer;
|
|
||||||
|
|
||||||
const effectiveEnableLogin = enableLogin;
|
|
||||||
const effectiveIsAdmin = isAdmin;
|
|
||||||
const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) {
|
|
||||||
handleRoleSelect('admin');
|
|
||||||
}
|
|
||||||
}, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]);
|
|
||||||
|
|
||||||
const shouldUseServerCount =
|
|
||||||
(effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin;
|
|
||||||
const licenseUserCountFromServer =
|
|
||||||
shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null;
|
|
||||||
|
|
||||||
const effectiveLicenseUserCount = licenseUserCountFromServer ?? null;
|
|
||||||
|
|
||||||
const os = useMemo(() => {
|
|
||||||
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 };
|
|
||||||
default:
|
|
||||||
return { label: '', url: '' };
|
|
||||||
}
|
|
||||||
}, [osType]);
|
|
||||||
|
|
||||||
const osOptions = useMemo(() => {
|
|
||||||
const options = [
|
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
return options.filter(opt => opt.url);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resolvedFlow = useMemo(
|
|
||||||
() => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin),
|
|
||||||
[effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin],
|
|
||||||
);
|
|
||||||
const shouldSkipSecurityCheck = shouldAssumeAdminForNewServer;
|
|
||||||
const flowSlideIds = useMemo(
|
|
||||||
() =>
|
|
||||||
shouldSkipSecurityCheck
|
|
||||||
? resolvedFlow.ids.filter((id) => id !== 'security-check')
|
|
||||||
: resolvedFlow.ids,
|
|
||||||
[resolvedFlow.ids, shouldSkipSecurityCheck],
|
|
||||||
);
|
|
||||||
const flowType = resolvedFlow.type;
|
|
||||||
const totalSteps = flowSlideIds.length;
|
|
||||||
const maxIndex = Math.max(totalSteps - 1, 0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.step >= flowSlideIds.length) {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
step: Math.max(flowSlideIds.length - 1, 0),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [flowSlideIds.length, state.step]);
|
|
||||||
|
|
||||||
const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1];
|
|
||||||
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
|
|
||||||
|
|
||||||
if (!slideDefinition) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scenarioProvidesInfo =
|
|
||||||
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
|
|
||||||
const scenarioIndicatesAdmin = scenarioProvidesInfo
|
|
||||||
? scenarioKey!.includes('admin')
|
|
||||||
: state.selfReportedAdmin || effectiveIsAdmin;
|
|
||||||
const scenarioIndicatesOverLimit = scenarioProvidesInfo
|
|
||||||
? scenarioKey!.includes('over-limit')
|
|
||||||
: effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit;
|
|
||||||
const scenarioRequiresLicense =
|
|
||||||
scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true;
|
|
||||||
|
|
||||||
const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense;
|
|
||||||
|
|
||||||
const licenseNotice = useMemo<LicenseNotice>(
|
|
||||||
() => ({
|
|
||||||
totalUsers: effectiveLicenseUserCount,
|
|
||||||
freeTierLimit,
|
|
||||||
isOverLimit: scenarioIndicatesOverLimit,
|
|
||||||
requiresLicense: shouldShowServerLicenseInfo,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
effectiveLicenseUserCount,
|
|
||||||
freeTierLimit,
|
|
||||||
scenarioIndicatesOverLimit,
|
|
||||||
shouldShowServerLicenseInfo,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const requestServerLicenseIfNeeded = useCallback(
|
|
||||||
(options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => {
|
|
||||||
if (!shouldShowServerLicenseInfo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onRequestServerLicense?.(options);
|
|
||||||
},
|
|
||||||
[onRequestServerLicense, shouldShowServerLicenseInfo],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onLicenseNoticeUpdate?.(licenseNotice);
|
|
||||||
}, [licenseNotice, onLicenseNoticeUpdate]);
|
|
||||||
|
|
||||||
// Initialize ref with default URL
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedDownloadUrlRef.current && os.url) {
|
|
||||||
selectedDownloadUrlRef.current = os.url;
|
|
||||||
}
|
|
||||||
}, [os.url]);
|
|
||||||
|
|
||||||
const handleDownloadUrlChange = useCallback((url: string) => {
|
|
||||||
selectedDownloadUrlRef.current = url;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const currentSlide = slideDefinition.createSlide({
|
|
||||||
osLabel: os.label,
|
|
||||||
osUrl: os.url,
|
|
||||||
osOptions,
|
|
||||||
onDownloadUrlChange: handleDownloadUrlChange,
|
|
||||||
selectedRole: state.selectedRole,
|
|
||||||
onRoleSelect: handleRoleSelect,
|
|
||||||
licenseNotice,
|
|
||||||
loginEnabled: effectiveEnableLogin,
|
|
||||||
});
|
|
||||||
|
|
||||||
const goNext = useCallback(() => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
step: Math.min(prev.step + 1, maxIndex),
|
|
||||||
}));
|
|
||||||
}, [maxIndex]);
|
|
||||||
|
|
||||||
const goPrev = useCallback(() => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
step: Math.max(prev.step - 1, 0),
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const launchTour = useCallback(
|
|
||||||
(mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => {
|
|
||||||
if (options?.closeOnboardingSlides) {
|
|
||||||
closeAndMarkSeen();
|
|
||||||
}
|
|
||||||
|
|
||||||
startTour(mode, {
|
|
||||||
source: 'initial-onboarding-modal',
|
|
||||||
metadata: {
|
|
||||||
hasCompletedOnboarding: preferences.hasCompletedOnboarding,
|
|
||||||
toolPanelModePromptSeen: preferences.toolPanelModePromptSeen,
|
|
||||||
selfReportedAdmin: state.selfReportedAdmin,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleButtonAction = useCallback(
|
|
||||||
(action: ButtonAction) => {
|
|
||||||
const currentSlideIdLocal = currentSlideId;
|
|
||||||
const shouldAutoLaunchLoginUserTour =
|
|
||||||
flowType === 'login-user' && currentSlideIdLocal === 'desktop-install';
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'next':
|
|
||||||
if (shouldAutoLaunchLoginUserTour) {
|
|
||||||
launchTour('tools', { closeOnboardingSlides: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goNext();
|
|
||||||
return;
|
|
||||||
case 'prev':
|
|
||||||
goPrev();
|
|
||||||
return;
|
|
||||||
case 'close':
|
|
||||||
closeAndMarkSeen();
|
|
||||||
return;
|
|
||||||
case 'download-selected': {
|
|
||||||
const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl;
|
|
||||||
if (downloadUrl) {
|
|
||||||
window.open(downloadUrl, '_blank', 'noopener');
|
|
||||||
}
|
|
||||||
if (shouldAutoLaunchLoginUserTour) {
|
|
||||||
launchTour('tools', { closeOnboardingSlides: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goNext();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case 'complete-close':
|
|
||||||
updatePreference('hasCompletedOnboarding', true);
|
|
||||||
closeAndMarkSeen();
|
|
||||||
return;
|
|
||||||
case 'security-next':
|
|
||||||
if (!state.selectedRole) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.selectedRole === 'admin') {
|
|
||||||
goNext();
|
|
||||||
} else {
|
|
||||||
launchTour('tools', { closeOnboardingSlides: true });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case 'launch-admin':
|
|
||||||
requestServerLicenseIfNeeded({
|
|
||||||
deferUntilTourComplete: true,
|
|
||||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
|
||||||
});
|
|
||||||
launchTour('admin', { closeOnboardingSlides: true });
|
|
||||||
return;
|
|
||||||
case 'launch-tools':
|
|
||||||
launchTour('tools', { closeOnboardingSlides: true });
|
|
||||||
return;
|
|
||||||
case 'launch-auto': {
|
|
||||||
const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools';
|
|
||||||
if (launchMode === 'admin') {
|
|
||||||
requestServerLicenseIfNeeded({
|
|
||||||
deferUntilTourComplete: true,
|
|
||||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
launchTour(launchMode, { closeOnboardingSlides: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case 'skip-to-license':
|
|
||||||
updatePreference('hasCompletedOnboarding', true);
|
|
||||||
requestServerLicenseIfNeeded({
|
|
||||||
deferUntilTourComplete: false,
|
|
||||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
|
||||||
});
|
|
||||||
closeAndMarkSeen();
|
|
||||||
return;
|
|
||||||
case 'see-plans':
|
|
||||||
closeAndMarkSeen();
|
|
||||||
navigate('/settings/adminPlan');
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
closeAndMarkSeen,
|
|
||||||
currentSlide,
|
|
||||||
effectiveIsAdmin,
|
|
||||||
flowType,
|
|
||||||
goNext,
|
|
||||||
goPrev,
|
|
||||||
launchTour,
|
|
||||||
navigate,
|
|
||||||
requestServerLicenseIfNeeded,
|
|
||||||
onRequestServerLicense,
|
|
||||||
os.url,
|
|
||||||
state.selectedRole,
|
|
||||||
state.selfReportedAdmin,
|
|
||||||
updatePreference,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const flowState: FlowState = { selectedRole: state.selectedRole };
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
totalSteps,
|
|
||||||
slideDefinition,
|
|
||||||
currentSlide,
|
|
||||||
licenseNotice,
|
|
||||||
flowState,
|
|
||||||
closeAndMarkSeen,
|
|
||||||
handleButtonAction,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
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 { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||||
|
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 { 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 '@app/components/onboarding/OnboardingTour.css';
|
||||||
|
|
||||||
|
export default function Onboarding() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { state, actions } = useOnboardingOrchestrator();
|
||||||
|
const serverExperience = useServerExperience();
|
||||||
|
const onAuthRoute = isAuthRoute(location.pathname);
|
||||||
|
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
|
||||||
|
|
||||||
|
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
|
||||||
|
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
|
||||||
|
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
|
||||||
|
|
||||||
|
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
|
||||||
|
actions.updateRuntimeState({ selectedRole: role });
|
||||||
|
serverExperience.setSelfReportedAdmin(role === 'admin');
|
||||||
|
}, [actions, serverExperience]);
|
||||||
|
|
||||||
|
const handlePasswordChanged = useCallback(() => {
|
||||||
|
actions.updateRuntimeState({ requiresPasswordChange: false });
|
||||||
|
window.location.href = '/login';
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const handleButtonAction = useCallback((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({ tourRequested: true, tourType: 'tools' });
|
||||||
|
}
|
||||||
|
actions.complete();
|
||||||
|
break;
|
||||||
|
case 'launch-admin':
|
||||||
|
actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
|
||||||
|
actions.complete();
|
||||||
|
break;
|
||||||
|
case 'launch-tools':
|
||||||
|
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
||||||
|
actions.complete();
|
||||||
|
break;
|
||||||
|
case 'launch-auto': {
|
||||||
|
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
|
||||||
|
actions.updateRuntimeState({ tourRequested: true, tourType });
|
||||||
|
actions.complete();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'skip-to-license':
|
||||||
|
markStepSeen('tour');
|
||||||
|
actions.updateRuntimeState({ tourRequested: false });
|
||||||
|
actions.complete();
|
||||||
|
break;
|
||||||
|
case 'see-plans':
|
||||||
|
actions.complete();
|
||||||
|
navigate('/settings/adminPlan');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [actions, 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]);
|
||||||
|
|
||||||
|
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||||
|
const tourOrch = useTourOrchestration();
|
||||||
|
const adminTourOrch = useAdminTourOrchestration();
|
||||||
|
|
||||||
|
const userStepsConfig = useMemo(
|
||||||
|
() => createUserStepsConfig({
|
||||||
|
t,
|
||||||
|
actions: {
|
||||||
|
saveWorkbenchState: tourOrch.saveWorkbenchState,
|
||||||
|
closeFilesModal,
|
||||||
|
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,
|
||||||
|
openFilesModal,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[t, tourOrch, closeFilesModal, openFilesModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminStepsConfig = useMemo(
|
||||||
|
() => createAdminStepsConfig({
|
||||||
|
t,
|
||||||
|
actions: {
|
||||||
|
saveAdminState: adminTourOrch.saveAdminState,
|
||||||
|
openConfigModal: adminTourOrch.openConfigModal,
|
||||||
|
navigateToSection: adminTourOrch.navigateToSection,
|
||||||
|
scrollNavToSection: adminTourOrch.scrollNavToSection,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[t, adminTourOrch]
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}, [currentStep, isTourOpen, activeFlow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalTourRequested) {
|
||||||
|
actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
|
||||||
|
markStepSeen('tour');
|
||||||
|
setIsTourOpen(true);
|
||||||
|
clearTourRequest();
|
||||||
|
}
|
||||||
|
}, [externalTourRequested, requestedTourType, actions, clearTourRequest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTourOpen) removeAllGlows();
|
||||||
|
return () => removeAllGlows();
|
||||||
|
}, [isTourOpen]);
|
||||||
|
|
||||||
|
const finishTour = useCallback(() => {
|
||||||
|
setIsTourOpen(false);
|
||||||
|
if (runtimeState.tourType === 'admin') {
|
||||||
|
adminTourOrch.restoreAdminState();
|
||||||
|
} else {
|
||||||
|
tourOrch.restoreWorkbenchState();
|
||||||
|
}
|
||||||
|
markStepSeen('tour');
|
||||||
|
if (currentStep?.id === 'tour') actions.complete();
|
||||||
|
}, [actions, adminTourOrch, currentStep?.id, 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 handleCloseTour = useCallback((args: CloseArgs) => {
|
||||||
|
args.setIsOpen(false);
|
||||||
|
finishTour();
|
||||||
|
}, [finishTour]);
|
||||||
|
|
||||||
|
const currentSlideDefinition = useMemo(() => {
|
||||||
|
if (!currentStep || currentStep.type !== 'modal-slide' || !currentStep.slideId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
|
||||||
|
}, [currentStep]);
|
||||||
|
|
||||||
|
const currentSlideContent = useMemo(() => {
|
||||||
|
if (!currentSlideDefinition) return null;
|
||||||
|
return currentSlideDefinition.createSlide({
|
||||||
|
osLabel: osInfo.label,
|
||||||
|
osUrl: osInfo.url,
|
||||||
|
osOptions,
|
||||||
|
onDownloadUrlChange: setSelectedDownloadUrl,
|
||||||
|
selectedRole: runtimeState.selectedRole,
|
||||||
|
onRoleSelect: handleRoleSelect,
|
||||||
|
licenseNotice: runtimeState.licenseNotice,
|
||||||
|
loginEnabled: serverExperience.loginEnabled,
|
||||||
|
firstLoginUsername: runtimeState.firstLoginUsername,
|
||||||
|
onPasswordChanged: handlePasswordChanged,
|
||||||
|
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
|
||||||
|
});
|
||||||
|
}, [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;
|
||||||
|
}, [activeFlow]);
|
||||||
|
|
||||||
|
const currentModalSlideIndex = useMemo(() => {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
if (onAuthRoute) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLicenseSlide) {
|
||||||
|
const slideDefinition = SLIDE_DEFINITIONS['server-license'];
|
||||||
|
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
|
||||||
|
const slideContent = slideDefinition.createSlide({
|
||||||
|
osLabel: '',
|
||||||
|
osUrl: '',
|
||||||
|
osOptions: [],
|
||||||
|
onDownloadUrlChange: () => {},
|
||||||
|
selectedRole: null,
|
||||||
|
onRoleSelect: () => {},
|
||||||
|
licenseNotice: effectiveLicenseNotice,
|
||||||
|
loginEnabled: serverExperience.loginEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingModalSlide
|
||||||
|
slideDefinition={slideDefinition}
|
||||||
|
slideContent={slideContent}
|
||||||
|
runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice }}
|
||||||
|
modalSlideCount={1}
|
||||||
|
currentModalSlideIndex={0}
|
||||||
|
onSkip={closeLicenseSlide}
|
||||||
|
onAction={(action) => {
|
||||||
|
if (action === 'see-plans') {
|
||||||
|
closeLicenseSlide();
|
||||||
|
navigate('/settings/adminPlan');
|
||||||
|
} else {
|
||||||
|
closeLicenseSlide();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !isActive || !currentStep) {
|
||||||
|
return (
|
||||||
|
<OnboardingTour
|
||||||
|
isOpen={isTourOpen}
|
||||||
|
tourSteps={tourSteps}
|
||||||
|
tourType={runtimeState.tourType}
|
||||||
|
isRTL={isRTL}
|
||||||
|
t={t}
|
||||||
|
onAdvance={handleAdvanceTour}
|
||||||
|
onClose={handleCloseTour}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<OnboardingModalSlide
|
||||||
|
slideDefinition={currentSlideDefinition}
|
||||||
|
slideContent={currentSlideContent}
|
||||||
|
runtimeState={runtimeState}
|
||||||
|
modalSlideCount={modalSlideCount}
|
||||||
|
currentModalSlideIndex={currentModalSlideIndex}
|
||||||
|
onSkip={actions.skip}
|
||||||
|
onAction={handleButtonAction}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-41
@@ -1,33 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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 React from 'react';
|
||||||
import { Modal, Stack } from '@mantine/core';
|
import { Modal, Stack } from '@mantine/core';
|
||||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
|
||||||
|
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 AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||||
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
|
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
|
||||||
import { renderButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
|
import { SlideButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
|
||||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import type { InitialOnboardingModalProps } from '@app/components/onboarding/InitialOnboardingModal/types';
|
|
||||||
import { useInitialOnboardingState } from '@app/components/onboarding/InitialOnboardingModal/useInitialOnboardingState';
|
|
||||||
import { BASE_PATH } from '@app/constants/app';
|
import { BASE_PATH } from '@app/constants/app';
|
||||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||||
|
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||||
|
|
||||||
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
|
interface OnboardingModalSlideProps {
|
||||||
const flow = useInitialOnboardingState(props);
|
slideDefinition: SlideDefinition;
|
||||||
|
slideContent: SlideConfig;
|
||||||
|
runtimeState: OnboardingRuntimeState;
|
||||||
|
modalSlideCount: number;
|
||||||
|
currentModalSlideIndex: number;
|
||||||
|
onSkip: () => void;
|
||||||
|
onAction: (action: ButtonAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
if (!flow) {
|
export default function OnboardingModalSlide({
|
||||||
return null;
|
slideDefinition,
|
||||||
}
|
slideContent,
|
||||||
|
runtimeState,
|
||||||
const {
|
modalSlideCount,
|
||||||
state,
|
currentModalSlideIndex,
|
||||||
totalSteps,
|
onSkip,
|
||||||
currentSlide,
|
onAction,
|
||||||
slideDefinition,
|
}: OnboardingModalSlideProps) {
|
||||||
licenseNotice,
|
|
||||||
flowState,
|
|
||||||
closeAndMarkSeen,
|
|
||||||
handleButtonAction,
|
|
||||||
} = flow;
|
|
||||||
|
|
||||||
const renderHero = () => {
|
const renderHero = () => {
|
||||||
if (slideDefinition.hero.type === 'dual-icon') {
|
if (slideDefinition.hero.type === 'dual-icon') {
|
||||||
@@ -48,6 +59,9 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
|
|||||||
{slideDefinition.hero.type === 'shield' && (
|
{slideDefinition.hero.type === 'shield' && (
|
||||||
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
|
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
|
||||||
)}
|
)}
|
||||||
|
{slideDefinition.hero.type === 'lock' && (
|
||||||
|
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
||||||
|
)}
|
||||||
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
||||||
{slideDefinition.hero.type === 'logo' && (
|
{slideDefinition.hero.type === 'logo' && (
|
||||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||||
@@ -58,8 +72,8 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={props.opened}
|
opened={true}
|
||||||
onClose={closeAndMarkSeen}
|
onClose={onSkip}
|
||||||
closeOnClickOutside={false}
|
closeOnClickOutside={false}
|
||||||
centered
|
centered
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -67,48 +81,48 @@ export default function InitialOnboardingModal(props: InitialOnboardingModalProp
|
|||||||
withCloseButton={false}
|
withCloseButton={false}
|
||||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||||
styles={{
|
styles={{
|
||||||
body: { padding: 0 },
|
body: { padding: 0, maxHeight: '90vh', overflow: 'hidden' },
|
||||||
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
|
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)', maxHeight: '90vh' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack gap={0} className={styles.modalContent}>
|
<Stack gap={0} className={styles.modalContent}>
|
||||||
<div className={styles.heroWrapper}>
|
<div className={styles.heroWrapper}>
|
||||||
<AnimatedSlideBackground
|
<AnimatedSlideBackground
|
||||||
gradientStops={currentSlide.background.gradientStops}
|
gradientStops={slideContent.background.gradientStops}
|
||||||
circles={currentSlide.background.circles}
|
circles={slideContent.background.circles}
|
||||||
isActive
|
isActive
|
||||||
slideKey={currentSlide.key}
|
slideKey={slideContent.key}
|
||||||
/>
|
/>
|
||||||
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
|
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
|
||||||
{renderHero()}
|
{renderHero()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.modalBody}>
|
<div className={styles.modalBody} style={{ overflowY: 'auto', maxHeight: 'calc(90vh - 220px)' }}>
|
||||||
<Stack gap={16}>
|
<Stack gap={16}>
|
||||||
<div
|
<div
|
||||||
key={`title-${currentSlide.key}`}
|
key={`title-${slideContent.key}`}
|
||||||
className={`${styles.title} ${styles.titleText}`}
|
className={`${styles.title} ${styles.titleText}`}
|
||||||
>
|
>
|
||||||
{currentSlide.title}
|
{slideContent.title}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.bodyText}>
|
<div className={styles.bodyText}>
|
||||||
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
<div key={`body-${slideContent.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||||
{currentSlide.body}
|
{slideContent.body}
|
||||||
</div>
|
</div>
|
||||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OnboardingStepper totalSteps={totalSteps} activeStep={state.step} />
|
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
||||||
|
|
||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
{renderButtons({
|
<SlideButtons
|
||||||
slideDefinition,
|
slideDefinition={slideDefinition}
|
||||||
licenseNotice,
|
licenseNotice={runtimeState.licenseNotice}
|
||||||
flowState,
|
flowState={{ selectedRole: runtimeState.selectedRole }}
|
||||||
onAction: handleButtonAction,
|
onAction={onAction}
|
||||||
})}
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,231 +1,151 @@
|
|||||||
import React, { useEffect, useMemo } from "react";
|
/**
|
||||||
import { TourProvider, type StepType } from '@reactour/tour';
|
* OnboardingTour Component
|
||||||
import { useTranslation } from 'react-i18next';
|
*
|
||||||
|
* 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 { CloseButton, ActionIcon } from '@mantine/core';
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
|
||||||
import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal';
|
|
||||||
import ServerLicenseModal from '@app/components/onboarding/ServerLicenseModal';
|
|
||||||
import '@app/components/onboarding/OnboardingTour.css';
|
|
||||||
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
|
|
||||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
|
||||||
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
|
||||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
|
||||||
import { useOnboardingFlow } from '@app/components/onboarding/hooks/useOnboardingFlow';
|
|
||||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
|
||||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
|
||||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
|
||||||
import TourContent from '@app/components/onboarding/TourContent';
|
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import '@app/components/onboarding/OnboardingTour.css';
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
import i18n from "@app/i18n";
|
import type { TFunction } from 'i18next';
|
||||||
|
import i18n from '@app/i18n';
|
||||||
|
|
||||||
export default function OnboardingTour() {
|
/**
|
||||||
const { t } = useTranslation();
|
* TourContent - Controls the tour visibility
|
||||||
const flow = useOnboardingFlow();
|
* Syncs the forceOpen prop with the reactour tour state.
|
||||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
*/
|
||||||
const {
|
function TourContent({ forceOpen = false }: { forceOpen?: boolean }) {
|
||||||
saveWorkbenchState,
|
const { setIsOpen, setCurrentStep } = useTour();
|
||||||
restoreWorkbenchState,
|
const previousIsOpenRef = React.useRef(forceOpen);
|
||||||
backToAllTools,
|
|
||||||
selectCropTool,
|
|
||||||
loadSampleFile,
|
|
||||||
switchToViewer,
|
|
||||||
switchToPageEditor,
|
|
||||||
switchToActiveFiles,
|
|
||||||
selectFirstFile,
|
|
||||||
pinFile,
|
|
||||||
modifyCropSettings,
|
|
||||||
executeTool,
|
|
||||||
} = useTourOrchestration();
|
|
||||||
const {
|
|
||||||
saveAdminState,
|
|
||||||
restoreAdminState,
|
|
||||||
openConfigModal,
|
|
||||||
navigateToSection,
|
|
||||||
scrollNavToSection,
|
|
||||||
} = useAdminTourOrchestration();
|
|
||||||
|
|
||||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
React.useEffect(() => {
|
||||||
|
const wasClosedNowOpen = !previousIsOpenRef.current && forceOpen;
|
||||||
|
previousIsOpenRef.current = forceOpen;
|
||||||
|
|
||||||
useEffect(() => {
|
if (wasClosedNowOpen) {
|
||||||
if (!flow.isTourOpen) {
|
setCurrentStep(0);
|
||||||
removeAllGlows();
|
|
||||||
}
|
}
|
||||||
return () => removeAllGlows();
|
setIsOpen(forceOpen);
|
||||||
}, [flow.isTourOpen]);
|
}, [forceOpen, setIsOpen, setCurrentStep]);
|
||||||
|
|
||||||
const userStepsConfig = useMemo(
|
return null;
|
||||||
() =>
|
}
|
||||||
createUserStepsConfig({
|
|
||||||
t,
|
|
||||||
actions: {
|
|
||||||
saveWorkbenchState,
|
|
||||||
closeFilesModal,
|
|
||||||
backToAllTools,
|
|
||||||
selectCropTool,
|
|
||||||
loadSampleFile,
|
|
||||||
switchToViewer,
|
|
||||||
switchToPageEditor,
|
|
||||||
switchToActiveFiles,
|
|
||||||
selectFirstFile,
|
|
||||||
pinFile,
|
|
||||||
modifyCropSettings,
|
|
||||||
executeTool,
|
|
||||||
openFilesModal,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
t,
|
|
||||||
backToAllTools,
|
|
||||||
closeFilesModal,
|
|
||||||
executeTool,
|
|
||||||
loadSampleFile,
|
|
||||||
modifyCropSettings,
|
|
||||||
openFilesModal,
|
|
||||||
pinFile,
|
|
||||||
saveWorkbenchState,
|
|
||||||
selectCropTool,
|
|
||||||
selectFirstFile,
|
|
||||||
switchToActiveFiles,
|
|
||||||
switchToPageEditor,
|
|
||||||
switchToViewer,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const adminStepsConfig = useMemo(
|
interface AdvanceArgs {
|
||||||
() =>
|
setCurrentStep: (value: number | ((prev: number) => number)) => void;
|
||||||
createAdminStepsConfig({
|
currentStep: number;
|
||||||
t,
|
steps?: StepType[];
|
||||||
actions: {
|
setIsOpen: (value: boolean) => void;
|
||||||
saveAdminState,
|
}
|
||||||
openConfigModal,
|
|
||||||
navigateToSection,
|
|
||||||
scrollNavToSection,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const steps = useMemo<StepType[]>(() => {
|
interface CloseArgs {
|
||||||
const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
|
setIsOpen: (value: boolean) => void;
|
||||||
return Object.values(config);
|
}
|
||||||
}, [adminStepsConfig, flow.tourType, userStepsConfig]);
|
|
||||||
|
|
||||||
const advanceTour = ({
|
interface OnboardingTourProps {
|
||||||
setCurrentStep,
|
tourSteps: StepType[];
|
||||||
currentStep,
|
tourType: 'admin' | 'tools';
|
||||||
steps,
|
isRTL: boolean;
|
||||||
setIsOpen,
|
t: TFunction;
|
||||||
}: {
|
isOpen: boolean;
|
||||||
setCurrentStep: (value: number | ((prev: number) => number)) => void;
|
onAdvance: (args: AdvanceArgs) => void;
|
||||||
currentStep: number;
|
onClose: (args: CloseArgs) => void;
|
||||||
steps?: StepType[];
|
}
|
||||||
setIsOpen: (value: boolean) => void;
|
|
||||||
}) => {
|
|
||||||
if (steps && currentStep === steps.length - 1) {
|
|
||||||
setIsOpen(false);
|
|
||||||
if (flow.tourType === 'admin') {
|
|
||||||
restoreAdminState();
|
|
||||||
} else {
|
|
||||||
restoreWorkbenchState();
|
|
||||||
}
|
|
||||||
flow.handleTourCompletion();
|
|
||||||
} else if (steps) {
|
|
||||||
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
|
export default function OnboardingTour({
|
||||||
setIsOpen(false);
|
tourSteps,
|
||||||
if (flow.tourType === 'admin') {
|
tourType,
|
||||||
restoreAdminState();
|
isRTL,
|
||||||
} else {
|
t,
|
||||||
restoreWorkbenchState();
|
isOpen,
|
||||||
}
|
onAdvance,
|
||||||
flow.handleTourCompletion();
|
onClose,
|
||||||
};
|
}: OnboardingTourProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<TourProvider
|
||||||
<InitialOnboardingModal {...flow.initialModalProps} />
|
key={`${tourType}-${i18n.language}`}
|
||||||
<ToolPanelModePrompt onComplete={flow.handleToolPromptComplete} />
|
steps={tourSteps}
|
||||||
<TourProvider
|
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
|
||||||
key={`${flow.tourType}-${i18n.language}`}
|
onClickClose={onClose}
|
||||||
steps={steps}
|
onClickMask={onAdvance}
|
||||||
maskClassName={flow.maskClassName}
|
onClickHighlighted={(e, clickProps) => {
|
||||||
onClickClose={handleCloseTour}
|
e.stopPropagation();
|
||||||
onClickMask={advanceTour}
|
onAdvance(clickProps);
|
||||||
onClickHighlighted={(e, clickProps) => {
|
}}
|
||||||
e.stopPropagation();
|
keyboardHandler={(e, clickProps, status) => {
|
||||||
advanceTour(clickProps);
|
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
||||||
}}
|
e.preventDefault();
|
||||||
keyboardHandler={(e, clickProps, status) => {
|
onAdvance(clickProps);
|
||||||
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
advanceTour(clickProps);
|
onClose(clickProps);
|
||||||
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
}
|
||||||
e.preventDefault();
|
}}
|
||||||
handleCloseTour(clickProps);
|
rtl={isRTL}
|
||||||
}
|
styles={{
|
||||||
}}
|
popover: (base) => ({
|
||||||
rtl={isRTL}
|
...base,
|
||||||
styles={{
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
popover: (base) => ({
|
color: 'var(--mantine-color-text)',
|
||||||
...base,
|
borderRadius: '8px',
|
||||||
backgroundColor: 'var(--mantine-color-body)',
|
padding: '20px',
|
||||||
color: 'var(--mantine-color-text)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
borderRadius: '8px',
|
maxWidth: '400px',
|
||||||
padding: '20px',
|
}),
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
maskArea: (base) => ({
|
||||||
maxWidth: '400px',
|
...base,
|
||||||
}),
|
rx: 8,
|
||||||
maskArea: (base) => ({
|
}),
|
||||||
...base,
|
badge: (base) => ({
|
||||||
rx: 8,
|
...base,
|
||||||
}),
|
backgroundColor: 'var(--mantine-primary-color-filled)',
|
||||||
badge: (base) => ({
|
}),
|
||||||
...base,
|
controls: (base) => ({
|
||||||
backgroundColor: 'var(--mantine-primary-color-filled)',
|
...base,
|
||||||
}),
|
justifyContent: 'center',
|
||||||
controls: (base) => ({
|
}),
|
||||||
...base,
|
}}
|
||||||
justifyContent: 'center',
|
highlightedMaskClassName="tour-highlight-glow"
|
||||||
}),
|
showNavigation={true}
|
||||||
}}
|
showBadge={false}
|
||||||
highlightedMaskClassName="tour-highlight-glow"
|
showCloseButton={true}
|
||||||
showNavigation={true}
|
disableInteraction={true}
|
||||||
showBadge={false}
|
disableDotsNavigation={false}
|
||||||
showCloseButton={true}
|
prevButton={() => null}
|
||||||
disableInteraction={true}
|
nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
||||||
disableDotsNavigation={false}
|
const isLast = tourCurrentStep === stepsLength - 1;
|
||||||
prevButton={() => null}
|
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
|
||||||
nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
return (
|
||||||
const isLast = currentStep === stepsLength - 1;
|
<ActionIcon
|
||||||
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
|
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
|
||||||
return (
|
variant="subtle"
|
||||||
<ActionIcon
|
size="lg"
|
||||||
onClick={() => advanceTour({ setCurrentStep, currentStep, steps, setIsOpen })}
|
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
||||||
variant="subtle"
|
>
|
||||||
size="lg"
|
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
||||||
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
</ActionIcon>
|
||||||
>
|
);
|
||||||
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
}}
|
||||||
</ActionIcon>
|
components={{
|
||||||
);
|
Close: ({ onClick }) => (
|
||||||
}}
|
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
|
||||||
components={{
|
),
|
||||||
Close: ({ onClick }) => (
|
Content: ({ content }: { content: string }) => (
|
||||||
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
|
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||||
),
|
),
|
||||||
Content: ({ content }: { content: string }) => (
|
}}
|
||||||
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
|
>
|
||||||
),
|
<TourContent forceOpen={true} />
|
||||||
}}
|
</TourProvider>
|
||||||
>
|
|
||||||
<TourContent />
|
|
||||||
</TourProvider>
|
|
||||||
<ServerLicenseModal {...flow.serverLicenseModalProps} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { AdvanceArgs, CloseArgs };
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Modal, Button, Group, Stack } from '@mantine/core';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
|
||||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
|
||||||
import { LicenseNotice } from '@app/types/types';
|
|
||||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
|
||||||
import { BASE_PATH } from '@app/constants/app';
|
|
||||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
|
||||||
|
|
||||||
interface ServerLicenseModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSeePlans?: () => void;
|
|
||||||
licenseNotice: LicenseNotice;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ServerLicenseModal({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
onSeePlans,
|
|
||||||
licenseNotice,
|
|
||||||
}: ServerLicenseModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]);
|
|
||||||
const primaryLabel = licenseNotice.isOverLimit
|
|
||||||
? t('onboarding.serverLicense.upgrade', 'Upgrade now →')
|
|
||||||
: t('onboarding.serverLicense.seePlans', 'See Plans →');
|
|
||||||
const secondaryLabel = t('onboarding.serverLicense.skip', 'Skip for now');
|
|
||||||
|
|
||||||
const handleSeePlans = () => {
|
|
||||||
onSeePlans?.();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondaryStyles = {
|
|
||||||
root: {
|
|
||||||
background: 'var(--onboarding-secondary-button-bg)',
|
|
||||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
|
||||||
color: 'var(--onboarding-secondary-button-text)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const primaryStyles = {
|
|
||||||
root: {
|
|
||||||
background: 'var(--onboarding-primary-button-bg)',
|
|
||||||
color: 'var(--onboarding-primary-button-text)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
centered
|
|
||||||
size="lg"
|
|
||||||
radius="lg"
|
|
||||||
withCloseButton={false}
|
|
||||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
|
||||||
styles={{
|
|
||||||
body: { padding: 0 },
|
|
||||||
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap={0}>
|
|
||||||
<div className={styles.heroWrapper}>
|
|
||||||
<AnimatedSlideBackground
|
|
||||||
gradientStops={slide.background.gradientStops}
|
|
||||||
circles={slide.background.circles}
|
|
||||||
isActive
|
|
||||||
slideKey={slide.key}
|
|
||||||
/>
|
|
||||||
<div className={styles.heroLogo}>
|
|
||||||
<div className={styles.heroIconsContainer}>
|
|
||||||
<div className={styles.iconWrapper}>
|
|
||||||
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ padding: 24 }}>
|
|
||||||
<Stack gap={16}>
|
|
||||||
<div
|
|
||||||
className={styles.title}
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 22,
|
|
||||||
color: 'var(--onboarding-title)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{slide.title}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={styles.bodyCopy}
|
|
||||||
style={{
|
|
||||||
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
|
||||||
fontSize: 16,
|
|
||||||
color: 'var(--onboarding-body)',
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{slide.body}
|
|
||||||
</div>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Button styles={secondaryStyles} onClick={onClose}>
|
|
||||||
{secondaryLabel}
|
|
||||||
</Button>
|
|
||||||
<Button styles={primaryStyles} onClick={handleSeePlans}>
|
|
||||||
{primaryLabel}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTour } from '@reactour/tour';
|
|
||||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
|
||||||
|
|
||||||
export default function TourContent() {
|
|
||||||
const { isOpen } = useOnboarding();
|
|
||||||
const { setIsOpen, setCurrentStep } = useTour();
|
|
||||||
const previousIsOpenRef = React.useRef(isOpen);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
|
|
||||||
previousIsOpenRef.current = isOpen;
|
|
||||||
|
|
||||||
if (wasClosedNowOpen) {
|
|
||||||
setCurrentStep(0);
|
|
||||||
}
|
|
||||||
setIsOpen(isOpen);
|
|
||||||
}, [isOpen, setIsOpen, setCurrentStep]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { Modal, Title, Text, Button, Stack, Group } from '@mantine/core';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
|
||||||
|
|
||||||
interface TourWelcomeModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onStartTour: () => void;
|
|
||||||
onMaybeLater: () => void;
|
|
||||||
onDontShowAgain: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TourWelcomeModal({
|
|
||||||
opened,
|
|
||||||
onStartTour,
|
|
||||||
onMaybeLater,
|
|
||||||
onDontShowAgain,
|
|
||||||
}: TourWelcomeModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onMaybeLater}
|
|
||||||
centered
|
|
||||||
size="md"
|
|
||||||
radius="lg"
|
|
||||||
withCloseButton={false}
|
|
||||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
|
||||||
>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Title order={2}>
|
|
||||||
{t('onboarding.welcomeModal.title', 'Welcome to Stirling PDF!')}
|
|
||||||
</Title>
|
|
||||||
<Text size="md" c="dimmed">
|
|
||||||
{t('onboarding.welcomeModal.description',
|
|
||||||
"Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
size="md"
|
|
||||||
c="dimmed"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: t('onboarding.welcomeModal.helpHint',
|
|
||||||
'You can always access this tour later from the <strong>Help</strong> button in the bottom left.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Button
|
|
||||||
onClick={onStartTour}
|
|
||||||
size="md"
|
|
||||||
variant="filled"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{t('onboarding.welcomeModal.startTour', 'Start Tour')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Group grow>
|
|
||||||
<Button
|
|
||||||
onClick={onMaybeLater}
|
|
||||||
size="md"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
{t('onboarding.welcomeModal.maybeLater', 'Maybe Later')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={onDontShowAgain}
|
|
||||||
size="md"
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
{t('onboarding.welcomeModal.dontShowAgain', "Don't Show Again")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import type { StepType } from '@reactour/tour';
|
import type { StepType } from '@reactour/tour';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { AdminTourStep } from '@app/components/onboarding/tourSteps';
|
|
||||||
import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
|
import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||||
|
|
||||||
|
export enum AdminTourStep {
|
||||||
|
WELCOME,
|
||||||
|
CONFIG_BUTTON,
|
||||||
|
SETTINGS_OVERVIEW,
|
||||||
|
TEAMS_AND_USERS,
|
||||||
|
SYSTEM_CUSTOMIZATION,
|
||||||
|
DATABASE_SECTION,
|
||||||
|
CONNECTIONS_SECTION,
|
||||||
|
ADMIN_TOOLS,
|
||||||
|
WRAP_UP,
|
||||||
|
}
|
||||||
|
|
||||||
interface AdminStepActions {
|
interface AdminStepActions {
|
||||||
saveAdminState: () => void;
|
saveAdminState: () => void;
|
||||||
openConfigModal: () => void;
|
openConfigModal: () => void;
|
||||||
|
|||||||
@@ -1,304 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
|
||||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
|
||||||
import type { LicenseNotice } from '@app/types/types';
|
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
ONBOARDING_SESSION_BLOCK_KEY,
|
|
||||||
ONBOARDING_SESSION_EVENT,
|
|
||||||
SERVER_LICENSE_REQUEST_EVENT,
|
|
||||||
type ServerLicenseRequestPayload,
|
|
||||||
} from '@app/constants/events';
|
|
||||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
|
||||||
|
|
||||||
// Auth routes where onboarding should NOT show
|
|
||||||
const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite'];
|
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InitialModalHandlers {
|
|
||||||
opened: boolean;
|
|
||||||
onLicenseNoticeUpdate: (notice: LicenseNotice) => void;
|
|
||||||
onRequestServerLicense: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerLicenseModalHandlers {
|
|
||||||
opened: boolean;
|
|
||||||
licenseNotice: LicenseNotice;
|
|
||||||
onClose: () => void;
|
|
||||||
onSeePlans: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOnboardingFlow() {
|
|
||||||
const { preferences, updatePreference } = usePreferences();
|
|
||||||
const { config, loading: configLoading } = useAppConfig();
|
|
||||||
const { completeTour, tourType, isOpen } = useOnboarding();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// Check if we're on an auth route (login, signup, etc.)
|
|
||||||
const isOnAuthRoute = AUTH_ROUTES.some(route => location.pathname.startsWith(route));
|
|
||||||
|
|
||||||
// Check if login is enabled but user doesn't have a token
|
|
||||||
// This prevents a flash of the modal before redirect to /login
|
|
||||||
const loginEnabled = config?.enableLogin === true;
|
|
||||||
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
|
|
||||||
|
|
||||||
// Don't show intro onboarding:
|
|
||||||
// 1. On explicit auth routes (/login, /signup, etc.)
|
|
||||||
// 2. While config is still loading
|
|
||||||
// 3. When login is enabled but user isn't authenticated (would redirect to /login)
|
|
||||||
// This ensures:
|
|
||||||
// - If login is enabled: user must be logged in before seeing onboarding
|
|
||||||
// - If login is disabled: homepage must have rendered first
|
|
||||||
const shouldShowIntro = !preferences.hasSeenIntroOnboarding
|
|
||||||
&& !isOnAuthRoute
|
|
||||||
&& !configLoading
|
|
||||||
&& !isUnauthenticatedWithLoginEnabled;
|
|
||||||
const isAdminUser = !!config?.isAdmin;
|
|
||||||
const { hasPaidLicense } = useServerExperience();
|
|
||||||
|
|
||||||
const [licenseNotice, setLicenseNotice] = useState<LicenseNotice>({
|
|
||||||
totalUsers: null,
|
|
||||||
freeTierLimit: 5,
|
|
||||||
isOverLimit: false,
|
|
||||||
requiresLicense: false,
|
|
||||||
});
|
|
||||||
const [serverLicenseIntent, setServerLicenseIntent] = useState<'idle' | 'pending' | 'deferred'>('idle');
|
|
||||||
const [serverLicenseSource, setServerLicenseSource] = useState<'config' | 'self-reported' | null>(null);
|
|
||||||
const [isServerLicenseOpen, setIsServerLicenseOpen] = useState(false);
|
|
||||||
const [hasShownServerLicense, setHasShownServerLicense] = useState(false);
|
|
||||||
const [toolPromptCompleted, setToolPromptCompleted] = useState(
|
|
||||||
preferences.toolPanelModePromptSeen || preferences.hasSelectedToolPanelMode,
|
|
||||||
);
|
|
||||||
const introWasOpenRef = useRef(false);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const onboardingSessionMarkedRef = useRef(false);
|
|
||||||
|
|
||||||
const handleInitialModalClose = useCallback(() => {
|
|
||||||
if (!preferences.hasSeenIntroOnboarding) {
|
|
||||||
updatePreference('hasSeenIntroOnboarding', true);
|
|
||||||
}
|
|
||||||
}, [preferences.hasSeenIntroOnboarding, updatePreference]);
|
|
||||||
|
|
||||||
const handleLicenseNoticeUpdate = useCallback((notice: LicenseNotice) => {
|
|
||||||
setLicenseNotice(notice);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToolPromptComplete = useCallback(() => {
|
|
||||||
setToolPromptCompleted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestServerLicense = useCallback(
|
|
||||||
({
|
|
||||||
deferUntilTourComplete = false,
|
|
||||||
selfReportedAdmin = false,
|
|
||||||
}: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean } = {}) => {
|
|
||||||
const qualifies = isAdminUser || selfReportedAdmin;
|
|
||||||
if (!qualifies) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hasPaidLicense || !licenseNotice.requiresLicense) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
|
|
||||||
setServerLicenseIntent((prev) => {
|
|
||||||
if (prev === 'pending') {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
if (prev === 'deferred') {
|
|
||||||
return deferUntilTourComplete ? prev : 'pending';
|
|
||||||
}
|
|
||||||
if (prev === 'idle') {
|
|
||||||
return deferUntilTourComplete ? 'deferred' : 'pending';
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[hasPaidLicense, isAdminUser, licenseNotice.requiresLicense],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleServerLicenseRequested = (event: Event) => {
|
|
||||||
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
|
|
||||||
|
|
||||||
if (detail?.licenseNotice) {
|
|
||||||
setLicenseNotice((prev) => ({
|
|
||||||
...prev,
|
|
||||||
...detail.licenseNotice,
|
|
||||||
totalUsers:
|
|
||||||
detail.licenseNotice?.totalUsers ?? prev.totalUsers,
|
|
||||||
freeTierLimit:
|
|
||||||
detail.licenseNotice?.freeTierLimit ?? prev.freeTierLimit,
|
|
||||||
isOverLimit:
|
|
||||||
detail.licenseNotice?.isOverLimit ?? prev.isOverLimit,
|
|
||||||
requiresLicense:
|
|
||||||
detail.licenseNotice?.requiresLicense ?? prev.requiresLicense,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
requestServerLicense({
|
|
||||||
deferUntilTourComplete: detail?.deferUntilTourComplete ?? false,
|
|
||||||
selfReportedAdmin: detail?.selfReportedAdmin ?? false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener(
|
|
||||||
SERVER_LICENSE_REQUEST_EVENT,
|
|
||||||
handleServerLicenseRequested as EventListener,
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(
|
|
||||||
SERVER_LICENSE_REQUEST_EVENT,
|
|
||||||
handleServerLicenseRequested as EventListener,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, [requestServerLicense]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isEligibleAdmin =
|
|
||||||
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
|
|
||||||
if (
|
|
||||||
introWasOpenRef.current &&
|
|
||||||
!shouldShowIntro &&
|
|
||||||
isEligibleAdmin &&
|
|
||||||
toolPromptCompleted &&
|
|
||||||
!hasShownServerLicense &&
|
|
||||||
licenseNotice.requiresLicense &&
|
|
||||||
serverLicenseIntent === 'idle'
|
|
||||||
) {
|
|
||||||
if (!serverLicenseSource) {
|
|
||||||
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
|
|
||||||
}
|
|
||||||
setServerLicenseIntent('pending');
|
|
||||||
}
|
|
||||||
introWasOpenRef.current = shouldShowIntro;
|
|
||||||
}, [
|
|
||||||
hasShownServerLicense,
|
|
||||||
isAdminUser,
|
|
||||||
serverLicenseIntent,
|
|
||||||
shouldShowIntro,
|
|
||||||
serverLicenseSource,
|
|
||||||
toolPromptCompleted,
|
|
||||||
licenseNotice.requiresLicense,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isEligibleAdmin =
|
|
||||||
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
|
|
||||||
if (
|
|
||||||
serverLicenseIntent !== 'idle' &&
|
|
||||||
!shouldShowIntro &&
|
|
||||||
!isOpen &&
|
|
||||||
!isServerLicenseOpen &&
|
|
||||||
isEligibleAdmin &&
|
|
||||||
toolPromptCompleted &&
|
|
||||||
licenseNotice.requiresLicense
|
|
||||||
) {
|
|
||||||
setIsServerLicenseOpen(true);
|
|
||||||
setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle');
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isAdminUser,
|
|
||||||
isOpen,
|
|
||||||
isServerLicenseOpen,
|
|
||||||
serverLicenseIntent,
|
|
||||||
shouldShowIntro,
|
|
||||||
serverLicenseSource,
|
|
||||||
toolPromptCompleted,
|
|
||||||
licenseNotice.requiresLicense,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleServerLicenseClose = useCallback(() => {
|
|
||||||
setIsServerLicenseOpen(false);
|
|
||||||
setHasShownServerLicense(true);
|
|
||||||
setServerLicenseIntent('idle');
|
|
||||||
setServerLicenseSource(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (onboardingSessionMarkedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (shouldShowIntro || isOpen) {
|
|
||||||
onboardingSessionMarkedRef.current = true;
|
|
||||||
window.sessionStorage.setItem(ONBOARDING_SESSION_BLOCK_KEY, 'true');
|
|
||||||
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
|
|
||||||
}
|
|
||||||
}, [isOpen, shouldShowIntro]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!shouldShowIntro && !isOpen) {
|
|
||||||
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
|
|
||||||
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
|
|
||||||
}
|
|
||||||
}, [isOpen, shouldShowIntro]);
|
|
||||||
|
|
||||||
const handleServerLicenseSeePlans = useCallback(() => {
|
|
||||||
handleServerLicenseClose();
|
|
||||||
navigate('/settings/adminPlan');
|
|
||||||
}, [handleServerLicenseClose, navigate]);
|
|
||||||
|
|
||||||
const handleTourCompletion = useCallback(() => {
|
|
||||||
completeTour();
|
|
||||||
if (serverLicenseIntent === 'deferred') {
|
|
||||||
setServerLicenseIntent('pending');
|
|
||||||
} else if (tourType === 'admin' && (isAdminUser || serverLicenseSource === 'self-reported')) {
|
|
||||||
setServerLicenseSource((prev) => prev ?? (isAdminUser ? 'config' : 'self-reported'));
|
|
||||||
setServerLicenseIntent((prev) => (prev === 'pending' ? prev : 'pending'));
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
completeTour,
|
|
||||||
isAdminUser,
|
|
||||||
serverLicenseIntent,
|
|
||||||
serverLicenseSource,
|
|
||||||
tourType,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const initialModalProps: InitialModalHandlers = useMemo(
|
|
||||||
() => ({
|
|
||||||
opened: shouldShowIntro,
|
|
||||||
onLicenseNoticeUpdate: handleLicenseNoticeUpdate,
|
|
||||||
onRequestServerLicense: requestServerLicense,
|
|
||||||
onClose: handleInitialModalClose,
|
|
||||||
}),
|
|
||||||
[handleInitialModalClose, handleLicenseNoticeUpdate, requestServerLicense, shouldShowIntro],
|
|
||||||
);
|
|
||||||
|
|
||||||
const serverLicenseModalProps: ServerLicenseModalHandlers = useMemo(
|
|
||||||
() => ({
|
|
||||||
opened: isServerLicenseOpen,
|
|
||||||
licenseNotice,
|
|
||||||
onClose: handleServerLicenseClose,
|
|
||||||
onSeePlans: handleServerLicenseSeePlans,
|
|
||||||
}),
|
|
||||||
[handleServerLicenseClose, handleServerLicenseSeePlans, isServerLicenseOpen, licenseNotice],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
tourType,
|
|
||||||
isTourOpen: isOpen,
|
|
||||||
maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined,
|
|
||||||
initialModalProps,
|
|
||||||
handleToolPromptComplete,
|
|
||||||
serverLicenseModalProps,
|
|
||||||
handleTourCompletion,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -3,16 +3,18 @@ import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstal
|
|||||||
import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
|
import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
|
||||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
||||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||||
|
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
|
||||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||||
|
|
||||||
export type SlideId =
|
export type SlideId =
|
||||||
|
| 'first-login'
|
||||||
| 'welcome'
|
| 'welcome'
|
||||||
| 'desktop-install'
|
| 'desktop-install'
|
||||||
| 'security-check'
|
| 'security-check'
|
||||||
| 'admin-overview'
|
| 'admin-overview'
|
||||||
| 'server-license';
|
| 'server-license';
|
||||||
|
|
||||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo';
|
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
|
||||||
|
|
||||||
export type ButtonAction =
|
export type ButtonAction =
|
||||||
| 'next'
|
| 'next'
|
||||||
@@ -46,6 +48,10 @@ export interface SlideFactoryParams {
|
|||||||
onRoleSelect: (role: 'admin' | 'user' | null) => void;
|
onRoleSelect: (role: 'admin' | 'user' | null) => void;
|
||||||
licenseNotice?: LicenseNotice;
|
licenseNotice?: LicenseNotice;
|
||||||
loginEnabled?: boolean;
|
loginEnabled?: boolean;
|
||||||
|
// First login params
|
||||||
|
firstLoginUsername?: string;
|
||||||
|
onPasswordChanged?: () => void;
|
||||||
|
usingDefaultCredentials?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HeroDefinition {
|
export interface HeroDefinition {
|
||||||
@@ -71,6 +77,17 @@ export interface SlideDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||||
|
'first-login': {
|
||||||
|
id: 'first-login',
|
||||||
|
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||||
|
FirstLoginSlide({
|
||||||
|
username: firstLoginUsername || '',
|
||||||
|
onPasswordChanged: onPasswordChanged || (() => {}),
|
||||||
|
usingDefaultCredentials: usingDefaultCredentials || false,
|
||||||
|
}),
|
||||||
|
hero: { type: 'lock' },
|
||||||
|
buttons: [], // Form has its own submit button
|
||||||
|
},
|
||||||
'welcome': {
|
'welcome': {
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
createSlide: () => WelcomeSlide(),
|
createSlide: () => WelcomeSlide(),
|
||||||
@@ -197,11 +214,3 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FLOW_SEQUENCES = {
|
|
||||||
loginAdmin: ['welcome', 'desktop-install', 'admin-overview'] as SlideId[],
|
|
||||||
loginUser: ['welcome', 'desktop-install'] as SlideId[],
|
|
||||||
noLoginBase: ['welcome', 'desktop-install', 'security-check'] as SlideId[],
|
|
||||||
noLoginAdmin: ['admin-overview'] as SlideId[],
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
export type OnboardingStepId =
|
||||||
|
| 'first-login'
|
||||||
|
| 'welcome'
|
||||||
|
| 'desktop-install'
|
||||||
|
| 'security-check'
|
||||||
|
| 'admin-overview'
|
||||||
|
| 'tool-layout'
|
||||||
|
| 'tour'
|
||||||
|
| 'server-license'
|
||||||
|
| 'analytics-choice';
|
||||||
|
|
||||||
|
export type OnboardingStepType =
|
||||||
|
| 'modal-slide'
|
||||||
|
| 'tool-prompt'
|
||||||
|
| 'tour'
|
||||||
|
| 'analytics-modal';
|
||||||
|
|
||||||
|
export interface OnboardingRuntimeState {
|
||||||
|
selectedRole: 'admin' | 'user' | null;
|
||||||
|
tourRequested: boolean;
|
||||||
|
tourType: 'admin' | 'tools';
|
||||||
|
isDesktopApp: boolean;
|
||||||
|
analyticsNotConfigured: boolean;
|
||||||
|
analyticsEnabled: boolean;
|
||||||
|
licenseNotice: {
|
||||||
|
totalUsers: number | null;
|
||||||
|
freeTierLimit: number;
|
||||||
|
isOverLimit: boolean;
|
||||||
|
requiresLicense: boolean;
|
||||||
|
};
|
||||||
|
requiresPasswordChange: boolean;
|
||||||
|
firstLoginUsername: string;
|
||||||
|
usingDefaultCredentials: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingConditionContext extends OnboardingRuntimeState {
|
||||||
|
loginEnabled: boolean;
|
||||||
|
effectiveIsAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingStep {
|
||||||
|
id: OnboardingStepId;
|
||||||
|
type: OnboardingStepType;
|
||||||
|
condition: (ctx: OnboardingConditionContext) => boolean;
|
||||||
|
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||||
|
selectedRole: null,
|
||||||
|
tourRequested: false,
|
||||||
|
tourType: 'tools',
|
||||||
|
isDesktopApp: false,
|
||||||
|
analyticsNotConfigured: false,
|
||||||
|
analyticsEnabled: false,
|
||||||
|
licenseNotice: {
|
||||||
|
totalUsers: null,
|
||||||
|
freeTierLimit: 5,
|
||||||
|
isOverLimit: false,
|
||||||
|
requiresLicense: false,
|
||||||
|
},
|
||||||
|
requiresPasswordChange: false,
|
||||||
|
firstLoginUsername: '',
|
||||||
|
usingDefaultCredentials: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||||
|
{
|
||||||
|
id: 'first-login',
|
||||||
|
type: 'modal-slide',
|
||||||
|
slideId: 'first-login',
|
||||||
|
condition: (ctx) => ctx.requiresPasswordChange,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'welcome',
|
||||||
|
type: 'modal-slide',
|
||||||
|
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',
|
||||||
|
slideId: 'admin-overview',
|
||||||
|
condition: (ctx) => ctx.effectiveIsAdmin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool-layout',
|
||||||
|
type: 'tool-prompt',
|
||||||
|
condition: () => true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tour',
|
||||||
|
type: 'tour',
|
||||||
|
condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'server-license',
|
||||||
|
type: 'modal-slide',
|
||||||
|
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 {
|
||||||
|
return ONBOARDING_STEPS.find((step) => step.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStepIndex(id: OnboardingStepId): number {
|
||||||
|
return ONBOARDING_STEPS.findIndex((step) => step.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'onboarding';
|
||||||
|
|
||||||
|
export function getStorageKey(stepId: OnboardingStepId): string {
|
||||||
|
return `${STORAGE_PREFIX}::${stepId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSeenStep(stepId: OnboardingStepId): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(getStorageKey(stepId)) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markStepSeen(stepId: OnboardingStepId): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(getStorageKey(stepId), 'true');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[onboardingStorage] Error marking step as seen:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetStepSeen(stepId: OnboardingStepId): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(getStorageKey(stepId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[onboardingStorage] Error resetting step seen:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAllOnboardingProgress(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 (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 {
|
||||||
|
// If migration fails, onboarding will show again - safer than hiding it
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
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,
|
||||||
|
type OnboardingStepId,
|
||||||
|
type OnboardingStep,
|
||||||
|
type OnboardingRuntimeState,
|
||||||
|
type OnboardingConditionContext,
|
||||||
|
DEFAULT_RUNTIME_STATE,
|
||||||
|
} from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||||
|
import {
|
||||||
|
hasSeenStep,
|
||||||
|
markStepSeen,
|
||||||
|
migrateFromLegacyPreferences,
|
||||||
|
} from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||||
|
import { accountService } from '@app/services/accountService';
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial runtime state from session storage (survives remounts)
|
||||||
|
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
|
||||||
|
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 selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseState,
|
||||||
|
tourRequested,
|
||||||
|
tourType,
|
||||||
|
selectedRole,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return baseState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
if (state.tourType !== undefined) {
|
||||||
|
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
|
||||||
|
}
|
||||||
|
if (state.selectedRole !== undefined) {
|
||||||
|
if (state.selectedRole) {
|
||||||
|
sessionStorage.setItem(SESSION_SELECTED_ROLE, state.selectedRole);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(SESSION_SELECTED_ROLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useOnboardingOrchestrator] Error persisting runtime state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRuntimeStateSession(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
|
||||||
|
sessionStorage.removeItem(SESSION_TOUR_TYPE);
|
||||||
|
sessionStorage.removeItem(SESSION_SELECTED_ROLE);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingOrchestratorState {
|
||||||
|
/** Whether onboarding is currently active */
|
||||||
|
isActive: boolean;
|
||||||
|
/** The current step being shown (null if no step is active) */
|
||||||
|
currentStep: OnboardingStep | null;
|
||||||
|
/** Index of current step in the active flow (for display purposes) */
|
||||||
|
currentStepIndex: number;
|
||||||
|
/** Total number of steps in the active flow */
|
||||||
|
totalSteps: number;
|
||||||
|
/** Runtime state that affects conditions */
|
||||||
|
runtimeState: OnboardingRuntimeState;
|
||||||
|
/** All steps that will be shown in this flow (filtered by conditions) */
|
||||||
|
activeFlow: OnboardingStep[];
|
||||||
|
/** Whether all steps have been seen */
|
||||||
|
isComplete: boolean;
|
||||||
|
/** Whether we're still initializing */
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingOrchestratorActions {
|
||||||
|
/** Move to the next step */
|
||||||
|
next: () => void;
|
||||||
|
/** Move to the previous step */
|
||||||
|
prev: () => void;
|
||||||
|
/** Skip the current step (marks as seen but doesn't complete) */
|
||||||
|
skip: () => void;
|
||||||
|
/** Mark current step as seen and move to next */
|
||||||
|
complete: () => void;
|
||||||
|
/** Update runtime state (e.g., after role selection) */
|
||||||
|
updateRuntimeState: (updates: Partial<OnboardingRuntimeState>) => void;
|
||||||
|
/** Force re-evaluation of the flow (used when conditions change) */
|
||||||
|
refreshFlow: () => void;
|
||||||
|
/** Manually start a specific step (for external triggers) */
|
||||||
|
startStep: (stepId: OnboardingStepId) => void;
|
||||||
|
/** Close/pause onboarding (can be resumed later) */
|
||||||
|
pause: () => void;
|
||||||
|
/** Resume onboarding from where it was paused */
|
||||||
|
resume: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseOnboardingOrchestratorResult {
|
||||||
|
state: OnboardingOrchestratorState;
|
||||||
|
actions: OnboardingOrchestratorActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseOnboardingOrchestratorOptions {
|
||||||
|
/** Override the default runtime state (used by desktop to set isDesktopApp: true) */
|
||||||
|
defaultRuntimeState?: OnboardingRuntimeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||||
|
getInitialRuntimeState(defaultState)
|
||||||
|
);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
||||||
|
const migrationDone = useRef(false);
|
||||||
|
const initialIndexSet = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!migrationDone.current) {
|
||||||
|
migrateFromLegacyPreferences();
|
||||||
|
migrationDone.current = true;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRuntimeState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
analyticsEnabled: config?.enableAnalytics === true,
|
||||||
|
analyticsNotConfigured: config?.enableAnalytics == null,
|
||||||
|
licenseNotice: {
|
||||||
|
totalUsers: serverExperience.totalUsers,
|
||||||
|
freeTierLimit: serverExperience.freeTierLimit,
|
||||||
|
isOverLimit: serverExperience.overFreeTierLimit ?? false,
|
||||||
|
requiresLicense: !serverExperience.hasPaidLicense && (
|
||||||
|
serverExperience.overFreeTierLimit === true ||
|
||||||
|
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [
|
||||||
|
config?.enableAnalytics,
|
||||||
|
serverExperience.totalUsers,
|
||||||
|
serverExperience.freeTierLimit,
|
||||||
|
serverExperience.overFreeTierLimit,
|
||||||
|
serverExperience.hasPaidLicense,
|
||||||
|
serverExperience.effectiveIsAdmin,
|
||||||
|
serverExperience.userCountResolved,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkFirstLogin = async () => {
|
||||||
|
if (config?.enableLogin !== true || !hasAuthToken()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [accountData, loginPageData] = await Promise.all([
|
||||||
|
accountService.getAccountData(),
|
||||||
|
accountService.getLoginPageData(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setRuntimeState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
requiresPasswordChange: accountData.changeCredsFlag,
|
||||||
|
firstLoginUsername: accountData.username,
|
||||||
|
usingDefaultCredentials: loginPageData.showDefaultCredentials,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Account endpoint failed - user not logged in or security disabled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!configLoading) {
|
||||||
|
checkFirstLogin();
|
||||||
|
}
|
||||||
|
}, [config?.enableLogin, configLoading]);
|
||||||
|
|
||||||
|
const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
|
||||||
|
const loginEnabled = config?.enableLogin === true;
|
||||||
|
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
|
||||||
|
const shouldBlockOnboarding = isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
|
||||||
|
|
||||||
|
let firstUnseenIndex = -1;
|
||||||
|
for (let i = 0; i < activeFlow.length; i++) {
|
||||||
|
if (!hasSeenStep(activeFlow[i].id)) {
|
||||||
|
firstUnseenIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstUnseenIndex === -1) {
|
||||||
|
setCurrentStepIndex(activeFlow.length);
|
||||||
|
initialIndexSet.current = true;
|
||||||
|
} else if (!initialIndexSet.current) {
|
||||||
|
setCurrentStepIndex(firstUnseenIndex);
|
||||||
|
initialIndexSet.current = true;
|
||||||
|
}
|
||||||
|
}, [activeFlow, configLoading, adminStatusResolved]);
|
||||||
|
|
||||||
|
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]
|
||||||
|
: null;
|
||||||
|
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
|
||||||
|
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
|
||||||
|
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!configLoading && !isInitialized) setIsInitialized(true);
|
||||||
|
}, [configLoading, isInitialized]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isComplete) clearRuntimeStateSession();
|
||||||
|
}, [isComplete]);
|
||||||
|
|
||||||
|
const next = useCallback(() => {
|
||||||
|
if (currentStep) markStepSeen(currentStep.id);
|
||||||
|
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||||
|
}, [currentStep, 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]);
|
||||||
|
|
||||||
|
const complete = useCallback(() => {
|
||||||
|
if (currentStep) markStepSeen(currentStep.id);
|
||||||
|
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||||
|
}, [currentStep, totalSteps]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentStep || isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasSeenStep(currentStep.id)) {
|
||||||
|
complete();
|
||||||
|
}
|
||||||
|
}, [currentStep, isLoading, complete]);
|
||||||
|
|
||||||
|
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
||||||
|
persistRuntimeState(updates);
|
||||||
|
setRuntimeState((prev) => ({ ...prev, ...updates }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshFlow = useCallback(() => {
|
||||||
|
initialIndexSet.current = false;
|
||||||
|
setCurrentStepIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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), []);
|
||||||
|
|
||||||
|
const state: OnboardingOrchestratorState = {
|
||||||
|
isActive,
|
||||||
|
currentStep,
|
||||||
|
currentStepIndex,
|
||||||
|
totalSteps,
|
||||||
|
runtimeState,
|
||||||
|
activeFlow,
|
||||||
|
isComplete,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions: OnboardingOrchestratorActions = {
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
skip,
|
||||||
|
complete,
|
||||||
|
updateRuntimeState,
|
||||||
|
refreshFlow,
|
||||||
|
startStep,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { state, actions };
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
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;
|
||||||
|
onPasswordChanged: () => void;
|
||||||
|
usingDefaultCredentials?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Validation
|
||||||
|
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
|
||||||
|
setError(t('firstLogin.allFieldsRequired', 'All fields are required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
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'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword === currentPassword) {
|
||||||
|
setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
await accountService.changePasswordOnLogin(currentPassword, newPassword);
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
alertType: 'success',
|
||||||
|
title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear form
|
||||||
|
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);
|
||||||
|
// 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.')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.securitySlideContent}>
|
||||||
|
<div className={styles.securityCard}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<div className={styles.securityAlertRow}>
|
||||||
|
<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.'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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')}
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
styles={{
|
||||||
|
input: { height: 44 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label={t('firstLogin.newPassword', 'New Password')}
|
||||||
|
placeholder={t('firstLogin.enterNewPassword', 'Enter new password (min 8 characters)')}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
styles={{
|
||||||
|
input: { height: 44 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label={t('firstLogin.confirmPassword', 'Confirm New Password')}
|
||||||
|
placeholder={t('firstLogin.reEnterNewPassword', 'Re-enter new password')}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
styles={{
|
||||||
|
input: { height: 44 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!newPassword || !confirmPassword}
|
||||||
|
size="md"
|
||||||
|
mt="xs"
|
||||||
|
>
|
||||||
|
{t('firstLogin.changePassword', 'Change Password')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FirstLoginSlide({
|
||||||
|
username,
|
||||||
|
onPasswordChanged,
|
||||||
|
usingDefaultCredentials = false,
|
||||||
|
}: FirstLoginSlideProps): SlideConfig {
|
||||||
|
return {
|
||||||
|
key: 'first-login',
|
||||||
|
title: 'Set Your Password',
|
||||||
|
body: (
|
||||||
|
<FirstLoginForm
|
||||||
|
username={username}
|
||||||
|
onPasswordChanged={onPasswordChanged}
|
||||||
|
usingDefaultCredentials={usingDefaultCredentials}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
background: {
|
||||||
|
gradientStops: ['#059669', '#0891B2'], // Green to teal - security/trust colors
|
||||||
|
circles: UNIFIED_CIRCLE_CONFIG,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
export enum TourStep {
|
|
||||||
ALL_TOOLS,
|
|
||||||
SELECT_CROP_TOOL,
|
|
||||||
TOOL_INTERFACE,
|
|
||||||
FILES_BUTTON,
|
|
||||||
FILE_SOURCES,
|
|
||||||
WORKBENCH,
|
|
||||||
VIEW_SWITCHER,
|
|
||||||
VIEWER,
|
|
||||||
PAGE_EDITOR,
|
|
||||||
ACTIVE_FILES,
|
|
||||||
FILE_CHECKBOX,
|
|
||||||
SELECT_CONTROLS,
|
|
||||||
CROP_SETTINGS,
|
|
||||||
RUN_BUTTON,
|
|
||||||
RESULTS,
|
|
||||||
FILE_REPLACEMENT,
|
|
||||||
PIN_BUTTON,
|
|
||||||
WRAP_UP,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AdminTourStep {
|
|
||||||
WELCOME,
|
|
||||||
CONFIG_BUTTON,
|
|
||||||
SETTINGS_OVERVIEW,
|
|
||||||
TEAMS_AND_USERS,
|
|
||||||
SYSTEM_CUSTOMIZATION,
|
|
||||||
DATABASE_SECTION,
|
|
||||||
CONNECTIONS_SECTION,
|
|
||||||
ADMIN_TOOLS,
|
|
||||||
WRAP_UP,
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
interface OsInfo {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OsOption {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseOnboardingDownloadResult {
|
||||||
|
osInfo: OsInfo;
|
||||||
|
osOptions: OsOption[];
|
||||||
|
selectedDownloadUrl: string;
|
||||||
|
setSelectedDownloadUrl: (url: string) => void;
|
||||||
|
handleDownloadSelected: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
||||||
|
const osType = useOs();
|
||||||
|
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 };
|
||||||
|
default:
|
||||||
|
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), []);
|
||||||
|
|
||||||
|
// Initialize selected URL from detected OS
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDownloadUrl && osInfo.url) {
|
||||||
|
setSelectedDownloadUrl(osInfo.url);
|
||||||
|
}
|
||||||
|
}, [osInfo.url, selectedDownloadUrl]);
|
||||||
|
|
||||||
|
const handleDownloadSelected = useCallback(() => {
|
||||||
|
const downloadUrl = selectedDownloadUrl || osInfo.url;
|
||||||
|
if (downloadUrl) {
|
||||||
|
window.open(downloadUrl, '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
}, [selectedDownloadUrl, osInfo.url]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
osInfo,
|
||||||
|
osOptions,
|
||||||
|
selectedDownloadUrl,
|
||||||
|
setSelectedDownloadUrl,
|
||||||
|
handleDownloadSelected,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export function useServerLicenseRequest(): {
|
||||||
|
showLicenseSlide: boolean;
|
||||||
|
licenseNotice: OnboardingRuntimeState['licenseNotice'] | null;
|
||||||
|
closeLicenseSlide: () => void;
|
||||||
|
} {
|
||||||
|
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
|
||||||
|
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState['licenseNotice'] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const handleLicenseRequest = (event: Event) => {
|
||||||
|
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
|
||||||
|
|
||||||
|
if (detail?.licenseNotice) {
|
||||||
|
setLicenseNotice({
|
||||||
|
totalUsers: detail.licenseNotice.totalUsers ?? null,
|
||||||
|
freeTierLimit: detail.licenseNotice.freeTierLimit ?? 5,
|
||||||
|
isOverLimit: detail.licenseNotice.isOverLimit ?? false,
|
||||||
|
requiresLicense: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowLicenseSlide(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
|
||||||
|
return () => window.removeEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeLicenseSlide = useCallback(() => {
|
||||||
|
setShowLicenseSlide(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { showLicenseSlide, licenseNotice, closeLicenseSlide };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTourRequest(): {
|
||||||
|
tourRequested: boolean;
|
||||||
|
requestedTourType: TourType;
|
||||||
|
clearTourRequest: () => void;
|
||||||
|
} {
|
||||||
|
const [tourRequested, setTourRequested] = useState(false);
|
||||||
|
const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const handleTourRequest = (event: Event) => {
|
||||||
|
const { detail } = event as CustomEvent<StartTourPayload>;
|
||||||
|
setRequestedTourType(detail?.tourType ?? 'tools');
|
||||||
|
setTourRequested(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||||
|
return () => window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearTourRequest = useCallback(() => {
|
||||||
|
setTourRequested(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { tourRequested, requestedTourType, clearTourRequest };
|
||||||
|
}
|
||||||
@@ -1,6 +1,26 @@
|
|||||||
import type { StepType } from '@reactour/tour';
|
import type { StepType } from '@reactour/tour';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { TourStep } from '@app/components/onboarding/tourSteps';
|
|
||||||
|
export enum TourStep {
|
||||||
|
ALL_TOOLS,
|
||||||
|
SELECT_CROP_TOOL,
|
||||||
|
TOOL_INTERFACE,
|
||||||
|
FILES_BUTTON,
|
||||||
|
FILE_SOURCES,
|
||||||
|
WORKBENCH,
|
||||||
|
VIEW_SWITCHER,
|
||||||
|
VIEWER,
|
||||||
|
PAGE_EDITOR,
|
||||||
|
ACTIVE_FILES,
|
||||||
|
FILE_CHECKBOX,
|
||||||
|
SELECT_CONTROLS,
|
||||||
|
CROP_SETTINGS,
|
||||||
|
RUN_BUTTON,
|
||||||
|
RESULTS,
|
||||||
|
FILE_REPLACEMENT,
|
||||||
|
PIN_BUTTON,
|
||||||
|
WRAP_UP,
|
||||||
|
}
|
||||||
|
|
||||||
interface UserStepActions {
|
interface UserStepActions {
|
||||||
saveWorkbenchState: () => void;
|
saveWorkbenchState: () => void;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
|
|||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
import '@app/components/shared/AppConfigModal.css';
|
import '@app/components/shared/AppConfigModal.css';
|
||||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||||
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
|
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
|
||||||
import { UnsavedChangesProvider, useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
|
import { UnsavedChangesProvider, useUnsavedChanges } from '@app/contexts/UnsavedChangesContext';
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
|||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
withCloseButton={false}
|
withCloseButton={false}
|
||||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
zIndex={Z_INDEX_CONFIG_MODAL}
|
||||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||||
padding={0}
|
padding={0}
|
||||||
fullScreen={isMobile}
|
fullScreen={isMobile}
|
||||||
|
|||||||
@@ -130,11 +130,6 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
onClick={onButtonClick}
|
onClick={onButtonClick}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
|
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
|
||||||
styles={{
|
|
||||||
label: {
|
|
||||||
color: textColor ?? toneStyle.text,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
|
|||||||
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
|
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
|
||||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
|
||||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||||
|
import { requestStartTour } from '@app/constants/events';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isNavButtonActive,
|
isNavButtonActive,
|
||||||
@@ -34,7 +34,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||||
const { getToolNavigation } = useSidebarNavigation();
|
const { getToolNavigation } = useSidebarNavigation();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const { startTour } = useOnboarding();
|
|
||||||
const licenseAlert = useLicenseAlert();
|
const licenseAlert = useLicenseAlert();
|
||||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||||
@@ -269,7 +268,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
<div
|
<div
|
||||||
key={buttonConfig.id}
|
key={buttonConfig.id}
|
||||||
data-tour="help-button"
|
data-tour="help-button"
|
||||||
onClick={() => startTour('tools')}
|
onClick={() => requestStartTour('tools')}
|
||||||
>
|
>
|
||||||
{renderNavButton(buttonConfig, index)}
|
{renderNavButton(buttonConfig, index)}
|
||||||
</div>
|
</div>
|
||||||
@@ -286,7 +285,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
|
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
|
||||||
onClick={() => startTour('tools')}
|
onClick={() => requestStartTour('tools')}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500 }}>
|
<div style={{ fontWeight: 500 }}>
|
||||||
@@ -299,7 +298,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
|
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
|
||||||
onClick={() => startTour('admin')}
|
onClick={() => requestStartTour('admin')}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500 }}>
|
<div style={{ fontWeight: 500 }}>
|
||||||
|
|||||||
@@ -3,65 +3,39 @@ import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
|
||||||
import '@app/components/tools/ToolPanelModePrompt.css';
|
import '@app/components/tools/ToolPanelModePrompt.css';
|
||||||
import { type ToolPanelMode } from '@app/constants/toolPanel';
|
import { type ToolPanelMode } from '@app/constants/toolPanel';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
|
||||||
|
|
||||||
interface ToolPanelModePromptProps {
|
interface ToolPanelModePromptProps {
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
|
/** If true, the modal will be forced open (used by orchestrator) */
|
||||||
|
forceOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
|
/**
|
||||||
|
* ToolPanelModePrompt - Lets users choose between sidebar and fullscreen tool modes
|
||||||
|
*
|
||||||
|
* The orchestrator controls this via forceOpen prop. When shown standalone (legacy),
|
||||||
|
* it uses internal state based on preferences.
|
||||||
|
*/
|
||||||
|
const ToolPanelModePrompt = ({ onComplete, forceOpen }: ToolPanelModePromptProps = {}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
|
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
|
||||||
const { preferences, updatePreference } = usePreferences();
|
const { preferences, updatePreference } = usePreferences();
|
||||||
const {
|
const [internalOpened, setInternalOpened] = useState(false);
|
||||||
startTour,
|
|
||||||
startAfterToolModeSelection,
|
|
||||||
setStartAfterToolModeSelection,
|
|
||||||
pendingTourRequest,
|
|
||||||
} = useOnboarding();
|
|
||||||
const [opened, setOpened] = useState(false);
|
|
||||||
const { config } = useAppConfig();
|
|
||||||
const isAdmin = !!config?.isAdmin;
|
|
||||||
|
|
||||||
// Only show after the new 3-slide onboarding has been completed
|
// Only show after the intro onboarding has been completed (legacy standalone mode)
|
||||||
const shouldShowPrompt = !preferences.toolPanelModePromptSeen && preferences.hasSeenIntroOnboarding;
|
const shouldShowPrompt = !preferences.toolPanelModePromptSeen && preferences.hasSeenIntroOnboarding;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldShowPrompt) {
|
if (shouldShowPrompt && forceOpen === undefined) {
|
||||||
setOpened(true);
|
setInternalOpened(true);
|
||||||
}
|
}
|
||||||
}, [shouldShowPrompt]);
|
}, [shouldShowPrompt, forceOpen]);
|
||||||
|
|
||||||
const resolveRequestedTourType = (): 'admin' | 'tools' => {
|
// If forceOpen is provided, use it; otherwise use internal state
|
||||||
if (pendingTourRequest?.type) {
|
const opened = forceOpen ?? internalOpened;
|
||||||
return pendingTourRequest.type;
|
const setOpened = forceOpen !== undefined ? () => {} : setInternalOpened;
|
||||||
}
|
|
||||||
if (pendingTourRequest?.metadata && 'selfReportedAdmin' in pendingTourRequest.metadata) {
|
|
||||||
return pendingTourRequest.metadata.selfReportedAdmin ? 'admin' : 'tools';
|
|
||||||
}
|
|
||||||
return isAdmin ? 'admin' : 'tools';
|
|
||||||
};
|
|
||||||
|
|
||||||
const resumeDeferredTour = (context?: { selection?: ToolPanelMode; dismissed?: boolean }) => {
|
|
||||||
if (!startAfterToolModeSelection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStartAfterToolModeSelection(false);
|
|
||||||
const targetType = resolveRequestedTourType();
|
|
||||||
startTour(targetType, {
|
|
||||||
skipToolPromptRequirement: true,
|
|
||||||
source: 'tool-panel-mode-prompt',
|
|
||||||
metadata: {
|
|
||||||
...pendingTourRequest?.metadata,
|
|
||||||
resumedFromToolPrompt: true,
|
|
||||||
...(context?.selection ? { selection: context.selection } : {}),
|
|
||||||
...(context?.dismissed ? { dismissed: true } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (mode: ToolPanelMode) => {
|
const handleSelect = (mode: ToolPanelMode) => {
|
||||||
setToolPanelMode(mode);
|
setToolPanelMode(mode);
|
||||||
@@ -69,8 +43,6 @@ const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
|
|||||||
updatePreference('toolPanelModePromptSeen', true);
|
updatePreference('toolPanelModePromptSeen', true);
|
||||||
updatePreference('hasSelectedToolPanelMode', true);
|
updatePreference('hasSelectedToolPanelMode', true);
|
||||||
setOpened(false);
|
setOpened(false);
|
||||||
|
|
||||||
resumeDeferredTour({ selection: mode });
|
|
||||||
onComplete?.();
|
onComplete?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,7 +55,6 @@ const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
|
|||||||
updatePreference('hasSelectedToolPanelMode', true);
|
updatePreference('hasSelectedToolPanelMode', true);
|
||||||
updatePreference('toolPanelModePromptSeen', true);
|
updatePreference('toolPanelModePromptSeen', true);
|
||||||
setOpened(false);
|
setOpened(false);
|
||||||
resumeDeferredTour({ dismissed: true });
|
|
||||||
onComplete?.();
|
onComplete?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { LicenseNotice } from '@app/types/types';
|
import type { LicenseNotice } from '@app/types/types';
|
||||||
|
|
||||||
export const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active';
|
|
||||||
export const ONBOARDING_SESSION_EVENT = 'stirling:onboarding-session-started';
|
|
||||||
export const SERVER_LICENSE_REQUEST_EVENT = 'stirling:server-license-requested';
|
export const SERVER_LICENSE_REQUEST_EVENT = 'stirling:server-license-requested';
|
||||||
export const UPGRADE_BANNER_TEST_EVENT = 'stirling:upgrade-banner-test';
|
export const UPGRADE_BANNER_TEST_EVENT = 'stirling:upgrade-banner-test';
|
||||||
export const UPGRADE_BANNER_ALERT_EVENT = 'stirling:upgrade-banner-alert';
|
export const UPGRADE_BANNER_ALERT_EVENT = 'stirling:upgrade-banner-alert';
|
||||||
|
export const START_TOUR_EVENT = 'stirling:start-tour';
|
||||||
|
export const TOUR_STATE_EVENT = 'stirling:tour-state';
|
||||||
|
|
||||||
export interface ServerLicenseRequestPayload {
|
export interface ServerLicenseRequestPayload {
|
||||||
licenseNotice?: Partial<LicenseNotice>;
|
licenseNotice?: Partial<LicenseNotice>;
|
||||||
@@ -25,3 +25,29 @@ export interface UpgradeBannerAlertPayload {
|
|||||||
freeTierLimit?: number;
|
freeTierLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TourType = 'admin' | 'tools';
|
||||||
|
|
||||||
|
export interface StartTourPayload {
|
||||||
|
tourType: TourType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourStatePayload {
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to dispatch the start tour event */
|
||||||
|
export function requestStartTour(tourType: TourType): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent<StartTourPayload>(START_TOUR_EVENT, { detail: { tourType } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to dispatch tour state changes (for hiding cookie consent during tour) */
|
||||||
|
export function dispatchTourState(isOpen: boolean): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent<TourStatePayload>(TOUR_STATE_EVENT, { detail: { isOpen } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Route constants used across the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes where onboarding, cookie consent, and upgrade banners should not appear.
|
||||||
|
* These are authentication-related pages where users are not yet logged in or
|
||||||
|
* the main app chrome is not displayed.
|
||||||
|
*/
|
||||||
|
export const AUTH_ROUTES = [
|
||||||
|
'/login',
|
||||||
|
'/signup',
|
||||||
|
'/auth',
|
||||||
|
'/invite',
|
||||||
|
'/forgot-password',
|
||||||
|
'/reset-password',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a pathname matches any auth route
|
||||||
|
*/
|
||||||
|
export function isAuthRoute(pathname: string): boolean {
|
||||||
|
return AUTH_ROUTES.some((route) => pathname.startsWith(route));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
|
||||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
|
||||||
|
|
||||||
export type TourType = 'tools' | 'admin';
|
|
||||||
|
|
||||||
export interface StartTourOptions {
|
|
||||||
source?: string;
|
|
||||||
skipToolPromptRequirement?: boolean;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PendingTourRequest {
|
|
||||||
type: TourType;
|
|
||||||
source?: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
requestedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OnboardingContextValue {
|
|
||||||
isOpen: boolean;
|
|
||||||
currentStep: number;
|
|
||||||
tourType: TourType;
|
|
||||||
setCurrentStep: (step: number) => void;
|
|
||||||
startTour: (type?: TourType, options?: StartTourOptions) => void;
|
|
||||||
closeTour: () => void;
|
|
||||||
completeTour: () => void;
|
|
||||||
resetTour: (type?: TourType) => void;
|
|
||||||
startAfterToolModeSelection: boolean;
|
|
||||||
setStartAfterToolModeSelection: (value: boolean) => void;
|
|
||||||
pendingTourRequest: PendingTourRequest | null;
|
|
||||||
clearPendingTourRequest: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OnboardingContext = createContext<OnboardingContextValue | undefined>(undefined);
|
|
||||||
|
|
||||||
export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
||||||
const { preferences, updatePreference } = usePreferences();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
|
||||||
const [tourType, setTourType] = useState<TourType>('tools');
|
|
||||||
const [startAfterToolModeSelection, setStartAfterToolModeSelection] = useState(false);
|
|
||||||
const [pendingTourRequest, setPendingTourRequest] = useState<PendingTourRequest | null>(null);
|
|
||||||
|
|
||||||
const openTour = useCallback((type: TourType = 'tools') => {
|
|
||||||
setTourType(type);
|
|
||||||
setCurrentStep(0);
|
|
||||||
setIsOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startTour = useCallback(
|
|
||||||
(type: TourType = 'tools', options?: StartTourOptions) => {
|
|
||||||
const requestedType = type ?? 'tools';
|
|
||||||
const source = options?.source ?? 'unspecified';
|
|
||||||
const metadata = options?.metadata;
|
|
||||||
const skipToolPromptRequirement = options?.skipToolPromptRequirement ?? false;
|
|
||||||
const toolPromptSeen = preferences.toolPanelModePromptSeen;
|
|
||||||
const hasSelectedToolPanelMode = preferences.hasSelectedToolPanelMode;
|
|
||||||
const hasToolPreference = toolPromptSeen || hasSelectedToolPanelMode;
|
|
||||||
const shouldDefer = !skipToolPromptRequirement && !hasToolPreference;
|
|
||||||
|
|
||||||
console.log('[onboarding] startTour invoked', {
|
|
||||||
requestedType,
|
|
||||||
source,
|
|
||||||
toolPromptSeen,
|
|
||||||
hasSelectedToolPanelMode,
|
|
||||||
shouldDefer,
|
|
||||||
hasPendingTourRequest: !!pendingTourRequest,
|
|
||||||
metadata,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shouldDefer) {
|
|
||||||
setPendingTourRequest({
|
|
||||||
type: requestedType,
|
|
||||||
source,
|
|
||||||
metadata,
|
|
||||||
requestedAt: Date.now(),
|
|
||||||
});
|
|
||||||
setStartAfterToolModeSelection(true);
|
|
||||||
console.log('[onboarding] deferring tour launch until tool panel mode selection completes', {
|
|
||||||
requestedType,
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingTourRequest) {
|
|
||||||
console.log('[onboarding] clearing previous pending tour request before starting new tour', {
|
|
||||||
previousRequest: pendingTourRequest,
|
|
||||||
newType: requestedType,
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingTourRequest(null);
|
|
||||||
setStartAfterToolModeSelection(false);
|
|
||||||
console.log('[onboarding] starting tour', {
|
|
||||||
requestedType,
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
openTour(requestedType);
|
|
||||||
},
|
|
||||||
[openTour, pendingTourRequest, preferences.toolPanelModePromptSeen, preferences.hasSelectedToolPanelMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeTour = useCallback(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const completeTour = useCallback(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
updatePreference('hasCompletedOnboarding', true);
|
|
||||||
}, [updatePreference]);
|
|
||||||
|
|
||||||
const resetTour = useCallback((type: TourType = 'tools') => {
|
|
||||||
updatePreference('hasCompletedOnboarding', false);
|
|
||||||
setTourType(type);
|
|
||||||
setCurrentStep(0);
|
|
||||||
setIsOpen(true);
|
|
||||||
}, [updatePreference]);
|
|
||||||
|
|
||||||
const clearPendingTourRequest = useCallback(() => {
|
|
||||||
if (pendingTourRequest) {
|
|
||||||
console.log('[onboarding] clearing pending tour request manually', {
|
|
||||||
pendingTourRequest,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setPendingTourRequest(null);
|
|
||||||
setStartAfterToolModeSelection(false);
|
|
||||||
}, [pendingTourRequest]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OnboardingContext.Provider
|
|
||||||
value={{
|
|
||||||
isOpen,
|
|
||||||
currentStep,
|
|
||||||
tourType,
|
|
||||||
setCurrentStep,
|
|
||||||
startTour,
|
|
||||||
closeTour,
|
|
||||||
completeTour,
|
|
||||||
resetTour,
|
|
||||||
startAfterToolModeSelection,
|
|
||||||
setStartAfterToolModeSelection,
|
|
||||||
pendingTourRequest,
|
|
||||||
clearPendingTourRequest,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</OnboardingContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useOnboarding = (): OnboardingContextValue => {
|
|
||||||
const context = useContext(OnboardingContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useOnboarding must be used within an OnboardingProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { BASE_PATH } from '@app/constants/app';
|
import { BASE_PATH } from '@app/constants/app';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
import { TOUR_STATE_EVENT, type TourStatePayload } from '@app/constants/events';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -28,16 +28,11 @@ export const useCookieConsent = ({
|
|||||||
}: CookieConsentConfig = {}) => {
|
}: CookieConsentConfig = {}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const { isOpen: tourIsOpen } = useOnboarding();
|
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!analyticsEnabled) {
|
if (!analyticsEnabled) return;
|
||||||
console.log('Cookie consent not enabled - analyticsEnabled is false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the cookie consent CSS files first (always needed)
|
|
||||||
const mainCSS = document.createElement('link');
|
const mainCSS = document.createElement('link');
|
||||||
mainCSS.rel = 'stylesheet';
|
mainCSS.rel = 'stylesheet';
|
||||||
mainCSS.href = `${BASE_PATH}css/cookieconsent.css`;
|
mainCSS.href = `${BASE_PATH}css/cookieconsent.css`;
|
||||||
@@ -52,26 +47,19 @@ export const useCookieConsent = ({
|
|||||||
document.head.appendChild(customCSS);
|
document.head.appendChild(customCSS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent double initialization
|
|
||||||
if (window.CookieConsent) {
|
if (window.CookieConsent) {
|
||||||
|
if (forceLightMode) {
|
||||||
|
document.documentElement.classList.remove('cc--darkmode');
|
||||||
|
}
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
// Force show the modal if it exists but isn't visible
|
|
||||||
setTimeout(() => {
|
|
||||||
window.CookieConsent?.show();
|
|
||||||
}, 100);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the cookie consent library
|
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = `${BASE_PATH}js/thirdParty/cookieconsent.umd.js`;
|
script.src = `${BASE_PATH}js/thirdParty/cookieconsent.umd.js`;
|
||||||
script.onload = () => {
|
script.onload = () => {
|
||||||
// Small delay to ensure DOM is ready
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
||||||
// Detect current theme and set appropriate mode
|
|
||||||
const detectTheme = () => {
|
const detectTheme = () => {
|
||||||
// If forceLightMode is enabled, always use light mode
|
|
||||||
if (forceLightMode) {
|
if (forceLightMode) {
|
||||||
document.documentElement.classList.remove('cc--darkmode');
|
document.documentElement.classList.remove('cc--darkmode');
|
||||||
return false;
|
return false;
|
||||||
@@ -82,7 +70,6 @@ export const useCookieConsent = ({
|
|||||||
const hasDarkClass = document.documentElement.classList.contains('dark');
|
const hasDarkClass = document.documentElement.classList.contains('dark');
|
||||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
// Priority: Mantine attribute > CSS classes > system preference
|
|
||||||
let isDarkMode = false;
|
let isDarkMode = false;
|
||||||
|
|
||||||
if (mantineScheme) {
|
if (mantineScheme) {
|
||||||
@@ -95,24 +82,17 @@ export const useCookieConsent = ({
|
|||||||
isDarkMode = systemPrefersDark;
|
isDarkMode = systemPrefersDark;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always explicitly set or remove the class
|
|
||||||
document.documentElement.classList.toggle('cc--darkmode', isDarkMode);
|
document.documentElement.classList.toggle('cc--darkmode', isDarkMode);
|
||||||
|
|
||||||
return isDarkMode;
|
return isDarkMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial theme detection with slight delay to ensure DOM is ready
|
setTimeout(() => detectTheme(), 50);
|
||||||
setTimeout(() => {
|
|
||||||
detectTheme();
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
// Check if CookieConsent is available
|
|
||||||
if (!window.CookieConsent) {
|
if (!window.CookieConsent) {
|
||||||
console.error('CookieConsent is not available on window object');
|
console.error('CookieConsent is not available on window object');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for theme changes (but not if forceLightMode is enabled)
|
|
||||||
let themeObserver: MutationObserver | null = null;
|
let themeObserver: MutationObserver | null = null;
|
||||||
if (!forceLightMode) {
|
if (!forceLightMode) {
|
||||||
themeObserver = new MutationObserver((mutations) => {
|
themeObserver = new MutationObserver((mutations) => {
|
||||||
@@ -131,8 +111,6 @@ export const useCookieConsent = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Initialize cookie consent with full configuration
|
|
||||||
try {
|
try {
|
||||||
window.CookieConsent.run({
|
window.CookieConsent.run({
|
||||||
autoShow: true,
|
autoShow: true,
|
||||||
@@ -160,15 +138,11 @@ export const useCookieConsent = ({
|
|||||||
...(config?.enablePosthog !== false && {
|
...(config?.enablePosthog !== false && {
|
||||||
posthog: {
|
posthog: {
|
||||||
label: t('cookieBanner.services.posthog', 'PostHog Analytics'),
|
label: t('cookieBanner.services.posthog', 'PostHog Analytics'),
|
||||||
onAccept: () => console.log('PostHog service accepted'),
|
|
||||||
onReject: () => console.log('PostHog service rejected')
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
...(config?.enableScarf !== false && {
|
...(config?.enableScarf !== false && {
|
||||||
scarf: {
|
scarf: {
|
||||||
label: t('cookieBanner.services.scarf', 'Scarf Pixel'),
|
label: t('cookieBanner.services.scarf', 'Scarf Pixel'),
|
||||||
onAccept: () => console.log('Scarf service accepted'),
|
|
||||||
onReject: () => console.log('Scarf service rejected')
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -224,16 +198,11 @@ export const useCookieConsent = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force show after initialization
|
|
||||||
setTimeout(() => {
|
|
||||||
window.CookieConsent?.show();
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing CookieConsent:', error);
|
console.error('Error initializing CookieConsent:', error);
|
||||||
}
|
}
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
}, 100); // Small delay to ensure DOM is ready
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
script.onerror = () => {
|
script.onerror = () => {
|
||||||
@@ -243,7 +212,6 @@ export const useCookieConsent = ({
|
|||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Cleanup script and CSS when component unmounts
|
|
||||||
if (document.head.contains(script)) {
|
if (document.head.contains(script)) {
|
||||||
document.head.removeChild(script);
|
document.head.removeChild(script);
|
||||||
}
|
}
|
||||||
@@ -254,9 +222,8 @@ export const useCookieConsent = ({
|
|||||||
document.head.removeChild(customCSS);
|
document.head.removeChild(customCSS);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
|
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t, forceLightMode]);
|
||||||
|
|
||||||
// Update theme when forceLightMode changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized) return;
|
if (!isInitialized) return;
|
||||||
|
|
||||||
@@ -286,10 +253,8 @@ export const useCookieConsent = ({
|
|||||||
return isDarkMode;
|
return isDarkMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update theme immediately
|
|
||||||
detectTheme();
|
detectTheme();
|
||||||
|
|
||||||
// Set up or remove theme observer based on forceLightMode
|
|
||||||
let themeObserver: MutationObserver | null = null;
|
let themeObserver: MutationObserver | null = null;
|
||||||
if (!forceLightMode) {
|
if (!forceLightMode) {
|
||||||
themeObserver = new MutationObserver((mutations) => {
|
themeObserver = new MutationObserver((mutations) => {
|
||||||
@@ -315,23 +280,23 @@ export const useCookieConsent = ({
|
|||||||
};
|
};
|
||||||
}, [forceLightMode, isInitialized]);
|
}, [forceLightMode, isInitialized]);
|
||||||
|
|
||||||
// Hide cookie banner when tour is active
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized || !window.CookieConsent) {
|
if (!isInitialized || !window.CookieConsent) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tourIsOpen) {
|
const handleTourState = (event: Event) => {
|
||||||
window.CookieConsent.hide();
|
const { detail } = event as CustomEvent<TourStatePayload>;
|
||||||
} else {
|
if (detail?.isOpen) {
|
||||||
// Only show if user hasn't made a choice yet
|
window.CookieConsent?.hide();
|
||||||
const consentCookie = window.CookieConsent.getCookie?.();
|
} else {
|
||||||
const hasConsented = consentCookie && Object.keys(consentCookie).length > 0;
|
const consentCookie = window.CookieConsent?.getCookie?.();
|
||||||
if (!hasConsented) {
|
const hasConsented = consentCookie && Object.keys(consentCookie).length > 0;
|
||||||
window.CookieConsent.show();
|
if (!hasConsented) window.CookieConsent?.show();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [tourIsOpen, isInitialized]);
|
|
||||||
|
window.addEventListener(TOUR_STATE_EVENT, handleTourState);
|
||||||
|
return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourState);
|
||||||
|
}, [isInitialized]);
|
||||||
|
|
||||||
const showCookieConsent = useCallback(() => {
|
const showCookieConsent = useCallback(() => {
|
||||||
if (isInitialized && window.CookieConsent) {
|
if (isInitialized && window.CookieConsent) {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import FileManager from "@app/components/FileManager";
|
|||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||||
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
import AppConfigModal from "@app/components/shared/AppConfigModal";
|
||||||
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
|
|
||||||
|
|
||||||
import "@app/pages/HomePage.css";
|
import "@app/pages/HomePage.css";
|
||||||
|
|
||||||
@@ -53,20 +52,12 @@ export default function HomePage() {
|
|||||||
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
||||||
const isProgrammaticScroll = useRef(false);
|
const isProgrammaticScroll = useRef(false);
|
||||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
|
|
||||||
|
|
||||||
const { activeFiles } = useFileContext();
|
const { activeFiles } = useFileContext();
|
||||||
const { actions } = useNavigationActions();
|
const { actions } = useNavigationActions();
|
||||||
const { setActiveFileIndex } = useViewer();
|
const { setActiveFileIndex } = useViewer();
|
||||||
const prevFileCountRef = useRef(activeFiles.length);
|
const prevFileCountRef = useRef(activeFiles.length);
|
||||||
|
|
||||||
// Show admin analytics choice modal if analytics settings not configured
|
|
||||||
useEffect(() => {
|
|
||||||
if (config && config.enableAnalytics === null) {
|
|
||||||
setShowAnalyticsModal(true);
|
|
||||||
}
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
// Auto-switch to viewer when going from 0 to 1 file
|
// Auto-switch to viewer when going from 0 to 1 file
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prevCount = prevFileCountRef.current;
|
const prevCount = prevFileCountRef.current;
|
||||||
@@ -181,10 +172,6 @@ export default function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen overflow-hidden">
|
<div className="h-screen overflow-hidden">
|
||||||
<AdminAnalyticsChoiceModal
|
|
||||||
opened={showAnalyticsModal}
|
|
||||||
onClose={() => setShowAnalyticsModal(false)}
|
|
||||||
/>
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="mobile-layout">
|
<div className="mobile-layout">
|
||||||
<div className="mobile-toggle">
|
<div className="mobile-toggle">
|
||||||
|
|||||||
@@ -9,11 +9,26 @@ export interface AccountData {
|
|||||||
saml2Login: boolean;
|
saml2Login: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginPageData {
|
||||||
|
showDefaultCredentials: boolean;
|
||||||
|
firstTimeSetup: boolean;
|
||||||
|
enableLogin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Account Service
|
* Account Service
|
||||||
* Provides functions to interact with account-related backend APIs
|
* Provides functions to interact with account-related backend APIs
|
||||||
*/
|
*/
|
||||||
export const accountService = {
|
export const accountService = {
|
||||||
|
/**
|
||||||
|
* Get login page data (includes showDefaultCredentials flag)
|
||||||
|
* This is a public endpoint - doesn't require authentication
|
||||||
|
*/
|
||||||
|
async getLoginPageData(): Promise<LoginPageData> {
|
||||||
|
const response = await apiClient.get<LoginPageData>('/api/v1/proprietary/ui-data/login');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current user account data
|
* Get current user account data
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
export const Z_INDEX_FULLSCREEN_SURFACE = 1000;
|
export const Z_INDEX_FULLSCREEN_SURFACE = 1000;
|
||||||
export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
|
export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
|
||||||
export const Z_ANALYTICS_MODAL = 1301;
|
export const Z_ANALYTICS_MODAL = 1301;
|
||||||
|
// Config/Settings modal - should appear above analytics modal when navigating from onboarding
|
||||||
|
export const Z_INDEX_CONFIG_MODAL = 1400;
|
||||||
|
|
||||||
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
|
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
|
||||||
export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300;
|
export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300;
|
||||||
|
|||||||
-390
@@ -1,390 +0,0 @@
|
|||||||
/**
|
|
||||||
* Desktop override of useInitialOnboardingState.
|
|
||||||
*
|
|
||||||
* Key difference: Handles the simplified onboarding flow where desktop-install
|
|
||||||
* and security-check slides are removed. When welcome is the last slide,
|
|
||||||
* clicking Next will launch the tools tour instead of doing nothing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
|
||||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
|
||||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
|
||||||
import { useOs } from '@app/hooks/useOs';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
SLIDE_DEFINITIONS,
|
|
||||||
type ButtonAction,
|
|
||||||
type FlowState,
|
|
||||||
type SlideId,
|
|
||||||
} from '@app/components/onboarding/onboardingFlowConfig';
|
|
||||||
import type { LicenseNotice } from '@app/types/types';
|
|
||||||
import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver';
|
|
||||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
|
||||||
import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types';
|
|
||||||
import { DOWNLOAD_URLS } from '@app/constants/downloads';
|
|
||||||
|
|
||||||
interface UseInitialOnboardingStateResult {
|
|
||||||
state: OnboardingState;
|
|
||||||
totalSteps: number;
|
|
||||||
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
|
|
||||||
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
|
|
||||||
licenseNotice: LicenseNotice;
|
|
||||||
flowState: FlowState;
|
|
||||||
closeAndMarkSeen: () => void;
|
|
||||||
handleButtonAction: (action: ButtonAction) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInitialOnboardingState({
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
onRequestServerLicense,
|
|
||||||
onLicenseNoticeUpdate,
|
|
||||||
}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
|
|
||||||
const { preferences, updatePreference } = usePreferences();
|
|
||||||
const { startTour } = useOnboarding();
|
|
||||||
const {
|
|
||||||
loginEnabled: loginEnabledFromServer,
|
|
||||||
configIsAdmin,
|
|
||||||
totalUsers: serverTotalUsers,
|
|
||||||
userCountResolved: serverUserCountResolved,
|
|
||||||
freeTierLimit,
|
|
||||||
hasPaidLicense,
|
|
||||||
scenarioKey,
|
|
||||||
setSelfReportedAdmin,
|
|
||||||
isNewServer,
|
|
||||||
} = useServerExperience();
|
|
||||||
const osType = useOs();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const selectedDownloadUrlRef = useRef<string>('');
|
|
||||||
|
|
||||||
const [state, setState] = useState<OnboardingState>(DEFAULT_STATE);
|
|
||||||
|
|
||||||
const resetState = useCallback(() => {
|
|
||||||
setState(DEFAULT_STATE);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!opened) {
|
|
||||||
resetState();
|
|
||||||
}
|
|
||||||
}, [opened, resetState]);
|
|
||||||
|
|
||||||
const handleRoleSelect = useCallback(
|
|
||||||
(role: 'admin' | 'user' | null) => {
|
|
||||||
const isAdminSelection = role === 'admin';
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
selectedRole: role,
|
|
||||||
selfReportedAdmin: isAdminSelection,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
if (isAdminSelection) {
|
|
||||||
window.localStorage.setItem('stirling-self-reported-admin', 'true');
|
|
||||||
} else {
|
|
||||||
window.localStorage.removeItem('stirling-self-reported-admin');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelfReportedAdmin(isAdminSelection);
|
|
||||||
},
|
|
||||||
[setSelfReportedAdmin],
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeAndMarkSeen = useCallback(() => {
|
|
||||||
if (!preferences.hasSeenIntroOnboarding) {
|
|
||||||
updatePreference('hasSeenIntroOnboarding', true);
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
|
|
||||||
|
|
||||||
const isAdmin = configIsAdmin;
|
|
||||||
const enableLogin = loginEnabledFromServer;
|
|
||||||
|
|
||||||
const effectiveEnableLogin = enableLogin;
|
|
||||||
const effectiveIsAdmin = isAdmin;
|
|
||||||
const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) {
|
|
||||||
handleRoleSelect('admin');
|
|
||||||
}
|
|
||||||
}, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]);
|
|
||||||
|
|
||||||
const shouldUseServerCount =
|
|
||||||
(effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin;
|
|
||||||
const licenseUserCountFromServer =
|
|
||||||
shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null;
|
|
||||||
|
|
||||||
const effectiveLicenseUserCount = licenseUserCountFromServer ?? null;
|
|
||||||
|
|
||||||
const os = useMemo(() => {
|
|
||||||
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 };
|
|
||||||
default:
|
|
||||||
return { label: '', url: '' };
|
|
||||||
}
|
|
||||||
}, [osType]);
|
|
||||||
|
|
||||||
const osOptions = useMemo(() => {
|
|
||||||
const options = [
|
|
||||||
{ 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' },
|
|
||||||
];
|
|
||||||
return options.filter(opt => opt.url);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resolvedFlow = useMemo(
|
|
||||||
() => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin),
|
|
||||||
[effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin],
|
|
||||||
);
|
|
||||||
// Desktop: No security-check slide, so no need to filter it out
|
|
||||||
const flowSlideIds = resolvedFlow.ids;
|
|
||||||
const flowType = resolvedFlow.type;
|
|
||||||
const totalSteps = flowSlideIds.length;
|
|
||||||
const maxIndex = Math.max(totalSteps - 1, 0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.step >= flowSlideIds.length) {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
step: Math.max(flowSlideIds.length - 1, 0),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [flowSlideIds.length, state.step]);
|
|
||||||
|
|
||||||
const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1];
|
|
||||||
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
|
|
||||||
|
|
||||||
if (!slideDefinition) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scenarioProvidesInfo =
|
|
||||||
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
|
|
||||||
const scenarioIndicatesAdmin = scenarioProvidesInfo
|
|
||||||
? scenarioKey!.includes('admin')
|
|
||||||
: state.selfReportedAdmin || effectiveIsAdmin;
|
|
||||||
const scenarioIndicatesOverLimit = scenarioProvidesInfo
|
|
||||||
? scenarioKey!.includes('over-limit')
|
|
||||||
: effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit;
|
|
||||||
const scenarioRequiresLicense =
|
|
||||||
scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true;
|
|
||||||
|
|
||||||
const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense;
|
|
||||||
|
|
||||||
const licenseNotice = useMemo<LicenseNotice>(
|
|
||||||
() => ({
|
|
||||||
totalUsers: effectiveLicenseUserCount,
|
|
||||||
freeTierLimit,
|
|
||||||
isOverLimit: scenarioIndicatesOverLimit,
|
|
||||||
requiresLicense: shouldShowServerLicenseInfo,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
effectiveLicenseUserCount,
|
|
||||||
freeTierLimit,
|
|
||||||
scenarioIndicatesOverLimit,
|
|
||||||
shouldShowServerLicenseInfo,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const requestServerLicenseIfNeeded = useCallback(
|
|
||||||
(options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => {
|
|
||||||
if (!shouldShowServerLicenseInfo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onRequestServerLicense?.(options);
|
|
||||||
},
|
|
||||||
[onRequestServerLicense, shouldShowServerLicenseInfo],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onLicenseNoticeUpdate?.(licenseNotice);
|
|
||||||
}, [licenseNotice, onLicenseNoticeUpdate]);
|
|
||||||
|
|
||||||
// Initialize ref with default URL
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedDownloadUrlRef.current && os.url) {
|
|
||||||
selectedDownloadUrlRef.current = os.url;
|
|
||||||
}
|
|
||||||
}, [os.url]);
|
|
||||||
|
|
||||||
const handleDownloadUrlChange = useCallback((url: string) => {
|
|
||||||
selectedDownloadUrlRef.current = url;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const currentSlide = slideDefinition.createSlide({
|
|
||||||
osLabel: os.label,
|
|
||||||
osUrl: os.url,
|
|
||||||
osOptions,
|
|
||||||
onDownloadUrlChange: handleDownloadUrlChange,
|
|
||||||
selectedRole: state.selectedRole,
|
|
||||||
onRoleSelect: handleRoleSelect,
|
|
||||||
licenseNotice,
|
|
||||||
loginEnabled: effectiveEnableLogin,
|
|
||||||
});
|
|
||||||
|
|
||||||
const goNext = useCallback(() => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
step: Math.min(prev.step + 1, maxIndex),
|
|
||||||
}));
|
|
||||||
}, [maxIndex]);
|
|
||||||
|
|
||||||
const goPrev = useCallback(() => {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
step: Math.max(prev.step - 1, 0),
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const launchTour = useCallback(
|
|
||||||
(mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => {
|
|
||||||
if (options?.closeOnboardingSlides) {
|
|
||||||
closeAndMarkSeen();
|
|
||||||
}
|
|
||||||
|
|
||||||
startTour(mode, {
|
|
||||||
source: 'initial-onboarding-modal',
|
|
||||||
metadata: {
|
|
||||||
hasCompletedOnboarding: preferences.hasCompletedOnboarding,
|
|
||||||
toolPanelModePromptSeen: preferences.toolPanelModePromptSeen,
|
|
||||||
selfReportedAdmin: state.selfReportedAdmin,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleButtonAction = useCallback(
|
|
||||||
(action: ButtonAction) => {
|
|
||||||
const isOnLastSlide = state.step >= maxIndex;
|
|
||||||
|
|
||||||
// Desktop: For login-user and no-login flows, when on the last slide (welcome),
|
|
||||||
// clicking Next should launch the tools tour
|
|
||||||
const shouldAutoLaunchToolsTour =
|
|
||||||
(flowType === 'login-user' || flowType === 'no-login') && isOnLastSlide;
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'next':
|
|
||||||
if (shouldAutoLaunchToolsTour) {
|
|
||||||
launchTour('tools', { closeOnboardingSlides: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goNext();
|
|
||||||
return;
|
|
||||||
case 'prev':
|
|
||||||
goPrev();
|
|
||||||
return;
|
|
||||||
case 'close':
|
|
||||||
closeAndMarkSeen();
|
|
||||||
return;
|
|
||||||
case 'download-selected': {
|
|
||||||
const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl;
|
|
||||||
if (downloadUrl) {
|
|
||||||
window.open(downloadUrl, '_blank', 'noopener');
|
|
||||||
}
|
|
||||||
if (shouldAutoLaunchToolsTour) {
|
|
||||||
launchTour('tools', { closeOnboardingSlides: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goNext();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case 'complete-close':
|
|
||||||
updatePreference('hasCompletedOnboarding', true);
|
|
||||||
closeAndMarkSeen();
|
|
||||||
return;
|
|
||||||
case 'security-next':
|
|
||||||
// Desktop: security-check slide is removed, but keep this for completeness
|
|
||||||
if (!state.selectedRole) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.selectedRole === 'admin') {
|
|
||||||
goNext();
|
|
||||||
} else {
|
|
||||||
launchTour('tools', { closeOnboardingSlides: true });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case 'launch-admin':
|
|
||||||
requestServerLicenseIfNeeded({
|
|
||||||
deferUntilTourComplete: true,
|
|
||||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
|
||||||
});
|
|
||||||
launchTour('admin', { closeOnboardingSlides: true });
|
|
||||||
return;
|
|
||||||
case 'launch-tools':
|
|
||||||
launchTour('tools', { closeOnboardingSlides: true });
|
|
||||||
return;
|
|
||||||
case 'launch-auto': {
|
|
||||||
const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools';
|
|
||||||
if (launchMode === 'admin') {
|
|
||||||
requestServerLicenseIfNeeded({
|
|
||||||
deferUntilTourComplete: true,
|
|
||||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
launchTour(launchMode, { closeOnboardingSlides: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case 'skip-to-license':
|
|
||||||
updatePreference('hasCompletedOnboarding', true);
|
|
||||||
requestServerLicenseIfNeeded({
|
|
||||||
deferUntilTourComplete: false,
|
|
||||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
|
||||||
});
|
|
||||||
closeAndMarkSeen();
|
|
||||||
return;
|
|
||||||
case 'see-plans':
|
|
||||||
closeAndMarkSeen();
|
|
||||||
navigate('/settings/adminPlan');
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
closeAndMarkSeen,
|
|
||||||
currentSlide,
|
|
||||||
currentSlideId,
|
|
||||||
effectiveIsAdmin,
|
|
||||||
flowType,
|
|
||||||
goNext,
|
|
||||||
goPrev,
|
|
||||||
launchTour,
|
|
||||||
maxIndex,
|
|
||||||
navigate,
|
|
||||||
requestServerLicenseIfNeeded,
|
|
||||||
onRequestServerLicense,
|
|
||||||
os.url,
|
|
||||||
state.selectedRole,
|
|
||||||
state.selfReportedAdmin,
|
|
||||||
state.step,
|
|
||||||
updatePreference,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const flowState: FlowState = { selectedRole: state.selectedRole };
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
totalSteps,
|
|
||||||
slideDefinition,
|
|
||||||
currentSlide,
|
|
||||||
licenseNotice,
|
|
||||||
flowState,
|
|
||||||
closeAndMarkSeen,
|
|
||||||
handleButtonAction,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
/**
|
|
||||||
* Desktop override of onboarding flow config.
|
|
||||||
*
|
|
||||||
* This version removes the desktop-install and security-check slides
|
|
||||||
* since they're not relevant when running as a desktop app.
|
|
||||||
*
|
|
||||||
* The SlideId type still includes all values for type compatibility,
|
|
||||||
* but the actual FLOW_SEQUENCES don't use these slides.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
|
|
||||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
|
||||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
|
||||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
|
||||||
|
|
||||||
// Keep the full type for compatibility, but these slides won't be used
|
|
||||||
export type SlideId =
|
|
||||||
| 'welcome'
|
|
||||||
| 'desktop-install'
|
|
||||||
| 'security-check'
|
|
||||||
| 'admin-overview'
|
|
||||||
| 'server-license';
|
|
||||||
|
|
||||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo';
|
|
||||||
|
|
||||||
export type ButtonAction =
|
|
||||||
| 'next'
|
|
||||||
| 'prev'
|
|
||||||
| 'close'
|
|
||||||
| 'complete-close'
|
|
||||||
| 'download-selected'
|
|
||||||
| 'security-next'
|
|
||||||
| 'launch-admin'
|
|
||||||
| 'launch-tools'
|
|
||||||
| 'launch-auto'
|
|
||||||
| 'see-plans'
|
|
||||||
| 'skip-to-license';
|
|
||||||
|
|
||||||
export interface FlowState {
|
|
||||||
selectedRole: 'admin' | 'user' | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OSOption {
|
|
||||||
label: string;
|
|
||||||
url: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SlideFactoryParams {
|
|
||||||
osLabel: string;
|
|
||||||
osUrl: string;
|
|
||||||
osOptions?: OSOption[];
|
|
||||||
onDownloadUrlChange?: (url: string) => void;
|
|
||||||
selectedRole: 'admin' | 'user' | null;
|
|
||||||
onRoleSelect: (role: 'admin' | 'user' | null) => void;
|
|
||||||
licenseNotice?: LicenseNotice;
|
|
||||||
loginEnabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HeroDefinition {
|
|
||||||
type: HeroType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ButtonDefinition {
|
|
||||||
key: string;
|
|
||||||
type: 'button' | 'icon';
|
|
||||||
label?: string;
|
|
||||||
icon?: 'chevron-left';
|
|
||||||
variant?: 'primary' | 'secondary' | 'default';
|
|
||||||
group: 'left' | 'right';
|
|
||||||
action: ButtonAction;
|
|
||||||
disabledWhen?: (state: FlowState) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SlideDefinition {
|
|
||||||
id: SlideId;
|
|
||||||
createSlide: (params: SlideFactoryParams) => SlideConfig;
|
|
||||||
hero: HeroDefinition;
|
|
||||||
buttons: ButtonDefinition[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
|
||||||
'welcome': {
|
|
||||||
id: 'welcome',
|
|
||||||
createSlide: () => WelcomeSlide(),
|
|
||||||
hero: { type: 'rocket' },
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
key: 'welcome-next',
|
|
||||||
type: 'button',
|
|
||||||
label: 'onboarding.buttons.next',
|
|
||||||
variant: 'primary',
|
|
||||||
group: 'right',
|
|
||||||
action: 'next',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Stub definitions for desktop-install and security-check - not used on desktop
|
|
||||||
// but kept for type compatibility with core code
|
|
||||||
'desktop-install': {
|
|
||||||
id: 'desktop-install',
|
|
||||||
createSlide: () => WelcomeSlide(), // Placeholder - never used
|
|
||||||
hero: { type: 'dual-icon' },
|
|
||||||
buttons: [],
|
|
||||||
},
|
|
||||||
'security-check': {
|
|
||||||
id: 'security-check',
|
|
||||||
createSlide: () => WelcomeSlide(), // Placeholder - never used
|
|
||||||
hero: { type: 'shield' },
|
|
||||||
buttons: [],
|
|
||||||
},
|
|
||||||
'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-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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'server-license': {
|
|
||||||
id: 'server-license',
|
|
||||||
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
|
|
||||||
hero: { type: 'dual-icon' },
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Desktop flow sequences - simplified without desktop-install and security-check slides
|
|
||||||
* since users are already on desktop and security check is not needed.
|
|
||||||
*/
|
|
||||||
export const FLOW_SEQUENCES = {
|
|
||||||
loginAdmin: ['welcome', 'admin-overview'] as SlideId[],
|
|
||||||
loginUser: ['welcome'] as SlideId[],
|
|
||||||
noLoginBase: ['welcome'] as SlideId[],
|
|
||||||
noLoginAdmin: ['admin-overview'] as SlideId[],
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Desktop Override: Onboarding Configuration
|
||||||
|
*
|
||||||
|
* This version modifies the onboarding config for the desktop app:
|
||||||
|
* - Sets isDesktopApp to true in the default runtime state
|
||||||
|
* - This causes desktop-install step to be skipped
|
||||||
|
*
|
||||||
|
* All other step definitions and logic remain the same.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export everything from core
|
||||||
|
export {
|
||||||
|
ONBOARDING_STEPS,
|
||||||
|
getStepById,
|
||||||
|
getStepIndex,
|
||||||
|
} from '@core/components/onboarding/orchestrator/onboardingConfig';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
OnboardingStepId,
|
||||||
|
OnboardingStepType,
|
||||||
|
OnboardingStep,
|
||||||
|
OnboardingRuntimeState,
|
||||||
|
OnboardingConditionContext,
|
||||||
|
} from '@core/components/onboarding/orchestrator/onboardingConfig';
|
||||||
|
|
||||||
|
// Import and override the default runtime state
|
||||||
|
import { DEFAULT_RUNTIME_STATE as CORE_DEFAULT_RUNTIME_STATE } from '@core/components/onboarding/orchestrator/onboardingConfig';
|
||||||
|
import type { OnboardingRuntimeState } from '@core/components/onboarding/orchestrator/onboardingConfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop default runtime state
|
||||||
|
* Sets isDesktopApp to true so desktop-install step is skipped
|
||||||
|
*/
|
||||||
|
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||||
|
...CORE_DEFAULT_RUNTIME_STATE,
|
||||||
|
isDesktopApp: true,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Desktop Override: Onboarding Orchestrator Hook
|
||||||
|
*
|
||||||
|
* Simply wraps the core hook with the desktop-specific default runtime state
|
||||||
|
* which has isDesktopApp set to true.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
useOnboardingOrchestrator as useCoreOnboardingOrchestrator,
|
||||||
|
type UseOnboardingOrchestratorResult,
|
||||||
|
} from '@core/components/onboarding/orchestrator/useOnboardingOrchestrator';
|
||||||
|
import { DEFAULT_RUNTIME_STATE } from '@desktop/components/onboarding/orchestrator/onboardingConfig';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
OnboardingOrchestratorState,
|
||||||
|
OnboardingOrchestratorActions,
|
||||||
|
UseOnboardingOrchestratorResult,
|
||||||
|
} from '@core/components/onboarding/orchestrator/useOnboardingOrchestrator';
|
||||||
|
|
||||||
|
export function useOnboardingOrchestrator(): UseOnboardingOrchestratorResult {
|
||||||
|
return useCoreOnboardingOrchestrator({ defaultRuntimeState: DEFAULT_RUNTIME_STATE });
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import Login from "@app/routes/Login";
|
|||||||
import Signup from "@app/routes/Signup";
|
import Signup from "@app/routes/Signup";
|
||||||
import AuthCallback from "@app/routes/AuthCallback";
|
import AuthCallback from "@app/routes/AuthCallback";
|
||||||
import InviteAccept from "@app/routes/InviteAccept";
|
import InviteAccept from "@app/routes/InviteAccept";
|
||||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||||
|
|
||||||
// Import global styles
|
// Import global styles
|
||||||
import "@app/styles/tailwind.css";
|
import "@app/styles/tailwind.css";
|
||||||
@@ -34,7 +34,7 @@ export default function App() {
|
|||||||
{/* Main app routes - Landing handles auth logic */}
|
{/* Main app routes - Landing handles auth logic */}
|
||||||
<Route path="/*" element={<Landing />} />
|
<Route path="/*" element={<Landing />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<OnboardingTour />
|
<Onboarding />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
import { isAuthRoute } from '@core/constants/routes';
|
||||||
import { useCheckout } from '@app/contexts/CheckoutContext';
|
import { useCheckout } from '@app/contexts/CheckoutContext';
|
||||||
import { InfoBanner } from '@app/components/shared/InfoBanner';
|
import { InfoBanner } from '@app/components/shared/InfoBanner';
|
||||||
import {
|
import {
|
||||||
ONBOARDING_SESSION_BLOCK_KEY,
|
|
||||||
ONBOARDING_SESSION_EVENT,
|
|
||||||
SERVER_LICENSE_REQUEST_EVENT,
|
SERVER_LICENSE_REQUEST_EVENT,
|
||||||
type ServerLicenseRequestPayload,
|
type ServerLicenseRequestPayload,
|
||||||
UPGRADE_BANNER_TEST_EVENT,
|
UPGRADE_BANNER_TEST_EVENT,
|
||||||
@@ -15,13 +13,17 @@ import {
|
|||||||
UPGRADE_BANNER_ALERT_EVENT,
|
UPGRADE_BANNER_ALERT_EVENT,
|
||||||
} from '@core/constants/events';
|
} from '@core/constants/events';
|
||||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||||
|
import { hasSeenStep } from '@core/components/onboarding/orchestrator/onboardingStorage';
|
||||||
|
|
||||||
const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt';
|
const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt';
|
||||||
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
const UpgradeBanner: React.FC = () => {
|
const UpgradeBanner: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isOpen: tourOpen } = useOnboarding();
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Check if we're on an auth route (evaluated after hooks, used in render)
|
||||||
|
const onAuthRoute = isAuthRoute(location.pathname);
|
||||||
const { openCheckout } = useCheckout();
|
const { openCheckout } = useCheckout();
|
||||||
const {
|
const {
|
||||||
totalUsers,
|
totalUsers,
|
||||||
@@ -34,40 +36,18 @@ const UpgradeBanner: React.FC = () => {
|
|||||||
overFreeTierLimit,
|
overFreeTierLimit,
|
||||||
scenarioKey,
|
scenarioKey,
|
||||||
} = useServerExperience();
|
} = useServerExperience();
|
||||||
const [sessionBlocked, setSessionBlocked] = useState(true);
|
const onboardingComplete = hasSeenStep('welcome');
|
||||||
const [friendlyVisible, setFriendlyVisible] = useState(false);
|
const [friendlyVisible, setFriendlyVisible] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
|
||||||
|
if (!lastShownRaw) return false;
|
||||||
|
const lastShown = parseInt(lastShownRaw, 10);
|
||||||
|
if (!Number.isFinite(lastShown)) return false;
|
||||||
|
return Date.now() - lastShown >= WEEK_IN_MS;
|
||||||
|
});
|
||||||
const isDev = import.meta.env.DEV;
|
const isDev = import.meta.env.DEV;
|
||||||
const [testScenario, setTestScenario] = useState<UpgradeBannerTestScenario>(null);
|
const [testScenario, setTestScenario] = useState<UpgradeBannerTestScenario>(null);
|
||||||
|
|
||||||
// Track onboarding session flag so we don't show banner if onboarding ran this load
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const evaluateBlock = () => {
|
|
||||||
const blocked = window.sessionStorage.getItem(ONBOARDING_SESSION_BLOCK_KEY) === 'true';
|
|
||||||
setSessionBlocked(blocked);
|
|
||||||
};
|
|
||||||
|
|
||||||
evaluateBlock();
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
evaluateBlock();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
const handleOnboardingEvent = () => {
|
|
||||||
evaluateBlock();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
window.removeEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDev || typeof window === 'undefined') {
|
if (!isDev || typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -157,28 +137,16 @@ const UpgradeBanner: React.FC = () => {
|
|||||||
shouldShowFriendlyBase &&
|
shouldShowFriendlyBase &&
|
||||||
!licenseLoading &&
|
!licenseLoading &&
|
||||||
effectiveTotalUsersLoaded &&
|
effectiveTotalUsersLoaded &&
|
||||||
!tourOpen &&
|
onboardingComplete,
|
||||||
!sessionBlocked,
|
|
||||||
);
|
);
|
||||||
|
// Urgent banner should always show when over-limit
|
||||||
const shouldEvaluateUrgent = scenario
|
const shouldEvaluateUrgent = scenario
|
||||||
? Boolean(scenario && !scenarioIsFriendly)
|
? Boolean(scenario && !scenarioIsFriendly)
|
||||||
: Boolean(
|
: Boolean(
|
||||||
shouldShowUrgentBase &&
|
shouldShowUrgentBase &&
|
||||||
!licenseLoading &&
|
!licenseLoading,
|
||||||
!tourOpen &&
|
|
||||||
!sessionBlocked,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldShowFriendlyBase && effectiveTotalUsersLoaded) {
|
|
||||||
window.localStorage.removeItem(FRIENDLY_LAST_SEEN_KEY);
|
|
||||||
}
|
|
||||||
}, [shouldShowFriendlyBase, effectiveTotalUsersLoaded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scenario === 'friendly') {
|
if (scenario === 'friendly') {
|
||||||
return;
|
return;
|
||||||
@@ -214,16 +182,6 @@ const UpgradeBanner: React.FC = () => {
|
|||||||
}
|
}
|
||||||
: { active: false };
|
: { active: false };
|
||||||
|
|
||||||
console.debug('[UpgradeBanner] Dispatching alert event', {
|
|
||||||
shouldEvaluateUrgent,
|
|
||||||
detail,
|
|
||||||
totalUsers: effectiveTotalUsers,
|
|
||||||
freeTierLimit,
|
|
||||||
effectiveIsAdmin,
|
|
||||||
effectiveHasPaidLicense,
|
|
||||||
userCountLoaded: effectiveTotalUsersLoaded,
|
|
||||||
});
|
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }),
|
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }),
|
||||||
);
|
);
|
||||||
@@ -246,12 +204,6 @@ const UpgradeBanner: React.FC = () => {
|
|||||||
window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString());
|
window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (friendlyVisible) {
|
|
||||||
recordFriendlyLastShown();
|
|
||||||
}
|
|
||||||
}, [friendlyVisible, recordFriendlyLastShown]);
|
|
||||||
|
|
||||||
const handleUpgrade = () => {
|
const handleUpgrade = () => {
|
||||||
recordFriendlyLastShown();
|
recordFriendlyLastShown();
|
||||||
|
|
||||||
@@ -308,15 +260,8 @@ const UpgradeBanner: React.FC = () => {
|
|||||||
|
|
||||||
const renderUrgentBanner = () => {
|
const renderUrgentBanner = () => {
|
||||||
if (!shouldEvaluateUrgent) {
|
if (!shouldEvaluateUrgent) {
|
||||||
console.debug('[UpgradeBanner] renderUrgentBanner → hidden (shouldEvaluateUrgent=false)');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
console.debug('[UpgradeBanner] renderUrgentBanner → visible', {
|
|
||||||
totalUsers: effectiveTotalUsers,
|
|
||||||
freeTierLimit,
|
|
||||||
effectiveIsAdmin,
|
|
||||||
effectiveHasPaidLicense,
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttonText = effectiveIsAdmin ? t('upgradeBanner.seeInfo', 'See info') : undefined;
|
const buttonText = effectiveIsAdmin ? t('upgradeBanner.seeInfo', 'See info') : undefined;
|
||||||
|
|
||||||
@@ -351,7 +296,8 @@ const UpgradeBanner: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!friendlyVisible && !shouldEvaluateUrgent) {
|
// Don't show on auth routes or if neither banner type should show
|
||||||
|
if (onAuthRoute || (!friendlyVisible && !shouldEvaluateUrgent)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,19 +173,8 @@ export function ServerExperienceProvider({ children }: { children: ReactNode })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldUseAdminData = (config.enableLogin ?? true) && config.isAdmin;
|
const shouldUseAdminData = (config.enableLogin ?? true) && config.isAdmin;
|
||||||
const shouldUseEstimate = config.enableLogin === false;
|
// Use WAU estimate for no-login scenarios OR for login non-admin users
|
||||||
|
const shouldUseEstimate = config.enableLogin === false || !config.isAdmin;
|
||||||
if (!shouldUseAdminData && !shouldUseEstimate) {
|
|
||||||
setUserCountState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
totalUsers: null,
|
|
||||||
weeklyActiveUsers: null,
|
|
||||||
loading: false,
|
|
||||||
source: 'unknown',
|
|
||||||
error: null,
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUserCountState((prev) => ({
|
setUserCountState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom'
|
import { Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '@app/auth/UseSession'
|
import { useAuth } from '@app/auth/UseSession'
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext'
|
import { useAppConfig } from '@app/contexts/AppConfigContext'
|
||||||
import HomePage from '@app/pages/HomePage'
|
import HomePage from '@app/pages/HomePage'
|
||||||
// Login component is used via routing, not directly imported
|
|
||||||
import FirstLoginModal from '@app/components/shared/FirstLoginModal'
|
|
||||||
import { accountService } from '@app/services/accountService'
|
|
||||||
import { useBackendProbe } from '@app/hooks/useBackendProbe'
|
import { useBackendProbe } from '@app/hooks/useBackendProbe'
|
||||||
import AuthLayout from '@app/routes/authShared/AuthLayout'
|
import AuthLayout from '@app/routes/authShared/AuthLayout'
|
||||||
import LoginHeader from '@app/routes/login/LoginHeader'
|
import LoginHeader from '@app/routes/login/LoginHeader'
|
||||||
@@ -19,15 +16,12 @@ import { useTranslation } from 'react-i18next'
|
|||||||
* If user is not authenticated: Show Login or redirect to /login
|
* If user is not authenticated: Show Login or redirect to /login
|
||||||
*/
|
*/
|
||||||
export default function Landing() {
|
export default function Landing() {
|
||||||
const { session, loading: authLoading, refreshSession } = useAuth();
|
const { session, loading: authLoading } = useAuth();
|
||||||
const { config, loading: configLoading, refetch } = useAppConfig();
|
const { config, loading: configLoading, refetch } = useAppConfig();
|
||||||
const backendProbe = useBackendProbe();
|
const backendProbe = useBackendProbe();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isFirstLogin, setIsFirstLogin] = useState(false);
|
|
||||||
const [checkingFirstLogin, setCheckingFirstLogin] = useState(false);
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
|
|
||||||
const loading = authLoading || configLoading || backendProbe.loading;
|
const loading = authLoading || configLoading || backendProbe.loading;
|
||||||
|
|
||||||
@@ -51,42 +45,12 @@ export default function Landing() {
|
|||||||
return () => window.clearInterval(intervalId);
|
return () => window.clearInterval(intervalId);
|
||||||
}, [backendProbe.status, backendProbe.loginDisabled, backendProbe.probe, navigate, refetch]);
|
}, [backendProbe.status, backendProbe.loginDisabled, backendProbe.probe, navigate, refetch]);
|
||||||
|
|
||||||
// Check if user needs to change password on first login
|
|
||||||
useEffect(() => {
|
|
||||||
const checkFirstLogin = async () => {
|
|
||||||
if (session && config?.enableLogin !== false) {
|
|
||||||
try {
|
|
||||||
setCheckingFirstLogin(true)
|
|
||||||
const accountData = await accountService.getAccountData()
|
|
||||||
setUsername(accountData.username)
|
|
||||||
setIsFirstLogin(accountData.changeCredsFlag)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to check first login status:', err)
|
|
||||||
// If account endpoint fails (404), user probably doesn't have security enabled
|
|
||||||
setIsFirstLogin(false)
|
|
||||||
} finally {
|
|
||||||
setCheckingFirstLogin(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkFirstLogin()
|
|
||||||
}, [session, config])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (backendProbe.status === 'up') {
|
if (backendProbe.status === 'up') {
|
||||||
void refetch();
|
void refetch();
|
||||||
}
|
}
|
||||||
}, [backendProbe.status, refetch]);
|
}, [backendProbe.status, refetch]);
|
||||||
|
|
||||||
const handlePasswordChanged = async () => {
|
|
||||||
// After password change, backend logs out the user
|
|
||||||
// Refresh session to detect logout and redirect to login
|
|
||||||
setIsFirstLogin(false) // Close modal first
|
|
||||||
await refreshSession()
|
|
||||||
// The auth system will automatically redirect to login when session is null
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Landing] State:', {
|
console.log('[Landing] State:', {
|
||||||
pathname: location.pathname,
|
pathname: location.pathname,
|
||||||
loading,
|
loading,
|
||||||
@@ -95,7 +59,7 @@ export default function Landing() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Show loading while checking auth and config
|
// Show loading while checking auth and config
|
||||||
if (loading || checkingFirstLogin) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -154,17 +118,9 @@ export default function Landing() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we have a session, show the main app
|
// If we have a session, show the main app
|
||||||
|
// Note: First login password change is now handled by the onboarding flow
|
||||||
if (session) {
|
if (session) {
|
||||||
return (
|
return <HomePage />;
|
||||||
<>
|
|
||||||
<FirstLoginModal
|
|
||||||
opened={isFirstLogin}
|
|
||||||
onPasswordChanged={handlePasswordChanged}
|
|
||||||
username={username}
|
|
||||||
/>
|
|
||||||
<HomePage />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No session - redirect to login page
|
// No session - redirect to login page
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Login from '@app/routes/Login';
|
|||||||
import { useAuth } from '@app/auth/UseSession';
|
import { useAuth } from '@app/auth/UseSession';
|
||||||
import { springAuth } from '@app/auth/springAuthClient';
|
import { springAuth } from '@app/auth/springAuthClient';
|
||||||
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
|
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
|
||||||
import { OnboardingProvider } from '@app/contexts/OnboardingContext';
|
|
||||||
|
|
||||||
// Mock i18n to return fallback text
|
// Mock i18n to return fallback text
|
||||||
vi.mock('react-i18next', () => ({
|
vi.mock('react-i18next', () => ({
|
||||||
@@ -67,7 +66,7 @@ vi.mock('react-router-dom', async () => {
|
|||||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
<OnboardingProvider>{children}</OnboardingProvider>
|
{children}
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -129,8 +129,12 @@ const SIMULATION_SCENARIOS: SimulationScenario[] = [
|
|||||||
...BASE_LOGIN_CONFIG,
|
...BASE_LOGIN_CONFIG,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
},
|
},
|
||||||
adminUsage: {
|
// Non-admin users use WAU estimate (not adminUsage)
|
||||||
totalUsers: 3,
|
wau: {
|
||||||
|
trackingSince: '2025-11-18T23:20:12.520884200Z',
|
||||||
|
daysOnline: 0,
|
||||||
|
totalUniqueBrowsers: 3,
|
||||||
|
weeklyActiveUsers: 3,
|
||||||
},
|
},
|
||||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||||
},
|
},
|
||||||
@@ -151,8 +155,12 @@ const SIMULATION_SCENARIOS: SimulationScenario[] = [
|
|||||||
...BASE_LOGIN_CONFIG,
|
...BASE_LOGIN_CONFIG,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
},
|
},
|
||||||
adminUsage: {
|
// Non-admin users use WAU estimate (not adminUsage)
|
||||||
totalUsers: 12,
|
wau: {
|
||||||
|
trackingSince: '2025-09-01T00:00:00Z',
|
||||||
|
daysOnline: 30,
|
||||||
|
totalUniqueBrowsers: 12,
|
||||||
|
weeklyActiveUsers: 9,
|
||||||
},
|
},
|
||||||
licenseInfo: { ...FREE_LICENSE_INFO },
|
licenseInfo: { ...FREE_LICENSE_INFO },
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user