Feature/onboarding slides (#4955)

# Description of Changes

- Added onboarding slides/upgrade banner conditions for all the
following cases
  - 'licensed'
  - 'no-login-user-under-limit-no-license'
  - 'no-login-admin-under-limit-no-license'
  - 'no-login-user-over-limit-no-license'
  - 'no-login-admin-over-limit-no-license'
  - 'login-user-under-limit-no-license'
  - 'login-admin-under-limit-no-license'
  - 'login-user-over-limit-no-license'
  - 'login-admin-over-limit-no-license';


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
EthanHealy01
2025-11-25 13:45:02 +00:00
committed by GitHub
co-authored by Anthony Stirling Connor Yoh
parent 80f2980755
commit a8db2fda18
66 changed files with 5641 additions and 1248 deletions
+32 -29
View File
@@ -17,6 +17,7 @@ import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContex
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import { CookieConsentProvider } from "@app/contexts/CookieConsentContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
@@ -57,35 +58,37 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
<CookieConsentProvider>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</CookieConsentProvider>
</AppConfigProvider>
</OnboardingProvider>
</BannerProvider>
@@ -0,0 +1,277 @@
.heroWrapper {
position: relative;
width: 100%;
height: 220px;
overflow: hidden;
}
.heroLogo {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: heroLogoEnter 0.25s ease forwards;
z-index: 20;
}
.heroLogoCircle {
width: 96px;
height: 96px;
border-radius: 50%;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18);
animation: heroLogoScale 0.25s ease forwards;
}
.heroLogoCircle img {
width: 60px;
height: 60px;
animation: heroLogoRotate 0.25s ease forwards;
transform-origin: center;
}
.standaloneIcon {
width: 96px;
height: 96px;
object-fit: contain;
animation: heroLogoScale 0.25s ease forwards;
}
.securitySlideContent {
display: flex;
justify-content: center;
margin-top: 12px;
}
.securityCard {
width: 100%;
max-width: 480px;
background: var(--bg-surface, #ffffff);
border-radius: 16px;
padding: 20px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
display: flex;
flex-direction: column;
gap: 16px;
}
.securityAlertRow {
display: flex;
align-items: flex-start;
gap: 10px;
font-weight: 500;
font-size: 15px;
line-height: 1.5;
color: var(--onboarding-body, #1f2937);
}
.heroIconsContainer {
display: flex;
gap: 32px;
align-items: flex-start;
justify-content: center;
animation: heroLogoEnter 0.25s ease forwards;
position: relative;
top: 1rem;
}
.iconWrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.iconButton {
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: transform 0.2s ease, opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative;
border-radius: 12px;
padding: 4px;
outline: none;
}
.iconButton:focus {
outline: none;
}
.iconButton:focus-visible {
outline: none;
}
.iconButton:hover {
transform: scale(1.05);
opacity: 0.9;
}
.iconButton:active {
transform: scale(0.95);
}
.iconButtonSelected {
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.4);
}
.downloadIcon {
width: 96px;
height: 96px;
object-fit: contain;
animation: heroLogoScale 0.25s ease forwards;
}
.iconLabel {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
text-align: center;
animation: heroLogoEnter 0.25s ease forwards;
}
.title {
text-align: center;
opacity: 0;
transform: translateX(24px);
animation: bodySlideIn 0.25s ease forwards;
}
.bodyCopy {
opacity: 0;
transform: translateX(24px);
animation: bodySlideIn 0.25s ease forwards;
}
@keyframes heroLogoEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes heroLogoScale {
from {
transform: scale(0.6);
}
to {
transform: scale(1);
}
}
@keyframes heroLogoRotate {
from {
transform: rotate(-90deg) scale(0.9);
}
to {
transform: rotate(0deg) scale(1);
}
}
@keyframes bodySlideIn {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Dev overlay styles */
.devOverlay {
position: absolute;
bottom: 8px;
left: 10px;
display: flex;
gap: 6px;
z-index: 10;
}
.devButton {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
opacity: 0.8;
border: 1px solid rgba(255, 255, 255, 0.4);
background: rgba(0, 0, 0, 0.35);
color: #fff;
box-shadow: none;
}
.devButtonActive {
opacity: 1;
border: 1px solid rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.9);
color: #1F2933;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.7);
}
/* Modal content styles */
.modalContent {
background: var(--bg-surface);
position: relative;
}
.modalBody {
padding: 24px;
}
/* Title styles */
.titleText {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-weight: 600;
font-size: 22px;
color: var(--onboarding-title);
}
/* Body text styles */
.bodyText {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
font-size: 16px;
color: var(--onboarding-body);
line-height: 1.5;
}
.bodyCopyInner {
color: inherit;
}
/* Button margin */
.buttonContainer {
margin-top: 8px;
}
/* Welcome slide V2 badge */
.welcomeTitleContainer {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.v2Badge {
background: #DBEFFF;
color: #2A4BFF;
padding: 4px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
}
/* Icon styles */
.heroIcon {
color: #000000;
}
@@ -0,0 +1,33 @@
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,
};
}
@@ -0,0 +1,119 @@
import React from 'react';
import { Modal, Stack } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
import LocalIcon from '@app/components/shared/LocalIcon';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
import { renderButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
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 { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
const flow = useInitialOnboardingState(props);
if (!flow) {
return null;
}
const {
state,
totalSteps,
currentSlide,
slideDefinition,
licenseNotice,
flowState,
closeAndMarkSeen,
handleButtonAction,
} = flow;
const renderHero = () => {
if (slideDefinition.hero.type === 'dual-icon') {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img src={`${BASE_PATH}/branding/StirlingLogoLegacy.svg`} alt="Stirling icon" className={styles.downloadIcon} />
</div>
</div>
);
}
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === 'rocket' && (
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'shield' && (
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
)}
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
{slideDefinition.hero.type === 'logo' && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
)}
</div>
);
};
return (
<Modal
opened={props.opened}
onClose={closeAndMarkSeen}
closeOnClickOutside={false}
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} className={styles.modalContent}>
<div className={styles.heroWrapper}>
<AnimatedSlideBackground
gradientStops={currentSlide.background.gradientStops}
circles={currentSlide.background.circles}
isActive
slideKey={currentSlide.key}
/>
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
{renderHero()}
</div>
</div>
<div className={styles.modalBody}>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{currentSlide.title}
</div>
<div className={styles.bodyText}>
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{currentSlide.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper totalSteps={totalSteps} activeStep={state.step} />
<div className={styles.buttonContainer}>
{renderButtons({
slideDefinition,
licenseNotice,
flowState,
onAction: handleButtonAction,
})}
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -0,0 +1,109 @@
import React from 'react';
import { Button, Group, ActionIcon } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import { useTranslation } from 'react-i18next';
import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig';
import type { LicenseNotice } from '@app/types/types';
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
interface RenderButtonsProps {
slideDefinition: {
buttons: ButtonDefinition[];
id: string;
};
licenseNotice: LicenseNotice;
flowState: FlowState;
onAction: (action: ButtonAction) => void;
}
export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) {
const { t } = useTranslation();
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
const buttonStyles = (variant: ButtonDefinition['variant']) =>
variant === 'primary'
? {
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
}
: {
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
};
const resolveButtonLabel = (button: ButtonDefinition) => {
// Special case: override "See Plans" with "Upgrade now" when over limit
if (
button.type === 'button' &&
slideDefinition.id === 'server-license' &&
button.action === 'see-plans' &&
licenseNotice.isOverLimit
) {
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
}
// Translate the label (it's a translation key)
const label = button.label ?? '';
if (!label) return '';
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
const fallback = label.split('.').pop() || label;
return t(label, fallback);
};
const renderButton = (button: ButtonDefinition) => {
const disabled = button.disabledWhen?.(flowState) ?? false;
if (button.type === 'icon') {
return (
<ActionIcon
key={button.key}
onClick={() => onAction(button.action)}
radius="md"
size={40}
disabled={disabled}
styles={{
root: {
background: 'var(--onboarding-secondary-button-bg)',
border: '1px solid var(--onboarding-secondary-button-border)',
color: 'var(--onboarding-secondary-button-text)',
},
}}
>
{button.icon === 'chevron-left' && <ChevronLeftIcon fontSize="small" />}
</ActionIcon>
);
}
const variant = button.variant ?? 'secondary';
const label = resolveButtonLabel(button);
return (
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
{label}
</Button>
);
};
if (leftButtons.length === 0) {
return <Group justify="flex-end">{rightButtons.map(renderButton)}</Group>;
}
if (rightButtons.length === 0) {
return <Group justify="flex-start">{leftButtons.map(renderButton)}</Group>;
}
return (
<Group justify="space-between">
<Group gap={12}>{leftButtons.map(renderButton)}</Group>
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group>
);
}
@@ -0,0 +1,21 @@
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,
};
@@ -0,0 +1,381 @@
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,52 @@
import React from 'react';
interface OnboardingStepperProps {
totalSteps: number;
activeStep: number; // 0-indexed
className?: string;
}
/**
* Renders a progress indicator where the active step is a pill and others are dots.
* Colors come from theme.css variables.
*/
export function OnboardingStepper({ totalSteps, activeStep, className }: OnboardingStepperProps) {
const items = Array.from({ length: totalSteps }, (_, index) => index);
return (
<div
className={className}
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
}}
>
{items.map((index) => {
const isActive = index === activeStep;
const baseStyles: React.CSSProperties = {
background: isActive
? 'var(--onboarding-step-active)'
: 'var(--onboarding-step-inactive)',
};
return (
<div
key={index}
style={{
...baseStyles,
width: isActive ? 44 : 8,
height: 8,
borderRadius: 9999,
}}
/>
);
})}
</div>
);
}
export default OnboardingStepper;
@@ -1,105 +1,29 @@
import React, { useMemo } from "react";
import { TourProvider, useTour, type StepType } from '@reactour/tour';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import React, { useEffect, useMemo } from "react";
import { TourProvider, type StepType } from '@reactour/tour';
import { useTranslation } from 'react-i18next';
import { CloseButton, ActionIcon } from '@mantine/core';
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 ArrowForwardIcon from '@mui/icons-material/ArrowForward';
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 CheckIcon from '@mui/icons-material/Check';
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
import '@app/components/onboarding/OnboardingTour.css';
import i18n from "@app/i18n";
// Enum case order defines order steps will appear
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,
}
enum AdminTourStep {
WELCOME,
CONFIG_BUTTON,
SETTINGS_OVERVIEW,
TEAMS_AND_USERS,
SYSTEM_CUSTOMIZATION,
DATABASE_SECTION,
CONNECTIONS_SECTION,
ADMIN_TOOLS,
WRAP_UP,
}
function TourContent() {
const { isOpen } = useOnboarding();
const { setIsOpen, setCurrentStep } = useTour();
const previousIsOpenRef = React.useRef(isOpen);
// Sync tour open state with context and reset to step 0 when reopening
React.useEffect(() => {
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
previousIsOpenRef.current = isOpen;
if (wasClosedNowOpen) {
// Tour is being opened (Help button pressed), reset to first step
setCurrentStep(0);
}
setIsOpen(isOpen);
}, [isOpen, setIsOpen, setCurrentStep]);
return null;
}
export default function OnboardingTour() {
const { t } = useTranslation();
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding();
const flow = useOnboardingFlow();
const { openFilesModal, closeFilesModal } = useFilesModalContext();
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
// Helper to add glow to multiple elements
const addGlowToElements = (selectors: string[]) => {
selectors.forEach(selector => {
const element = document.querySelector(selector);
if (element) {
if (selector === '[data-tour="settings-content-area"]') {
element.classList.add('tour-content-glow');
} else {
element.classList.add('tour-nav-glow');
}
}
});
};
// Helper to remove all glows
const removeAllGlows = () => {
document.querySelectorAll('.tour-content-glow').forEach(el => el.classList.remove('tour-content-glow'));
document.querySelectorAll('.tour-nav-glow').forEach(el => el.classList.remove('tour-nav-glow'));
};
// Cleanup glows when tour closes
React.useEffect(() => {
if (!isOpen) {
removeAllGlows();
}
return () => removeAllGlows();
}, [isOpen]);
const {
saveWorkbenchState,
restoreWorkbenchState,
@@ -122,258 +46,78 @@ export default function OnboardingTour() {
scrollNavToSection,
} = useAdminTourOrchestration();
// Define steps as object keyed by enum - TypeScript ensures all keys are present
const stepsConfig: Record<TourStep, StepType> = useMemo(() => ({
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
position: 'center',
padding: 0,
action: () => {
saveWorkbenchState();
closeFilesModal();
backToAllTools();
},
},
[TourStep.SELECT_CROP_TOOL]: {
selector: '[data-tour="tool-button-crop"]',
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
position: 'right',
padding: 0,
actionAfter: () => selectCropTool(),
},
[TourStep.TOOL_INTERFACE]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
position: 'center',
padding: 0,
},
[TourStep.FILES_BUTTON]: {
selector: '[data-tour="files-button"]',
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
position: 'right',
padding: 10,
action: () => openFilesModal(),
},
[TourStep.FILE_SOURCES]: {
selector: '[data-tour="file-sources"]',
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
position: 'right',
padding: 0,
actionAfter: () => {
loadSampleFile();
closeFilesModal();
}
},
[TourStep.WORKBENCH]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
position: 'center',
padding: 0,
},
[TourStep.VIEW_SWITCHER]: {
selector: '[data-tour="view-switcher"]',
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
position: 'bottom',
padding: 0,
},
[TourStep.VIEWER]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
position: 'center',
padding: 0,
action: () => switchToViewer(),
},
[TourStep.PAGE_EDITOR]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
position: 'center',
padding: 0,
action: () => switchToPageEditor(),
},
[TourStep.ACTIVE_FILES]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
position: 'center',
padding: 0,
action: () => switchToActiveFiles(),
},
[TourStep.FILE_CHECKBOX]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
position: 'top',
padding: 10,
},
[TourStep.SELECT_CONTROLS]: {
selector: '[data-tour="right-rail-controls"]',
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
position: 'left',
padding: 5,
action: () => selectFirstFile(),
},
[TourStep.CROP_SETTINGS]: {
selector: '[data-tour="crop-settings"]',
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
position: 'left',
padding: 10,
action: () => modifyCropSettings(),
},
[TourStep.RUN_BUTTON]: {
selector: '[data-tour="run-button"]',
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
position: 'top',
padding: 10,
actionAfter: () => executeTool(),
},
[TourStep.RESULTS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
position: 'center',
padding: 0,
},
[TourStep.FILE_REPLACEMENT]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
position: 'left',
padding: 10,
},
[TourStep.PIN_BUTTON]: {
selector: '[data-tour="file-card-pin"]',
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
position: 'left',
padding: 10,
action: () => pinFile(),
},
[TourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
position: 'right',
padding: 10,
},
}), [t]);
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
// Define admin tour steps
const adminStepsConfig: Record<AdminTourStep, StepType> = useMemo(() => ({
[AdminTourStep.WELCOME]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
position: 'right',
padding: 10,
action: () => {
saveAdminState();
},
},
[AdminTourStep.CONFIG_BUTTON]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
position: 'right',
padding: 10,
actionAfter: () => {
openConfigModal();
},
},
[AdminTourStep.SETTINGS_OVERVIEW]: {
selector: '.modal-nav',
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
position: 'right',
padding: 0,
action: () => {
removeAllGlows();
},
},
[AdminTourStep.TEAMS_AND_USERS]: {
selector: '[data-tour="admin-people-nav"]',
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('people');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
selector: '[data-tour="admin-adminGeneral-nav"]',
highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminGeneral');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.DATABASE_SECTION]: {
selector: '[data-tour="admin-adminDatabase-nav"]',
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminDatabase');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.CONNECTIONS_SECTION]: {
selector: '[data-tour="admin-adminConnections-nav"]',
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminConnections');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
actionAfter: async () => {
// Scroll for the NEXT step before it shows
await scrollNavToSection('adminAudit');
},
},
[AdminTourStep.ADMIN_TOOLS]: {
selector: '[data-tour="admin-adminAudit-nav"]',
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
position: 'right',
padding: 10,
action: () => {
// Just navigate, scroll already happened in previous step
removeAllGlows();
navigateToSection('adminAudit');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
},
},
}), [t]);
useEffect(() => {
if (!flow.isTourOpen) {
removeAllGlows();
}
return () => removeAllGlows();
}, [flow.isTourOpen]);
// Select steps based on tour type
const steps = tourType === 'admin'
? Object.values(adminStepsConfig)
: Object.values(stepsConfig);
const userStepsConfig = useMemo(
() =>
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 advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: {
const adminStepsConfig = useMemo(
() =>
createAdminStepsConfig({
t,
actions: {
saveAdminState,
openConfigModal,
navigateToSection,
scrollNavToSection,
},
}),
[navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
);
const steps = useMemo<StepType[]>(() => {
const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
return Object.values(config);
}, [adminStepsConfig, flow.tourType, userStepsConfig]);
const advanceTour = ({
setCurrentStep,
currentStep,
steps,
setIsOpen,
}: {
setCurrentStep: (value: number | ((prev: number) => number)) => void;
currentStep: number;
steps?: StepType[];
@@ -381,12 +125,12 @@ export default function OnboardingTour() {
}) => {
if (steps && currentStep === steps.length - 1) {
setIsOpen(false);
if (tourType === 'admin') {
if (flow.tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
completeTour();
flow.handleTourCompletion();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
@@ -394,34 +138,22 @@ export default function OnboardingTour() {
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
setIsOpen(false);
if (tourType === 'admin') {
if (flow.tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
completeTour();
flow.handleTourCompletion();
};
return (
<>
<TourWelcomeModal
opened={showWelcomeModal}
onStartTour={() => {
setShowWelcomeModal(false);
startTour();
}}
onMaybeLater={() => {
setShowWelcomeModal(false);
}}
onDontShowAgain={() => {
setShowWelcomeModal(false);
completeTour();
}}
/>
<InitialOnboardingModal {...flow.initialModalProps} />
<ToolPanelModePrompt onComplete={flow.handleToolPromptComplete} />
<TourProvider
key={`${tourType}-${i18n.language}`}
key={`${flow.tourType}-${i18n.language}`}
steps={steps}
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
maskClassName={flow.maskClassName}
onClickClose={handleCloseTour}
onClickMask={advanceTour}
onClickHighlighted={(e, clickProps) => {
@@ -429,13 +161,10 @@ export default function OnboardingTour() {
advanceTour(clickProps);
}}
keyboardHandler={(e, clickProps, status) => {
// Handle right arrow key to advance tour
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
e.preventDefault();
advanceTour(clickProps);
}
// Handle escape key to close tour
else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
e.preventDefault();
handleCloseTour(clickProps);
}
@@ -487,22 +216,16 @@ export default function OnboardingTour() {
}}
components={{
Close: ({ onClick }) => (
<CloseButton
onClick={onClick}
size="md"
style={{ position: 'absolute', top: '8px', right: '8px' }}
/>
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
),
Content: ({ content } : {content: string}) => (
<div
style={{ paddingRight: '16px' /* Ensure text doesn't overlap with close button */ }}
dangerouslySetInnerHTML={{ __html: content }}
/>
Content: ({ content }: { content: string }) => (
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
),
}}
>
<TourContent />
</TourProvider>
<ServerLicenseModal {...flow.serverLicenseModalProps} />
</>
);
}
@@ -0,0 +1,118 @@
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.heroLogoCircle}>
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
</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>
);
}
@@ -0,0 +1,22 @@
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;
}
@@ -0,0 +1,133 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import { AdminTourStep } from '@app/components/onboarding/tourSteps';
import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
interface AdminStepActions {
saveAdminState: () => void;
openConfigModal: () => void;
navigateToSection: (section: string) => void;
scrollNavToSection: (section: string) => Promise<void> | void;
}
interface CreateAdminStepsConfigArgs {
t: TFunction;
actions: AdminStepActions;
}
export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions;
return {
[AdminTourStep.WELCOME]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
position: 'right',
padding: 10,
action: () => {
saveAdminState();
},
},
[AdminTourStep.CONFIG_BUTTON]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
position: 'right',
padding: 10,
actionAfter: () => {
openConfigModal();
},
},
[AdminTourStep.SETTINGS_OVERVIEW]: {
selector: '.modal-nav',
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
position: 'right',
padding: 0,
action: () => {
removeAllGlows();
},
},
[AdminTourStep.TEAMS_AND_USERS]: {
selector: '[data-tour="admin-people-nav"]',
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('people');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
selector: '[data-tour="admin-adminGeneral-nav"]',
highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminGeneral');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.DATABASE_SECTION]: {
selector: '[data-tour="admin-adminDatabase-nav"]',
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminDatabase');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.CONNECTIONS_SECTION]: {
selector: '[data-tour="admin-adminConnections-nav"]',
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminConnections');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
actionAfter: async () => {
await scrollNavToSection('adminAudit');
},
},
[AdminTourStep.ADMIN_TOOLS]: {
selector: '[data-tour="admin-adminAudit-nav"]',
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminAudit');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
},
},
};
}
@@ -0,0 +1,324 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import type { LicenseNotice } from '@app/types/types';
import { useNavigate } 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';
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 } = useAppConfig();
const { showCookieConsent, isReady: isCookieConsentReady } = useCookieConsentContext();
const { completeTour, tourType, isOpen } = useOnboarding();
const shouldShowIntro = !preferences.hasSeenIntroOnboarding;
const isAdminUser = !!config?.isAdmin;
const { hasPaidLicense } = useServerExperience();
const [licenseNotice, setLicenseNotice] = useState<LicenseNotice>({
totalUsers: null,
freeTierLimit: 5,
isOverLimit: false,
requiresLicense: false,
});
const [cookieBannerPending, setCookieBannerPending] = useState(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 maybeShowCookieBanner = useCallback(() => {
if (preferences.hasSeenCookieBanner) {
return;
}
if (!isCookieConsentReady || isServerLicenseOpen || serverLicenseIntent !== 'idle' || !toolPromptCompleted) {
setCookieBannerPending(true);
return;
}
setCookieBannerPending(false);
showCookieConsent();
updatePreference('hasSeenCookieBanner', true);
}, [
isCookieConsentReady,
isServerLicenseOpen,
preferences.hasSeenCookieBanner,
serverLicenseIntent,
showCookieConsent,
toolPromptCompleted,
updatePreference,
]);
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(() => {
if (
cookieBannerPending &&
isCookieConsentReady &&
serverLicenseIntent === 'idle' &&
!isServerLicenseOpen &&
toolPromptCompleted
) {
maybeShowCookieBanner();
}
}, [
cookieBannerPending,
isCookieConsentReady,
isServerLicenseOpen,
serverLicenseIntent,
toolPromptCompleted,
maybeShowCookieBanner,
]);
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);
maybeShowCookieBanner();
}, [maybeShowCookieBanner]);
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'));
}
maybeShowCookieBanner();
}, [
completeTour,
isAdminUser,
maybeShowCookieBanner,
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,
};
}
@@ -0,0 +1,207 @@
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import { SlideConfig, LicenseNotice } from '@app/types/types';
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',
},
],
},
'desktop-install': {
id: 'desktop-install',
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: 'dual-icon' },
buttons: [
{
key: 'desktop-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{
key: 'desktop-skip',
type: 'button',
label: 'onboarding.buttons.skipForNow',
variant: 'secondary',
group: 'left',
action: 'next',
},
{
key: 'desktop-download',
type: 'button',
label: 'onboarding.buttons.download',
variant: 'primary',
group: 'right',
action: 'download-selected',
},
],
},
'security-check': {
id: 'security-check',
createSlide: ({ selectedRole, onRoleSelect }) =>
SecurityCheckSlide({ selectedRole, onRoleSelect }),
hero: { type: 'shield' },
buttons: [
{
key: 'security-back',
type: 'button',
label: 'onboarding.buttons.back',
variant: 'secondary',
group: 'left',
action: 'prev',
},
{
key: 'security-next',
type: 'button',
label: 'onboarding.buttons.next',
variant: 'primary',
group: 'right',
action: 'security-next',
disabledWhen: (state) => !state.selectedRole,
},
],
},
'admin-overview': {
id: 'admin-overview',
createSlide: ({ licenseNotice, loginEnabled }) =>
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
hero: { type: 'diamond' },
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: 'logo' },
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',
},
],
},
};
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,70 @@
.hero {
position: relative;
width: 100%;
height: 220px;
overflow: hidden;
border-radius: 0;
}
.gradientLayer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: 180% 180%;
opacity: 0;
transition: opacity 0.8s ease-in-out;
z-index: 1;
}
.gradientLayerActive {
opacity: 1;
animation: gradientShift 18s ease-in-out infinite alternate;
z-index: 2;
}
.gradientLayerPrev {
opacity: 1;
z-index: 3;
transition: opacity 0.8s ease-in-out;
}
.gradientLayerPrevFadeOut {
opacity: 0;
z-index: 3;
transition: opacity 0.8s ease-in-out;
}
.circle {
position: absolute;
border-radius: 50%;
pointer-events: none;
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12);
animation-name: circleSway;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
animation-duration: var(--circle-duration, 15s);
animation-delay: var(--circle-delay, 0s);
will-change: transform;
z-index: 10;
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}
@keyframes circleSway {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(var(--circle-move-x, 40px), var(--circle-move-y, 24px), 0);
}
}
@@ -0,0 +1,115 @@
import React from 'react';
import styles from '@app/components/onboarding/slides/AnimatedSlideBackground.module.css';
import { AnimatedSlideBackgroundProps } from '@app/types/types';
type CircleStyles = React.CSSProperties & {
'--circle-move-x'?: string;
'--circle-move-y'?: string;
'--circle-duration'?: string;
'--circle-delay'?: string;
};
interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps {
isActive: boolean;
slideKey: string;
}
export default function AnimatedSlideBackground({
gradientStops,
circles,
isActive,
}: AnimatedSlideBackgroundComponentProps) {
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
const [isTransitioning, setIsTransitioning] = React.useState(false);
const isFirstMount = React.useRef(true);
React.useEffect(() => {
// Skip transition on first mount
if (isFirstMount.current) {
isFirstMount.current = false;
setCurrentGradient(gradientStops);
return;
}
// Only transition if gradient actually changed
if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) {
// Store previous gradient and start transition
setPrevGradient(currentGradient);
setIsTransitioning(true);
// Update to new gradient (will fade in)
setCurrentGradient(gradientStops);
}
}, [gradientStops]);
const currentGradientStyle = React.useMemo(
() => ({
backgroundImage: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
}),
[currentGradient],
);
const prevGradientStyle = prevGradient
? {
backgroundImage: `linear-gradient(135deg, ${prevGradient[0]}, ${prevGradient[1]})`,
}
: null;
return (
<div className={styles.hero} key="animated-background">
{prevGradientStyle && isTransitioning && (
<div
className={`${styles.gradientLayer} ${styles.gradientLayerPrevFadeOut}`}
style={prevGradientStyle}
onTransitionEnd={() => {
setPrevGradient(null);
setIsTransitioning(false);
}}
/>
)}
<div
className={`${styles.gradientLayer} ${isActive ? styles.gradientLayerActive : ''}`.trim()}
style={currentGradientStyle}
/>
{circles.map((circle, index) => {
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
const moveX = position === 'bottom-left' ? amplitude : -amplitude;
const moveY = position === 'bottom-left' ? -amplitude * 0.6 : amplitude * 0.6;
const circleStyle: CircleStyles = {
width: size,
height: size,
background: color,
opacity: opacity ?? 0.9,
filter: blur ? `blur(${blur}px)` : undefined,
'--circle-move-x': `${moveX}px`,
'--circle-move-y': `${moveY}px`,
'--circle-duration': `${duration}s`,
'--circle-delay': `${delay}s`,
};
const defaultOffset = -size / 2;
const offsetX = circle.offsetX ?? 0;
const offsetY = circle.offsetY ?? 0;
if (position === 'bottom-left') {
circleStyle.left = `${defaultOffset + offsetX}px`;
circleStyle.bottom = `${defaultOffset + offsetY}px`;
} else {
circleStyle.right = `${defaultOffset + offsetX}px`;
circleStyle.top = `${defaultOffset + offsetY}px`;
}
return (
<div
key={`circle-${index}-${position}`}
className={styles.circle}
style={circleStyle}
/>
);
})}
</div>
);
}
@@ -0,0 +1,54 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import { DesktopInstallTitle, type OSOption } from '@app/components/onboarding/slides/DesktopInstallTitle';
export type { OSOption };
interface DesktopInstallSlideProps {
osLabel: string;
osUrl: string;
osOptions?: OSOption[];
onDownloadUrlChange?: (url: string) => void;
}
const DesktopInstallBody = () => {
const { t } = useTranslation();
return (
<span>
{t(
'onboarding.desktopInstall.body',
'Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.',
)}
</span>
);
};
export default function DesktopInstallSlide({
osLabel,
osUrl,
osOptions = [],
onDownloadUrlChange,
}: DesktopInstallSlideProps): SlideConfig {
return {
key: 'desktop-install',
title: (
<DesktopInstallTitle
osLabel={osLabel}
osUrl={osUrl}
osOptions={osOptions || []}
onDownloadUrlChange={onDownloadUrlChange}
/>
),
body: <DesktopInstallBody />,
downloadUrl: osUrl,
background: {
gradientStops: ['#2563EB', '#0EA5E9'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,93 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Menu, ActionIcon } from '@mantine/core';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
export interface OSOption {
label: string;
url: string;
value: string;
}
interface DesktopInstallTitleProps {
osLabel: string;
osUrl: string;
osOptions: OSOption[];
onDownloadUrlChange?: (url: string) => void;
}
export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
osLabel,
osUrl,
osOptions,
onDownloadUrlChange
}) => {
const { t } = useTranslation();
const [selectedOsUrl, setSelectedOsUrl] = React.useState<string>(osUrl);
React.useEffect(() => {
setSelectedOsUrl(osUrl);
}, [osUrl]);
const handleOsSelect = React.useCallback((option: OSOption) => {
setSelectedOsUrl(option.url);
onDownloadUrlChange?.(option.url);
}, [onDownloadUrlChange]);
const currentOsOption = osOptions.find(opt => opt.url === selectedOsUrl) ||
(osOptions.length > 0 ? osOptions[0] : { label: osLabel, url: osUrl });
const displayLabel = currentOsOption.label || osLabel;
const title = displayLabel
? t('onboarding.desktopInstall.titleWithOs', 'Download for {{osLabel}}', { osLabel: displayLabel })
: t('onboarding.desktopInstall.title', 'Download');
// If only one option or no options, don't show dropdown
if (osOptions.length <= 1) {
return <div style={{ textAlign: 'center', width: '100%' }}>{title}</div>;
}
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', width: '100%' }}>
<span style={{ whiteSpace: 'nowrap' }}>{title}</span>
<Menu position="bottom" offset={5} zIndex={10000}>
<Menu.Target>
<ActionIcon
variant="transparent"
size="sm"
style={{
background: 'transparent',
border: 'none',
color: 'inherit',
padding: 0
}}
>
<ExpandMoreIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{osOptions.map((option) => {
const isSelected = option.url === selectedOsUrl;
return (
<Menu.Item
key={option.url}
onClick={() => handleOsSelect(option)}
style={{
backgroundColor: isSelected
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
: 'transparent',
color: isSelected
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
: 'inherit',
}}
>
{option.label}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
</div>
);
};
@@ -0,0 +1,83 @@
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { SlideConfig, LicenseNotice } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
interface PlanOverviewSlideProps {
isAdmin: boolean;
licenseNotice?: LicenseNotice;
loginEnabled?: boolean;
}
const DEFAULT_FREE_TIER_LIMIT = 5;
const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
const { t } = useTranslation();
return (
<>
{isAdmin
? t('onboarding.planOverview.adminTitle', 'Admin Overview')
: t('onboarding.planOverview.userTitle', 'Plan Overview')}
</>
);
};
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({
freeTierLimit,
loginEnabled,
}) => {
const adminBodyKey = loginEnabled
? 'onboarding.planOverview.adminBodyLoginEnabled'
: 'onboarding.planOverview.adminBodyLoginDisabled';
const defaultValue = loginEnabled
? 'As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.'
: 'Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.';
return (
<Trans
i18nKey={adminBodyKey}
values={{ freeTierLimit }}
components={{ strong: <strong /> }}
defaults={defaultValue}
/>
);
};
const UserOverviewBody: React.FC = () => {
const { t } = useTranslation();
return (
<span>
{t(
'onboarding.planOverview.userBody',
"Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use.",
)}
</span>
);
};
const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; loginEnabled: boolean }> = ({
isAdmin,
freeTierLimit,
loginEnabled,
}) =>
isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />;
export default function PlanOverviewSlide({
isAdmin,
licenseNotice,
loginEnabled = false,
}: PlanOverviewSlideProps): SlideConfig {
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
return {
key: isAdmin ? 'admin-overview' : 'plan-overview',
title: <PlanOverviewTitle isAdmin={isAdmin} />,
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
background: {
gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,55 @@
import React from 'react';
import { Select } from '@mantine/core';
import { SlideConfig } from '@app/types/types';
import LocalIcon from '@app/components/shared/LocalIcon';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import i18n from '@app/i18n';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
interface SecurityCheckSlideProps {
selectedRole: 'admin' | 'user' | null;
onRoleSelect: (role: 'admin' | 'user' | null) => void;
}
export default function SecurityCheckSlide({
selectedRole,
onRoleSelect,
}: SecurityCheckSlideProps): SlideConfig {
return {
key: 'security-check',
title: 'Security Check',
body: (
<div className={styles.securitySlideContent}>
<div className={styles.securityCard}>
<div className={styles.securityAlertRow}>
<LocalIcon icon="error" width={20} height={20} style={{ color: '#F04438', flexShrink: 0 }} />
<span>{i18n.t('onboarding.securityCheck.message', 'The application has undergone significant changes recently. Your server admin\'s attention may be required. Please confirm your role to continue.')}</span>
</div>
<Select
placeholder="Confirm your role"
value={selectedRole}
data={[
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
]}
onChange={(value) => onRoleSelect((value as 'admin' | 'user') ?? null)}
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
styles={{
input: {
height: 48,
fontSize: 15,
},
}}
/>
</div>
</div>
),
background: {
gradientStops: ['#5B21B6', '#2563EB'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,59 @@
import React from 'react';
import { Trans } from 'react-i18next';
import { SlideConfig, LicenseNotice } from '@app/types/types';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
import i18n from '@app/i18n';
interface ServerLicenseSlideProps {
licenseNotice?: LicenseNotice;
}
const DEFAULT_FREE_TIER_LIMIT = 5;
export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlideProps = {}): SlideConfig {
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
const totalUsers = licenseNotice?.totalUsers ?? null;
const isOverLimit = licenseNotice?.isOverLimit ?? false;
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
const title = isOverLimit
? i18n.t('onboarding.serverLicense.overLimitTitle', 'Server License Needed')
: i18n.t('onboarding.serverLicense.freeTitle', 'Server License');
const key = isOverLimit ? 'server-license-over-limit' : 'server-license';
const overLimitBody = (
<Trans
i18nKey="onboarding.serverLicense.overLimitBody"
values={{ freeTierLimit, overLimitUserCopy }}
components={{
strong: <strong />,
}}
defaults="Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - <strong>unlimited seats</strong>, PDF text editing, and full admin control for $99/server/mo."
/>
);
const freeBody = (
<Trans
i18nKey="onboarding.serverLicense.freeBody"
values={{ freeTierLimit }}
components={{
strong: <strong />,
}}
defaults="Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted and get early access to our new <strong>PDF text editing tool</strong>, we recommend the Stirling Server plan - full editing and <strong>unlimited seats</strong> for $99/server/mo."
/>
);
const body = isOverLimit ? overLimitBody : freeBody;
return {
key,
title,
body,
background: {
gradientStops: isOverLimit ? ['#F472B6', '#8B5CF6'] : ['#F97316', '#F59E0B'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,39 @@
import React from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { SlideConfig } from '@app/types/types';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
function WelcomeSlideTitle() {
const { t } = useTranslation();
return (
<span className={styles.welcomeTitleContainer}>
{t('onboarding.welcomeSlide.title', 'Welcome to Stirling')}
<span className={styles.v2Badge}>V2</span>
</span>
);
}
const WelcomeSlideBody = () => (
<span>
<Trans
i18nKey="onboarding.welcomeSlide.body"
components={{ strong: <strong /> }}
defaults="Stirling PDF is now ready for teams of all sizes. This update includes a new layout, powerful new admin capabilities, and our most requested feature - <strong>Edit Text</strong>."
/>
</span>
);
export default function WelcomeSlide(): SlideConfig {
return {
key: 'welcome',
title: <WelcomeSlideTitle />,
body: <WelcomeSlideBody />,
background: {
gradientStops: ['#7C3AED', '#EC4899'],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,30 @@
import { AnimatedCircleConfig } from '@app/types/types';
/**
* Unified circle background configuration used across all onboarding slides.
* Only gradient colors change between slides, creating smooth transitions.
*/
export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
{
position: 'bottom-left',
size: 270,
color: 'rgba(255, 255, 255, 0.25)',
opacity: 0.9,
amplitude: 24,
duration: 4.5,
offsetX: 18,
offsetY: 14,
},
{
position: 'top-right',
size: 300,
color: 'rgba(255, 255, 255, 0.2)',
opacity: 0.9,
amplitude: 28,
duration: 4.5,
delay: 0.5,
offsetX: 24,
offsetY: 18,
},
];
@@ -0,0 +1,18 @@
export const addGlowToElements = (selectors: string[]) => {
selectors.forEach((selector) => {
const element = document.querySelector(selector);
if (element) {
if (selector === '[data-tour="settings-content-area"]') {
element.classList.add('tour-content-glow');
} else {
element.classList.add('tour-nav-glow');
}
}
});
};
export const removeAllGlows = () => {
document.querySelectorAll('.tour-content-glow').forEach((el) => el.classList.remove('tour-content-glow'));
document.querySelectorAll('.tour-nav-glow').forEach((el) => el.classList.remove('tour-nav-glow'));
};
@@ -0,0 +1,33 @@
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,173 @@
import type { StepType } from '@reactour/tour';
import type { TFunction } from 'i18next';
import { TourStep } from '@app/components/onboarding/tourSteps';
interface UserStepActions {
saveWorkbenchState: () => void;
closeFilesModal: () => void;
backToAllTools: () => void;
selectCropTool: () => void;
loadSampleFile: () => void;
switchToViewer: () => void;
switchToPageEditor: () => void;
switchToActiveFiles: () => void;
selectFirstFile: () => void;
pinFile: () => void;
modifyCropSettings: () => void;
executeTool: () => void;
openFilesModal: () => void;
}
interface CreateUserStepsConfigArgs {
t: TFunction;
actions: UserStepActions;
}
export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
const {
saveWorkbenchState,
closeFilesModal,
backToAllTools,
selectCropTool,
loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles,
selectFirstFile,
pinFile,
modifyCropSettings,
executeTool,
openFilesModal,
} = actions;
return {
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
position: 'center',
padding: 0,
action: () => {
saveWorkbenchState();
closeFilesModal();
backToAllTools();
},
},
[TourStep.SELECT_CROP_TOOL]: {
selector: '[data-tour="tool-button-crop"]',
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
position: 'right',
padding: 0,
actionAfter: () => selectCropTool(),
},
[TourStep.TOOL_INTERFACE]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
position: 'center',
padding: 0,
},
[TourStep.FILES_BUTTON]: {
selector: '[data-tour="files-button"]',
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
position: 'right',
padding: 10,
action: () => openFilesModal(),
},
[TourStep.FILE_SOURCES]: {
selector: '[data-tour="file-sources"]',
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
position: 'right',
padding: 0,
actionAfter: () => {
loadSampleFile();
closeFilesModal();
},
},
[TourStep.WORKBENCH]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
position: 'center',
padding: 0,
},
[TourStep.VIEW_SWITCHER]: {
selector: '[data-tour="view-switcher"]',
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
position: 'bottom',
padding: 0,
},
[TourStep.VIEWER]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
position: 'center',
padding: 0,
action: () => switchToViewer(),
},
[TourStep.PAGE_EDITOR]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
position: 'center',
padding: 0,
action: () => switchToPageEditor(),
},
[TourStep.ACTIVE_FILES]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
position: 'center',
padding: 0,
action: () => switchToActiveFiles(),
},
[TourStep.FILE_CHECKBOX]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
position: 'top',
padding: 10,
},
[TourStep.SELECT_CONTROLS]: {
selector: '[data-tour="right-rail-controls"]',
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
position: 'left',
padding: 5,
action: () => selectFirstFile(),
},
[TourStep.CROP_SETTINGS]: {
selector: '[data-tour="crop-settings"]',
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
position: 'left',
padding: 10,
action: () => modifyCropSettings(),
},
[TourStep.RUN_BUTTON]: {
selector: '[data-tour="run-button"]',
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
position: 'top',
padding: 10,
actionAfter: () => executeTool(),
},
[TourStep.RESULTS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
position: 'center',
padding: 0,
},
[TourStep.FILE_REPLACEMENT]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
position: 'left',
padding: 10,
},
[TourStep.PIN_BUTTON]: {
selector: '[data-tour="file-card-pin"]',
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
position: 'left',
padding: 10,
action: () => pinFile(),
},
[TourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
position: 'right',
padding: 10,
},
};
}
@@ -1,4 +1,13 @@
import { Modal, Stack, Button, Text, Title, Anchor } from '@mantine/core';
import {
Modal,
Stack,
Button,
Text,
Title,
Anchor,
useMantineTheme,
useComputedColorScheme,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Z_ANALYTICS_MODAL } from '@app/styles/zIndex';
@@ -15,6 +24,21 @@ export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnal
const { refetch } = useAppConfig();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const theme = useMantineTheme();
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
const isDark = computedColorScheme === 'dark';
const privacyHighlightStyles = {
color: isDark ? '#FFFFFF' : theme.colors.blue[7],
padding: `${theme.spacing.xs} ${theme.spacing.sm}`,
borderRadius: theme.radius.md,
fontWeight: 700,
textAlign: 'center' as const,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing.xs,
letterSpacing: 0.3,
};
const handleChoice = async (enableAnalytics: boolean) => {
setLoading(true);
@@ -60,7 +84,10 @@ export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnal
<Title order={2}>{t('analytics.title', 'Do you want make Stirling PDF better?')}</Title>
<Text size="sm" c="dimmed">
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents.')}
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product.')}
</Text>
<Text size="sm" style={privacyHighlightStyles}>
{t('analytics.privacyAssurance', 'We do not track any personal information or the contents of your files.')}
</Text>
<Text size="sm" c="dimmed">
@@ -1,5 +1,5 @@
import React, { useMemo, useState, useEffect } from 'react';
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
import { Modal, Text, ActionIcon, Tooltip, Group } from '@mantine/core';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
@@ -8,6 +8,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
import '@app/components/shared/AppConfigModal.css';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
interface AppConfigModalProps {
opened: boolean;
@@ -20,6 +21,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const navigate = useNavigate();
const location = useLocation();
const { config } = useAppConfig();
const licenseAlert = useLicenseAlert();
// Extract section from URL path (e.g., /settings/people -> people)
const getSectionFromPath = (pathname: string): NavKey | null => {
@@ -64,8 +66,8 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
headerBorder: 'var(--modal-header-border)',
}), []);
// Get isAdmin, runningEE, and loginEnabled from app config
const isAdmin = config?.isAdmin ?? false;
// Get isAdmin and runningEE from app config
const isAdmin = true // config?.isAdmin ?? false;
const runningEE = config?.runningEE ?? false;
const loginEnabled = config?.enableLogin ?? false;
@@ -138,6 +140,10 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const isDisabled = item.disabled ?? false;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
const showPlanWarning =
item.key === 'adminPlan' &&
licenseAlert.active &&
licenseAlert.audience === 'admin';
const navItemContent = (
<div
@@ -157,9 +163,19 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
{!isMobile && (
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
<Group gap={4} align="center" wrap="nowrap">
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
{showPlanWarning && (
<LocalIcon
icon="warning-rounded"
width={14}
height={14}
style={{ color: 'var(--mantine-color-orange-7)' }}
/>
)}
</Group>
)}
</div>
);
@@ -1,6 +1,6 @@
import { Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useCookieConsent } from '@app/hooks/useCookieConsent';
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
interface FooterProps {
privacyPolicy?: string;
@@ -20,7 +20,7 @@ export default function Footer({
analyticsEnabled = false
}: FooterProps) {
const { t } = useTranslation();
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
const { showCookiePreferences } = useCookieConsentContext();
// Helper to check if a value is valid (not null/undefined/empty string)
const isValidLink = (link?: string) => link && link.trim().length > 0;
@@ -1,16 +1,55 @@
import React from 'react';
import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core';
import React, { ReactNode } from 'react';
import { Paper, Group, Text, Button, ActionIcon, Stack } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
type InfoBannerTone = 'info' | 'warning';
const toneStyles: Record<
InfoBannerTone,
{
background: string;
border: string;
text: string;
icon: string;
buttonColor: string;
}
> = {
info: {
background: 'var(--mantine-color-blue-0)',
border: 'var(--mantine-color-blue-2)',
text: 'var(--mantine-color-blue-9)',
icon: 'var(--mantine-color-blue-6)',
buttonColor: 'blue',
},
warning: {
background: 'var(--mantine-color-orange-0)',
border: 'var(--mantine-color-orange-3)',
text: 'var(--mantine-color-orange-9)',
icon: 'var(--mantine-color-orange-7)',
buttonColor: 'orange',
},
};
interface InfoBannerProps {
icon: string;
message: string;
buttonText: string;
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick: () => void;
onDismiss: () => void;
onButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: InfoBannerTone;
background?: string;
borderColor?: string;
textColor?: string;
iconColor?: string;
buttonColor?: string;
buttonVariant?: 'light' | 'filled' | 'white' | 'outline' | 'subtle';
minHeight?: number | string;
closeIconColor?: string;
}
/**
@@ -18,55 +57,97 @@ interface InfoBannerProps {
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
onButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = 'info',
background,
borderColor,
textColor,
iconColor,
buttonColor,
buttonVariant = 'light',
minHeight = 56,
closeIconColor,
}) => {
if (!show) {
return null;
}
const toneStyle = toneStyles[tone] ?? toneStyles.info;
const handleDismiss = () => {
onDismiss?.();
};
return (
<Paper
p="sm"
radius={0}
style={{
background: 'var(--mantine-color-blue-0)',
borderBottom: '1px solid var(--mantine-color-blue-2)',
position: 'relative',
background: background ?? toneStyle.background,
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: 'flex',
alignItems: 'center',
}}
>
<Group gap="sm" align="center" wrap="nowrap">
<LocalIcon icon={icon} width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)', flexShrink: 0 }} />
<Text fw={500} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
{message}
</Text>
<Button
variant="light"
color="blue"
size="xs"
onClick={onButtonClick}
loading={loading}
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
style={{ flexShrink: 0 }}
>
{buttonText}
</Button>
<Group gap="sm" align="center" wrap="nowrap" justify="space-between" style={{ width: '100%' }}>
<Group gap="sm" align="center" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<LocalIcon
icon={icon}
width="1.2rem"
height="1.2rem"
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text fw={600} size="sm" style={{ color: textColor ?? toneStyle.text }}>
{title}
</Text>
)}
<Text
fw={title ? 400 : 500}
size="sm"
style={{ color: textColor ?? toneStyle.text }}
lineClamp={2}
>
{message}
</Text>
</Stack>
</Group>
<Group gap="xs" align="center" wrap="nowrap">
{buttonText && onButtonClick && (
<Button
variant={buttonVariant}
color={buttonColor ?? toneStyle.buttonColor}
size="xs"
onClick={onButtonClick}
loading={loading}
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
>
{buttonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="subtle"
color={closeIconColor ? undefined : 'gray'}
size="sm"
onClick={handleDismiss}
aria-label="Dismiss"
style={closeIconColor ? { color: closeIconColor } : undefined}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
)}
</Group>
</Group>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={onDismiss}
aria-label="Dismiss"
style={{ position: 'absolute', top: '50%', right: '0.5rem', transform: 'translateY(-50%)' }}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
</Paper>
);
};
@@ -1,5 +1,5 @@
import React, { useState, useRef, forwardRef, useEffect } from "react";
import { ActionIcon, Stack, Divider, Menu } from "@mantine/core";
import { ActionIcon, Stack, Divider, Menu, Indicator } from "@mantine/core";
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -16,6 +16,7 @@ import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolBu
import AppConfigModal from '@app/components/shared/AppConfigModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import {
isNavButtonActive,
@@ -34,6 +35,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const { startTour } = useOnboarding();
const licenseAlert = useLicenseAlert();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
@@ -314,9 +316,27 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
);
}
const buttonNode = renderNavButton(buttonConfig, index);
const shouldShowSettingsBadge =
buttonConfig.id === 'config' &&
licenseAlert.active &&
licenseAlert.audience === 'admin';
return (
<React.Fragment key={buttonConfig.id}>
{renderNavButton(buttonConfig, index)}
{shouldShowSettingsBadge ? (
<Indicator
inline
size={12}
color="orange"
position="top-end"
offset={4}
>
{buttonNode}
</Indicator>
) : (
buttonNode
)}
</React.Fragment>
);
})}
@@ -3,16 +3,31 @@ import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
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';
const ToolPanelModePrompt = () => {
interface ToolPanelModePromptProps {
onComplete?: () => void;
}
const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
const { t } = useTranslation();
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
const { preferences, updatePreference } = usePreferences();
const {
startTour,
startAfterToolModeSelection,
setStartAfterToolModeSelection,
pendingTourRequest,
} = useOnboarding();
const [opened, setOpened] = useState(false);
const { config } = useAppConfig();
const isAdmin = !!config?.isAdmin;
const shouldShowPrompt = !preferences.toolPanelModePromptSeen;
// Only show after the new 3-slide onboarding has been completed
const shouldShowPrompt = !preferences.toolPanelModePromptSeen && preferences.hasSeenIntroOnboarding;
useEffect(() => {
if (shouldShowPrompt) {
@@ -20,16 +35,56 @@ const ToolPanelModePrompt = () => {
}
}, [shouldShowPrompt]);
const resolveRequestedTourType = (): 'admin' | 'tools' => {
if (pendingTourRequest?.type) {
return pendingTourRequest.type;
}
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) => {
setToolPanelMode(mode);
updatePreference('defaultToolPanelMode', mode);
updatePreference('toolPanelModePromptSeen', true);
updatePreference('hasSelectedToolPanelMode', true);
setOpened(false);
resumeDeferredTour({ selection: mode });
onComplete?.();
};
const handleDismiss = () => {
const defaultMode: ToolPanelMode = 'sidebar';
if (toolPanelMode !== defaultMode) {
setToolPanelMode(defaultMode);
updatePreference('defaultToolPanelMode', defaultMode);
}
updatePreference('hasSelectedToolPanelMode', true);
updatePreference('toolPanelModePromptSeen', true);
setOpened(false);
resumeDeferredTour({ dismissed: true });
onComplete?.();
};
return (
+10
View File
@@ -0,0 +1,10 @@
// Centralized download URLs for Stirling PDF desktop installers
export const DOWNLOAD_URLS = {
WINDOWS: 'https://files.stirlingpdf.com/win-installer.exe',
MAC_APPLE_SILICON: 'https://files.stirlingpdf.com/mac-installer.dmg',
MAC_INTEL: 'https://files.stirlingpdf.com/mac-x86_64-installer.dmg',
LINUX_DOCS: 'https://docs.stirlingpdf.com/Installation/Unix%20Installation/',
} as const;
export const DOWNLOAD_BASE_URL = 'https://files.stirlingpdf.com/';
+27
View File
@@ -0,0 +1,27 @@
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 UPGRADE_BANNER_TEST_EVENT = 'stirling:upgrade-banner-test';
export const UPGRADE_BANNER_ALERT_EVENT = 'stirling:upgrade-banner-alert';
export interface ServerLicenseRequestPayload {
licenseNotice?: Partial<LicenseNotice>;
deferUntilTourComplete?: boolean;
selfReportedAdmin?: boolean;
}
export type UpgradeBannerTestScenario = 'friendly' | 'urgent-admin' | 'urgent-user' | null;
export interface UpgradeBannerTestPayload {
scenario: UpgradeBannerTestScenario;
}
export interface UpgradeBannerAlertPayload {
active: boolean;
audience?: 'admin' | 'user';
totalUsers?: number | null;
freeTierLimit?: number;
}
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import apiClient from '@app/services/apiClient';
import { getSimulatedAppConfig } from '@app/testing/serverExperienceSimulations';
/**
* Sleep utility for delays
@@ -44,6 +45,8 @@ export interface AppConfig {
activeSecurity?: boolean;
dependenciesReady?: boolean;
error?: string;
isNewServer?: boolean;
isNewUser?: boolean;
}
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
@@ -106,6 +109,15 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const testConfig = getSimulatedAppConfig();
if (testConfig) {
setConfig(testConfig);
setFetchCount((prev) => prev + 1);
setHasResolvedConfig(true);
setLoading(false);
return;
}
if (attempt > 0) {
const delay = initialDelay * Math.pow(2, attempt - 1);
console.log(`[AppConfig] Retry attempt ${attempt}/${maxRetries} after ${delay}ms delay...`);
@@ -0,0 +1,45 @@
import React, { createContext, useContext, useMemo } from 'react';
import { useCookieConsent } from '@app/hooks/useCookieConsent';
import { useAppConfig } from '@app/contexts/AppConfigContext';
interface CookieConsentContextValue {
isReady: boolean;
showCookieConsent: () => void;
showCookiePreferences: () => void;
hasResponded: boolean;
}
const CookieConsentContext = createContext<CookieConsentContextValue | undefined>(undefined);
export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { config } = useAppConfig();
const analyticsEnabled = config ? config.enableAnalytics !== false : false;
const {
showCookieConsent,
showCookiePreferences,
isInitialized,
hasResponded,
} = useCookieConsent({ analyticsEnabled });
const value = useMemo<CookieConsentContextValue>(() => ({
isReady: analyticsEnabled && isInitialized,
showCookieConsent,
showCookiePreferences,
hasResponded,
}), [analyticsEnabled, hasResponded, isInitialized, showCookieConsent, showCookiePreferences]);
return (
<CookieConsentContext.Provider value={value}>
{children}
</CookieConsentContext.Provider>
);
};
export const useCookieConsentContext = (): CookieConsentContextValue => {
const context = useContext(CookieConsentContext);
if (!context) {
throw new Error('useCookieConsentContext must be used within a CookieConsentProvider');
}
return context;
};
@@ -1,45 +1,107 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import React, { createContext, useContext, useState, useCallback } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useShouldShowWelcomeModal } from '@app/hooks/useShouldShowWelcomeModal';
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) => void;
startTour: (type?: TourType, options?: StartTourOptions) => void;
closeTour: () => void;
completeTour: () => void;
resetTour: (type?: TourType) => void;
showWelcomeModal: boolean;
setShowWelcomeModal: (show: boolean) => 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 { updatePreference } = usePreferences();
const { preferences, updatePreference } = usePreferences();
const [isOpen, setIsOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [tourType, setTourType] = useState<TourType>('tools');
const [showWelcomeModal, setShowWelcomeModal] = useState(false);
const shouldShow = useShouldShowWelcomeModal();
const [startAfterToolModeSelection, setStartAfterToolModeSelection] = useState(false);
const [pendingTourRequest, setPendingTourRequest] = useState<PendingTourRequest | null>(null);
// Auto-show welcome modal for first-time users
useEffect(() => {
if (shouldShow) {
setShowWelcomeModal(true);
}
}, [shouldShow]);
const startTour = useCallback((type: TourType = 'tools') => {
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);
}, []);
@@ -56,6 +118,16 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch
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={{
@@ -67,8 +139,10 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch
closeTour,
completeTour,
resetTour,
showWelcomeModal,
setShowWelcomeModal,
startAfterToolModeSelection,
setStartAfterToolModeSelection,
pendingTourRequest,
clearPendingTourRequest,
}}
>
{children}
@@ -146,6 +146,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const setToolPanelMode = useCallback((mode: ToolPanelMode) => {
dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: mode });
updatePreference('defaultToolPanelMode', mode);
updatePreference('hasSelectedToolPanelMode', true);
}, [updatePreference]);
+59 -18
View File
@@ -10,6 +10,7 @@ declare global {
show: (show?: boolean) => void;
acceptedCategory: (category: string) => boolean;
acceptedService: (serviceName: string, category: string) => boolean;
validConsent?: () => boolean;
};
}
}
@@ -19,26 +20,50 @@ interface CookieConsentConfig {
}
export const useCookieConsent = ({
analyticsEnabled = false
analyticsEnabled = false,
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const [isInitialized, setIsInitialized] = useState(false);
const [hasRespondedInternal, setHasRespondedInternal] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const markResponded = () => setHasRespondedInternal(true);
const removeConsentListeners = () => {
window.removeEventListener('cc:onFirstConsent', markResponded);
window.removeEventListener('cc:onConsent', markResponded);
window.removeEventListener('cc:onChange', markResponded);
};
window.addEventListener('cc:onFirstConsent', markResponded);
window.addEventListener('cc:onConsent', markResponded);
window.addEventListener('cc:onChange', markResponded);
if (analyticsEnabled) {
setHasRespondedInternal(window.CookieConsent?.validConsent?.() ?? false);
}
if (!analyticsEnabled) {
console.log('Cookie consent not enabled - analyticsEnabled is false');
return;
setHasRespondedInternal(false);
return () => {
removeConsentListeners();
};
}
// Prevent double initialization
if (window.CookieConsent) {
setIsInitialized(true);
// Force show the modal if it exists but isn't visible
setTimeout(() => {
window.CookieConsent?.show();
}, 100);
return;
if (window.CookieConsent.validConsent?.()) {
markResponded();
}
return () => {
removeConsentListeners();
};
}
// Load the cookie consent CSS files first
@@ -116,7 +141,7 @@ export const useCookieConsent = ({
// Initialize cookie consent with full configuration
try {
window.CookieConsent.run({
autoShow: true,
autoShow: false,
hideFromBots: false,
guiOptions: {
consentModal: {
@@ -202,18 +227,21 @@ export const useCookieConsent = ({
}
}
}
}
},
onFirstConsent: markResponded,
onConsent: markResponded,
onChange: markResponded,
});
// Force show after initialization
setTimeout(() => {
window.CookieConsent?.show();
}, 200);
} catch (error) {
console.error('Error initializing CookieConsent:', error);
}
setIsInitialized(true);
if (window.CookieConsent?.validConsent?.()) {
markResponded();
} else {
setHasRespondedInternal(false);
}
setIsInitialized(true);
}, 100); // Small delay to ensure DOM is ready
};
@@ -224,6 +252,8 @@ export const useCookieConsent = ({
document.head.appendChild(script);
return () => {
// Cleanup event listeners
removeConsentListeners();
// Cleanup script and CSS when component unmounts
if (document.head.contains(script)) {
document.head.removeChild(script);
@@ -237,11 +267,17 @@ export const useCookieConsent = ({
};
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
const showCookiePreferences = () => {
const showCookieConsent = useCallback(() => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent?.show();
}
}, [isInitialized]);
const showCookiePreferences = useCallback(() => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent?.show(true);
}
};
}, [isInitialized]);
const isServiceAccepted = useCallback((service: string, category: string): boolean => {
if (typeof window === 'undefined' || !window.CookieConsent) {
@@ -250,8 +286,13 @@ export const useCookieConsent = ({
return window.CookieConsent.acceptedService(service, category);
}, []);
const effectiveHasResponded = analyticsEnabled ? hasRespondedInternal : true;
return {
showCookieConsent,
showCookiePreferences,
isServiceAccepted
isServiceAccepted,
isInitialized,
hasResponded: effectiveHasResponded,
};
};
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import {
UPGRADE_BANNER_ALERT_EVENT,
type UpgradeBannerAlertPayload,
} from '@app/constants/events';
export interface LicenseAlertState {
active: boolean;
audience: 'admin' | 'user' | null;
totalUsers: number | null;
freeTierLimit: number;
}
const defaultState: LicenseAlertState = {
active: false,
audience: null,
totalUsers: null,
freeTierLimit: 5,
};
export function useLicenseAlert(): LicenseAlertState {
const [state, setState] = useState<LicenseAlertState>(defaultState);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleAlert = (event: Event) => {
const detail = (event as CustomEvent<UpgradeBannerAlertPayload>).detail;
if (detail?.active) {
setState({
active: true,
audience: detail.audience ?? 'user',
totalUsers:
typeof detail.totalUsers === 'number' ? detail.totalUsers : null,
freeTierLimit: detail.freeTierLimit ?? 5,
});
} else {
setState(defaultState);
}
};
window.addEventListener(UPGRADE_BANNER_ALERT_EVENT, handleAlert as EventListener);
return () => {
window.removeEventListener(UPGRADE_BANNER_ALERT_EVENT, handleAlert as EventListener);
};
}, []);
return state;
}
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
export type OS =
| 'windows'
| 'mac-intel'
| 'mac-apple'
| 'linux-x64'
| 'linux-arm64'
| 'ios'
| 'android'
| 'unknown';
function parseUA(ua: string): OS {
const uaLower = ua.toLowerCase();
// iOS (includes iPadOS masquerading as Mac in some cases)
const isIOS = /iphone|ipad|ipod/.test(uaLower) || (ua.includes('Macintosh') && typeof window !== 'undefined' && 'ontouchstart' in window);
if (isIOS) return 'ios';
if (/android/.test(uaLower)) return 'android';
if (/windows nt/.test(uaLower)) return 'windows';
if (/mac os x/.test(uaLower)) {
// Default to Intel; refine via hints below
let detected: OS = 'mac-intel';
// Safari on Apple Silicon sometimes exposes both tokens
if (ua.includes('Apple') && ua.includes('ARM')) {
detected = 'mac-apple';
}
return detected; // will be further refined via Client Hints if available
}
if (/linux|x11/.test(uaLower)) return 'linux-x64';
return 'unknown';
}
export function useOs(): OS {
const [os, setOs] = useState<OS>('unknown');
useEffect(() => {
let cancelled = false;
async function detect() {
// Start with UA fallback
let detected: OS = parseUA(navigator.userAgent);
// Try Client Hints for better platform + architecture
const uaData = (navigator as any).userAgentData;
if (uaData?.getHighEntropyValues) {
try {
const { platform, architecture, bitness } = await uaData.getHighEntropyValues([
'platform',
'architecture',
'bitness',
'platformVersion',
]);
const plat = (platform || '').toLowerCase();
if (plat.includes('windows')) detected = 'windows';
else if (plat.includes('ios')) detected = 'ios';
else if (plat.includes('android')) detected = 'android';
else if (plat.includes('mac')) {
// CH “architecture” is often "arm" on Apple Silicon
detected = architecture?.toLowerCase().includes('arm') ? 'mac-apple' : 'mac-intel';
} else if (plat.includes('linux') || plat.includes('chrome os')) {
const archLower = (architecture || '').toLowerCase();
const isArm = archLower.includes('arm') || (bitness === '32' && /aarch|arm/.test(architecture || ''));
detected = isArm ? 'linux-arm64' : 'linux-x64';
}
} catch {
// ignore
}
} else {
// Heuristic Apple Silicon from UA when no Client Hints (Safari): uncertain, prefer not to guess
// Keep detected as-is (often 'mac-intel').
}
if (!cancelled) setOs(detected);
}
detect();
return () => {
cancelled = true;
};
}, []);
return os;
}
@@ -0,0 +1,157 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAppConfig } from '@app/contexts/AppConfigContext';
const SELF_REPORTED_ADMIN_KEY = 'stirling-self-reported-admin';
const FREE_TIER_LIMIT = 5;
type UserCountSource = 'admin' | 'estimate' | 'unknown';
export type ServerScenarioKey =
| 'unknown'
| 'licensed'
| 'no-login-user-under-limit-no-license'
| 'no-login-admin-under-limit-no-license'
| 'no-login-user-over-limit-no-license'
| 'no-login-admin-over-limit-no-license'
| 'login-user-under-limit-no-license'
| 'login-admin-under-limit-no-license'
| 'login-user-over-limit-no-license'
| 'login-admin-over-limit-no-license';
export interface ServerExperienceValue {
loginEnabled: boolean;
configIsAdmin: boolean;
effectiveIsAdmin: boolean;
selfReportedAdmin: boolean;
isAuthenticated: boolean;
isNewServer: boolean | null;
isNewUser: boolean | null;
premiumEnabled: boolean | null;
license: string | undefined;
runningProOrHigher: boolean | undefined;
runningEE: boolean | undefined;
hasPaidLicense: boolean;
licenseKeyValid: boolean | null;
licenseLoading: boolean;
licenseInfoAvailable: boolean;
totalUsers: number | null;
weeklyActiveUsers: number | null;
userCountLoading: boolean;
userCountError: string | null;
userCountSource: UserCountSource;
userCountResolved: boolean;
overFreeTierLimit: boolean | null;
freeTierLimit: number;
refreshUserCounts: () => Promise<void>;
setSelfReportedAdmin: (value: boolean) => void;
scenarioKey: ServerScenarioKey;
}
function readSelfReportedAdmin(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === 'true';
} catch {
return false;
}
}
export function useServerExperience(): ServerExperienceValue {
const { config } = useAppConfig();
const [selfReportedAdmin, setSelfReportedAdminState] = useState<boolean>(readSelfReportedAdmin);
const loginEnabled = config?.enableLogin !== false;
const configIsAdmin = Boolean(config?.isAdmin);
const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
const hasPaidLicense = config?.license === 'PRO' || config?.license === 'ENTERPRISE';
const setSelfReportedAdmin = useCallback((value: boolean) => {
setSelfReportedAdminState(value);
if (typeof window === 'undefined') {
return;
}
try {
if (value) {
window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, 'true');
} else {
window.localStorage.removeItem(SELF_REPORTED_ADMIN_KEY);
}
} catch {
// ignore storage write failures
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleStorage = (event: StorageEvent) => {
if (event.key === SELF_REPORTED_ADMIN_KEY) {
setSelfReportedAdminState(event.newValue === 'true');
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, []);
useEffect(() => {
if (config?.isNewServer && !loginEnabled && !selfReportedAdmin) {
setSelfReportedAdmin(true);
}
}, [config?.isNewServer, loginEnabled, selfReportedAdmin, setSelfReportedAdmin]);
const scenarioKey: ServerScenarioKey = useMemo(() => {
if (hasPaidLicense) {
return 'licensed';
}
return 'unknown';
}, [hasPaidLicense]);
const value = useMemo<ServerExperienceValue>(() => ({
loginEnabled,
configIsAdmin,
effectiveIsAdmin,
selfReportedAdmin,
isAuthenticated: false,
isNewServer: config?.isNewServer ?? null,
isNewUser: config?.isNewUser ?? null,
premiumEnabled: config?.premiumEnabled ?? null,
license: config?.license,
runningProOrHigher: config?.runningProOrHigher,
runningEE: config?.runningEE,
hasPaidLicense,
licenseKeyValid: config?.premiumEnabled ?? null,
licenseLoading: false,
licenseInfoAvailable: false,
totalUsers: null,
weeklyActiveUsers: null,
userCountLoading: false,
userCountError: null,
userCountSource: 'unknown',
userCountResolved: false,
overFreeTierLimit: null,
freeTierLimit: FREE_TIER_LIMIT,
refreshUserCounts: async () => {},
setSelfReportedAdmin,
scenarioKey,
}), [
config?.isNewServer,
config?.isNewUser,
config?.license,
config?.premiumEnabled,
config?.runningEE,
config?.runningProOrHigher,
configIsAdmin,
effectiveIsAdmin,
hasPaidLicense,
loginEnabled,
scenarioKey,
selfReportedAdmin,
setSelfReportedAdmin,
]);
return value;
}
@@ -5,7 +5,8 @@ export function useShouldShowWelcomeModal(): boolean {
const { preferences } = usePreferences();
const isMobile = useIsMobile();
return !preferences.hasCompletedOnboarding
return preferences.hasSeenIntroOnboarding
&& !preferences.hasCompletedOnboarding
&& preferences.toolPanelModePromptSeen
&& !isMobile;
}
+4 -4
View File
@@ -9,6 +9,7 @@ import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useLogoPath } from "@app/hooks/useLogoPath";
import { useCookieConsentContext } from "@app/contexts/CookieConsentContext";
import { useFileContext } from "@app/contexts/file/fileHooks";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
@@ -22,7 +23,6 @@ import FileManager from "@app/components/FileManager";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModal";
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
import "@app/pages/HomePage.css";
@@ -49,6 +49,7 @@ export default function HomePage() {
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const { config } = useAppConfig();
const { hasResponded: cookieConsentResponded } = useCookieConsentContext();
const isMobile = useIsMobile();
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
@@ -63,10 +64,10 @@ export default function HomePage() {
// Show admin analytics choice modal if analytics settings not configured
useEffect(() => {
if (config && config.enableAnalytics === null) {
if (config && config.enableAnalytics === null && cookieConsentResponded) {
setShowAnalyticsModal(true);
}
}, [config]);
}, [config, cookieConsentResponded]);
// Auto-switch to viewer when going from 0 to 1 file
useEffect(() => {
@@ -187,7 +188,6 @@ export default function HomePage() {
opened={showAnalyticsModal}
onClose={() => setShowAnalyticsModal(false)}
/>
<ToolPanelModePrompt />
{isMobile ? (
<div className="mobile-layout">
<div className="mobile-toggle">
@@ -7,8 +7,11 @@ export interface UserPreferences {
defaultToolPanelMode: ToolPanelMode;
theme: ThemeMode;
toolPanelModePromptSeen: boolean;
hasSelectedToolPanelMode: boolean;
showLegacyToolDescriptions: boolean;
hasCompletedOnboarding: boolean;
hasSeenIntroOnboarding: boolean;
hasSeenCookieBanner: boolean;
hideUnavailableTools: boolean;
hideUnavailableConversions: boolean;
}
@@ -19,8 +22,11 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
defaultToolPanelMode: DEFAULT_TOOL_PANEL_MODE,
theme: getSystemTheme(),
toolPanelModePromptSeen: false,
hasSelectedToolPanelMode: false,
showLegacyToolDescriptions: false,
hasCompletedOnboarding: false,
hasSeenIntroOnboarding: false,
hasSeenCookieBanner: false,
hideUnavailableTools: false,
hideUnavailableConversions: false,
};
+6 -6
View File
@@ -1,3 +1,5 @@
import { DOWNLOAD_BASE_URL } from '@app/constants/downloads';
export interface UpdateSummary {
latest_version: string | null;
latest_stable_version?: string | null;
@@ -71,11 +73,9 @@ export class UpdateService {
return null;
}
const baseUrl = 'https://files.stirlingpdf.com/';
// Determine file based on machine type and security
if (machineInfo.machineType === 'Server-jar') {
return baseUrl + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
return DOWNLOAD_BASE_URL + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
}
// Client installations
@@ -84,11 +84,11 @@ export class UpdateService {
const type = machineInfo.activeSecurity ? '-server-security' : '-server';
if (os === 'unix') {
return baseUrl + os + type + '.jar';
return DOWNLOAD_BASE_URL + os + type + '.jar';
} else if (os === 'win') {
return baseUrl + os + '-installer.exe';
return DOWNLOAD_BASE_URL + os + '-installer.exe';
} else if (os === 'mac') {
return baseUrl + os + '-installer.dmg';
return DOWNLOAD_BASE_URL + os + '-installer.dmg';
}
}
+23
View File
@@ -328,6 +328,19 @@
--compare-page-label-fg: var(--text-secondary);
}
/* Onboarding (light mode) */
:root {
--onboarding-title: #0A0A0A;
--onboarding-body: #4A5565;
--onboarding-primary-button-bg: #101828;
--onboarding-primary-button-text: #FFFFFF;
--onboarding-secondary-button-bg: #FFFFFF;
--onboarding-secondary-button-text: #6A7282;
--onboarding-secondary-button-border: #E5E5E5;
--onboarding-step-active: #1E2939;
--onboarding-step-inactive: #D1D5DC;
}
[data-mantine-color-scheme="dark"] {
/* Dark theme gray scale (inverted) */
--gray-50: 17 24 39;
@@ -548,6 +561,16 @@
--modal-content-bg: #2A2F36;
--modal-header-border: rgba(255, 255, 255, 0.08);
/* Onboarding (dark mode) */
--onboarding-title: #F9FAFB;
--onboarding-body: #D1D5DB;
--onboarding-primary-button-bg: #FFFFFF;
--onboarding-primary-button-text: #0B1220;
--onboarding-secondary-button-bg: transparent;
--onboarding-secondary-button-text: #E5E7EB;
--onboarding-secondary-button-border: #3A4047;
--onboarding-step-active: #E5E7EB;
--onboarding-step-inactive: #4B5563;
/* API Keys section colors (dark mode) */
--api-keys-card-bg: #2A2F36;
--api-keys-card-border: #3A4047;
@@ -0,0 +1,192 @@
import type { AppConfig } from '@app/contexts/AppConfigContext';
interface LicenseInfo {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}
interface WauResponse {
trackingSince: string;
daysOnline: number;
totalUniqueBrowsers: number;
weeklyActiveUsers: number;
}
interface AdminUsageResponse {
totalUsers?: number;
}
interface SimulationScenario {
label: string;
appConfig: AppConfig;
wau?: WauResponse;
adminUsage?: AdminUsageResponse;
licenseInfo: LicenseInfo;
}
const DEV_TESTING_MODE = false;
const SIMULATION_INDEX = 0;
const FREE_LICENSE_INFO: LicenseInfo = {
licenseType: 'NORMAL',
enabled: false,
maxUsers: 5,
hasKey: false,
};
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: '2.0.0',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
premiumEnabled: false,
runningProOrHigher: false,
runningEE: false,
enableLogin: false,
activeSecurity: false,
languages: [],
contextPath: '/',
license: 'NORMAL',
baseUrl: 'http://localhost',
enableEmailInvites: true,
};
const BASE_LOGIN_CONFIG: AppConfig = {
...BASE_NO_LOGIN_CONFIG,
enableLogin: true,
activeSecurity: true,
};
const SIMULATION_SCENARIOS: SimulationScenario[] = [
{
label: 'no-login-user-under-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-11-18T23:20:12.520884200Z',
daysOnline: 0,
totalUniqueBrowsers: 3,
weeklyActiveUsers: 3,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'no-login-admin-under-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-10-01T00:00:00Z',
daysOnline: 14,
totalUniqueBrowsers: 4,
weeklyActiveUsers: 4,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'no-login-user-over-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-09-01T00:00:00Z',
daysOnline: 30,
totalUniqueBrowsers: 12,
weeklyActiveUsers: 9,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'no-login-admin-over-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-08-15T00:00:00Z',
daysOnline: 45,
totalUniqueBrowsers: 18,
weeklyActiveUsers: 12,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-user-under-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: false,
},
adminUsage: {
totalUsers: 3,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-admin-under-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: true,
},
adminUsage: {
totalUsers: 4,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-user-over-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: false,
},
adminUsage: {
totalUsers: 12,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-admin-over-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: true,
},
adminUsage: {
totalUsers: 57,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
];
function getActiveScenario(): SimulationScenario | null {
if (!DEV_TESTING_MODE) {
return null;
}
const scenario = SIMULATION_SCENARIOS[SIMULATION_INDEX];
if (!scenario) {
console.warn('[Simulation] SIMULATION_INDEX out of range, using live backend.');
return null;
}
console.warn(`[Simulation] Using scenario #${SIMULATION_INDEX} (${scenario.label}).`);
return scenario;
}
export function getSimulatedAppConfig(): AppConfig | null {
return getActiveScenario()?.appConfig ?? null;
}
export function getSimulatedWauResponse(): WauResponse | null {
return getActiveScenario()?.wau ?? null;
}
export function getSimulatedAdminUsage(): AdminUsageResponse | null {
return getActiveScenario()?.adminUsage ?? null;
}
export function getSimulatedLicenseInfo(): LicenseInfo | null {
return getActiveScenario()?.licenseInfo ?? null;
}
export const DEV_TESTING_ENABLED = DEV_TESTING_MODE;
+34
View File
@@ -0,0 +1,34 @@
import { ReactNode } from 'react';
export interface AnimatedCircleConfig {
size: number;
color: string;
opacity?: number;
blur?: number;
position: 'bottom-left' | 'top-right';
amplitude?: number;
duration?: number;
delay?: number;
offsetX?: number;
offsetY?: number;
}
export interface AnimatedSlideBackgroundProps {
gradientStops: [string, string];
circles: AnimatedCircleConfig[];
}
export interface SlideConfig {
key: string;
title: ReactNode;
body: ReactNode;
background: AnimatedSlideBackgroundProps;
downloadUrl?: string;
}
export interface LicenseNotice {
totalUsers: number | null;
freeTierLimit: number;
isOverLimit: boolean;
requiresLicense: boolean;
}
@@ -1,12 +1,21 @@
import { useEffect } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
import UpgradeBanner from '@app/components/shared/UpgradeBanner';
export function DesktopBannerInitializer() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(<DefaultAppBanner />);
setBanner(
<>
<UpgradeBanner />
<DefaultAppBanner />
</>,
);
return () => {
setBanner(null);
};
}, [setBanner]);
return null;
@@ -2,8 +2,9 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/compo
import { AuthProvider } from "@app/auth/UseSession";
import { LicenseProvider } from "@app/contexts/LicenseContext";
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
return (
@@ -13,12 +14,14 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
>
<AuthProvider>
<LicenseProvider>
<CheckoutProvider>
<UpdateSeatsProvider>
<UpgradeBanner />
{children}
</UpdateSeatsProvider>
</CheckoutProvider>
<UpdateSeatsProvider>
<ServerExperienceProvider>
<CheckoutProvider>
<UpgradeBannerInitializer />
{children}
</CheckoutProvider>
</ServerExperienceProvider>
</UpdateSeatsProvider>
</LicenseProvider>
</AuthProvider>
</CoreAppProviders>
@@ -22,6 +22,7 @@ import { userManagementService } from '@app/services/userManagementService';
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useNavigate } from 'react-router-dom';
interface InviteMembersModalProps {
opened: boolean;
@@ -31,6 +32,7 @@ interface InviteMembersModalProps {
export default function InviteMembersModal({ opened, onClose }: InviteMembersModalProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const navigate = useNavigate();
const [teams, setTeams] = useState<Team[]>([]);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
@@ -45,6 +47,7 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
premiumEnabled: boolean;
totalUsers: number;
} | null>(null);
const hasNoSlots = licenseInfo ? licenseInfo.availableSlots <= 0 : false;
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
@@ -242,6 +245,21 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
onClose();
};
const handleGoToPlan = () => {
handleClose();
navigate('/settings/adminPlan');
};
const handlePrimaryAction = () => {
if (inviteMode === 'email') {
handleEmailInvite();
} else if (inviteMode === 'link') {
handleGenerateInviteLink();
} else {
handleInviteUser();
}
};
return (
<Modal
opened={opened}
@@ -281,16 +299,23 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
{licenseInfo && (
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
<Text size="sm" fw={500}>
{licenseInfo.availableSlots > 0
? t('workspace.people.license.slotsAvailable', {
count: licenseInfo.availableSlots,
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
})
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
</Text>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
<Text size="sm" fw={500}>
{licenseInfo.availableSlots > 0
? t('workspace.people.license.slotsAvailable', {
count: licenseInfo.availableSlots,
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
})
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
</Text>
</Group>
{licenseInfo.availableSlots === 0 && (
<Button size="xs" variant="light" onClick={handleGoToPlan}>
{t('workspace.people.actions.upgrade', 'Upgrade')}
</Button>
)}
</Group>
<Text size="xs" c="dimmed">
{t('workspace.people.license.currentUsage', {
@@ -495,8 +520,8 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
loading={processing}
onClick={handlePrimaryAction}
loading={!hasNoSlots && processing}
fullWidth
size="md"
mt="md"
@@ -1,143 +1,391 @@
import React, { useState, useEffect } from 'react';
import { Group, Text, Button, ActionIcon, Paper } from '@mantine/core';
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { useNavigate } from 'react-router-dom';
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useCheckout } from '@app/contexts/CheckoutContext';
import { useLicense } from '@app/contexts/LicenseContext';
import { mapLicenseToTier } from '@app/services/licenseService';
import LocalIcon from '@app/components/shared/LocalIcon';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import {
ONBOARDING_SESSION_BLOCK_KEY,
ONBOARDING_SESSION_EVENT,
SERVER_LICENSE_REQUEST_EVENT,
type ServerLicenseRequestPayload,
UPGRADE_BANNER_TEST_EVENT,
type UpgradeBannerTestPayload,
type UpgradeBannerTestScenario,
UPGRADE_BANNER_ALERT_EVENT,
} from '@core/constants/events';
import { useServerExperience } from '@app/hooks/useServerExperience';
/**
* UpgradeBanner - Dismissable top banner encouraging users to upgrade
*
* This component demonstrates:
* - How to check authentication status with useAuth()
* - How to check license status with licenseService
* - How to open checkout modal with useCheckout()
* - How to persist dismissal state with localStorage
*
* To remove this banner:
* 1. Remove the import and component from AppProviders.tsx
* 2. Delete this file
*/
const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt';
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
const UpgradeBanner: React.FC = () => {
const { t } = useTranslation();
const { user } = useAuth();
const navigate = useNavigate();
const { hasResponded: cookieChoiceMade } = useCookieConsentContext();
const { isOpen: tourOpen } = useOnboarding();
const { openCheckout } = useCheckout();
const { licenseInfo, loading: licenseLoading } = useLicense();
const [isVisible, setIsVisible] = useState(false);
const {
totalUsers,
userCountResolved,
userCountLoading,
effectiveIsAdmin: configIsAdmin,
hasPaidLicense,
licenseLoading,
freeTierLimit,
overFreeTierLimit,
scenarioKey,
} = useServerExperience();
const [sessionBlocked, setSessionBlocked] = useState(true);
const [friendlyVisible, setFriendlyVisible] = useState(false);
const isDev = import.meta.env.DEV;
const [testScenario, setTestScenario] = useState<UpgradeBannerTestScenario>(null);
// Check if user should see the banner
// Track onboarding session flag so we don't show banner if onboarding ran this load
useEffect(() => {
// Don't show if not logged in
if (!user) {
setIsVisible(false);
if (typeof window === 'undefined') {
return;
}
// Don't show if Supabase is not configured (no checkout available)
if (!isSupabaseConfigured) {
setIsVisible(false);
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(() => {
if (!isDev || typeof window === 'undefined') {
return;
}
// Don't show while license is loading
if (licenseLoading) {
const handleTestEvent = (event: Event) => {
const { detail } = event as CustomEvent<UpgradeBannerTestPayload>;
setTestScenario(detail?.scenario ?? null);
if (detail?.scenario === 'friendly') {
setFriendlyVisible(true);
} else if (!detail?.scenario) {
setFriendlyVisible(false);
}
};
window.addEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent as EventListener);
return () => {
window.removeEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent as EventListener);
};
}, [isDev]);
const isAdmin = configIsAdmin;
const scenario = isDev ? testScenario : null;
const scenarioIsFriendly = scenario === 'friendly';
const scenarioIsUrgentUser = scenario === 'urgent-user';
const userCountKnown = typeof totalUsers === 'number';
const isUnderLimit = userCountKnown ? totalUsers < freeTierLimit : null;
const isOverLimit = userCountKnown ? totalUsers > freeTierLimit : overFreeTierLimit;
const baseTotalUsersLoaded = userCountResolved && !userCountLoading;
const scenarioProvidesInfo =
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
const derivedIsAdmin = scenarioProvidesInfo
? scenarioKey!.includes('admin')
: isAdmin;
const derivedHasPaidLicense =
scenarioKey === 'licensed'
? true
: scenarioKey === 'unknown'
? hasPaidLicense
: false;
const derivedIsUnderLimit = scenarioProvidesInfo
? scenarioKey!.includes('under-limit')
: isUnderLimit === true;
const derivedIsOverLimit = scenarioProvidesInfo
? scenarioKey!.includes('over-limit')
: isOverLimit === true;
const effectiveIsAdmin = scenario
? scenarioIsUrgentUser
? false
: true
: derivedIsAdmin;
const effectiveTotalUsers =
scenario != null ? (scenarioIsFriendly ? 3 : 8) : totalUsers;
const effectiveTotalUsersLoaded = scenario != null ? true : baseTotalUsersLoaded;
const effectiveHasPaidLicense = scenario != null ? false : derivedHasPaidLicense;
const effectiveIsUnderLimit =
scenario != null ? scenarioIsFriendly : derivedIsUnderLimit;
const effectiveIsOverLimit =
scenario != null ? !scenarioIsFriendly : derivedIsOverLimit;
const isDerivedAdmin = scenario
? !scenarioIsUrgentUser
: scenarioKey === 'login-user-over-limit-no-license'
? false
: effectiveIsAdmin;
const shouldShowFriendlyBase = Boolean(
isDerivedAdmin &&
!effectiveHasPaidLicense &&
effectiveIsUnderLimit &&
effectiveTotalUsersLoaded,
);
const shouldShowUrgentBase = Boolean(
!effectiveHasPaidLicense &&
effectiveTotalUsersLoaded &&
(effectiveIsOverLimit || scenarioKey === 'login-user-over-limit-no-license'),
);
const shouldEvaluateFriendly = scenario
? scenarioIsFriendly
: Boolean(
shouldShowFriendlyBase &&
!licenseLoading &&
effectiveTotalUsersLoaded &&
cookieChoiceMade &&
!tourOpen &&
!sessionBlocked,
);
const shouldEvaluateUrgent = scenario
? Boolean(scenario && !scenarioIsFriendly)
: Boolean(
shouldShowUrgentBase &&
!licenseLoading &&
cookieChoiceMade &&
!tourOpen &&
!sessionBlocked,
);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
// Check if banner was dismissed
const dismissed = localStorage.getItem('upgradeBannerDismissed');
if (dismissed === 'true') {
setIsVisible(false);
if (!shouldShowFriendlyBase && effectiveTotalUsersLoaded) {
window.localStorage.removeItem(FRIENDLY_LAST_SEEN_KEY);
}
}, [shouldShowFriendlyBase, effectiveTotalUsersLoaded]);
useEffect(() => {
if (scenario === 'friendly') {
return;
}
// Check license status from global context
const tier = mapLicenseToTier(licenseInfo);
// Show banner only for free tier users
if (tier === 'free' || tier === null) {
setIsVisible(true);
} else {
// Auto-hide banner if user upgrades
setIsVisible(false);
if (!shouldEvaluateFriendly) {
setFriendlyVisible(false);
return;
}
}, [user, licenseInfo, licenseLoading]);
// Handle dismiss
const handleDismiss = () => {
localStorage.setItem('upgradeBannerDismissed', 'true');
setIsVisible(false);
};
if (friendlyVisible || typeof window === 'undefined' || userCountLoading) {
return;
}
// Handle upgrade button click
const handleUpgrade = () => {
openCheckout('server', {
// Currency auto-detected from locale in CheckoutContext
minimumSeats: 1,
onSuccess: () => {
// Banner will auto-hide on next render when license is detected
setIsVisible(false);
},
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
const lastShown = lastShownRaw ? parseInt(lastShownRaw, 10) : 0;
const now = Date.now();
const due = !Number.isFinite(lastShown) || now - lastShown >= WEEK_IN_MS;
setFriendlyVisible(due);
}, [scenario, shouldEvaluateFriendly, friendlyVisible, userCountLoading]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const detail = shouldEvaluateUrgent
? {
active: true,
audience: effectiveIsAdmin ? 'admin' : 'user',
totalUsers: effectiveTotalUsers ?? null,
freeTierLimit,
}
: { active: false };
console.debug('[UpgradeBanner] Dispatching alert event', {
shouldEvaluateUrgent,
detail,
totalUsers: effectiveTotalUsers,
freeTierLimit,
effectiveIsAdmin,
effectiveHasPaidLicense,
userCountLoaded: effectiveTotalUsersLoaded,
});
window.dispatchEvent(
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }),
);
}, [shouldEvaluateUrgent, effectiveIsAdmin, effectiveTotalUsers, scenario, freeTierLimit]);
useEffect(() => {
return () => {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail: { active: false } }),
);
}
};
}, []);
const recordFriendlyLastShown = useCallback(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString());
}, []);
useEffect(() => {
if (friendlyVisible) {
recordFriendlyLastShown();
}
}, [friendlyVisible, recordFriendlyLastShown]);
const handleUpgrade = () => {
recordFriendlyLastShown();
const hideBanner = () => setFriendlyVisible(false);
const navigateFallback = () => {
navigate('/settings/adminPlan');
hideBanner();
};
try {
openCheckout('server', {
minimumSeats: 1,
onSuccess: () => {
hideBanner();
},
onError: () => {
navigateFallback();
},
});
} catch (error) {
console.error('[UpgradeBanner] Failed to open checkout, redirecting instead', error);
navigateFallback();
return;
}
// Keep legacy behavior so banner disappears once the user initiates checkout
hideBanner();
};
// Don't render anything if loading or not visible
if (licenseLoading || !isVisible) {
const handleFriendlyDismiss = () => {
recordFriendlyLastShown();
setFriendlyVisible(false);
};
const handleSeeInfo = () => {
if (typeof window === 'undefined' || !effectiveIsAdmin) {
return;
}
const detail: ServerLicenseRequestPayload = {
licenseNotice: {
totalUsers: effectiveTotalUsers ?? null,
freeTierLimit,
isOverLimit: effectiveIsOverLimit ?? false,
},
selfReportedAdmin: true,
deferUntilTourComplete: false,
};
window.dispatchEvent(
new CustomEvent(SERVER_LICENSE_REQUEST_EVENT, { detail }),
);
};
const renderUrgentBanner = () => {
if (!shouldEvaluateUrgent) {
console.debug('[UpgradeBanner] renderUrgentBanner → hidden (shouldEvaluateUrgent=false)');
return null;
}
console.debug('[UpgradeBanner] renderUrgentBanner → visible', {
totalUsers: effectiveTotalUsers,
freeTierLimit,
effectiveIsAdmin,
effectiveHasPaidLicense,
});
const buttonText = effectiveIsAdmin ? t('upgradeBanner.seeInfo', 'See info') : undefined;
const attentionMessage = effectiveIsAdmin
? t(
'upgradeBanner.attentionBodyAdmin',
'Review the license requirements to keep this server compliant.',
)
: t(
'upgradeBanner.attentionBody',
'Your admin needs to sign in to see more info. Please contact them immediately.',
);
return (
<InfoBanner
icon="warning-rounded"
tone="warning"
title={t('upgradeBanner.attentionTitle', 'This server needs admin attention')}
message={attentionMessage}
buttonText={buttonText}
buttonIcon="info-rounded"
onButtonClick={buttonText ? handleSeeInfo : undefined}
dismissible={false}
minHeight={60}
background="#FFF4E6"
borderColor="var(--mantine-color-orange-7)"
textColor="#9A3412"
iconColor="#EA580C"
buttonVariant="filled"
buttonColor="orange.7"
/>
);
};
if (!friendlyVisible && !shouldEvaluateUrgent) {
return null;
}
return (
<Paper
shadow="sm"
p="md"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
borderRadius: 0,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
<LocalIcon icon="stars-rounded" width="1.5rem" height="1.5rem" />
<div>
<Text size="sm" fw={600}>
{t('upgradeBanner.title', 'Upgrade to Server Plan')}
</Text>
<Text size="xs" opacity={0.9}>
{t('upgradeBanner.message', 'Get the most out of Stirling PDF with unlimited users and advanced features')}
</Text>
</div>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
variant="white"
size="sm"
onClick={handleUpgrade}
leftSection={<LocalIcon icon="upgrade-rounded" width="1rem" height="1rem" />}
>
{t('upgradeBanner.upgradeButton', 'Upgrade Now')}
</Button>
<ActionIcon
variant="subtle"
color="white"
size="lg"
onClick={handleDismiss}
aria-label={t('upgradeBanner.dismiss', 'Dismiss banner')}
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</ActionIcon>
</Group>
</Group>
</Paper>
<>
{friendlyVisible && (
<InfoBanner
icon="stars-rounded"
title={t('upgradeBanner.title', 'Upgrade to Server Plan')}
message={t(
'upgradeBanner.message',
'Get the most out of Stirling PDF with unlimited users and advanced features.',
)}
buttonText={t('upgradeBanner.upgradeButton', 'Upgrade Now')}
buttonIcon="upgrade-rounded"
onButtonClick={handleUpgrade}
onDismiss={handleFriendlyDismiss}
show={friendlyVisible}
background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
borderColor="transparent"
textColor="#fff"
iconColor="#fff"
closeIconColor="#fff"
buttonVariant="white"
buttonColor="blue"
minHeight={64}
/>
)}
{renderUrgentBanner()}
</>
);
};
@@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import UpgradeBanner from '@app/components/shared/UpgradeBanner';
export function UpgradeBannerInitializer() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(<UpgradeBanner />);
return () => {
setBanner(null);
};
}, [setBanner]);
return null;
}
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
@@ -9,6 +9,9 @@ import AvailablePlansSection from '@app/components/shared/config/configSections/
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
@@ -25,10 +28,11 @@ const AdminPlanSection: React.FC = () => {
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const { plans, loading, error, refetch } = usePlans(currency);
const licenseAlert = useLicenseAlert();
// Check if we should use static version
useEffect(() => {
// Check if Stripe and Supabase are configured
// Check if Stripe is configured
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!stripeKey || !isSupabaseConfigured || error) {
setUseStaticVersion(true);
@@ -146,6 +150,23 @@ const AdminPlanSection: React.FC = () => {
[openCheckout, currency, refetch, licenseInfo, t]
);
const shouldShowLicenseWarning = licenseAlert.active && licenseAlert.audience === 'admin';
const formattedUserCount = useMemo(() => {
if (licenseAlert.totalUsers == null) {
return t('plan.licenseWarning.overLimit', 'more than {{limit}}', {
limit: licenseAlert.freeTierLimit,
});
}
return licenseAlert.totalUsers.toLocaleString();
}, [licenseAlert.totalUsers, licenseAlert.freeTierLimit, t]);
const scrollToPlans = useCallback(() => {
const el = document.getElementById('available-plans-section');
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, []);
// Show static version if Stripe is not configured or there's an error
if (useStaticVersion) {
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
@@ -175,6 +196,40 @@ const AdminPlanSection: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{shouldShowLicenseWarning && (
<InfoBanner
icon="warning-rounded"
tone="warning"
title={t('plan.licenseWarning.title', 'Free self-hosted limit reached')}
message={t('plan.licenseWarning.body', {
total: formattedUserCount,
limit: licenseAlert.freeTierLimit,
})}
buttonText={t('plan.licenseWarning.cta', 'See plans')}
buttonIcon="upgrade-rounded"
onButtonClick={scrollToPlans}
dismissible={false}
minHeight={68}
background="#FFF4E6"
borderColor="var(--mantine-color-orange-7)"
textColor="#9A3412"
iconColor="#EA580C"
buttonVariant="filled"
buttonColor="orange.7"
/>
)}
{/* Manage Subscription Button - Only show if user has active license and Supabase is configured */}
{licenseInfo?.licenseKey && isSupabaseConfigured && (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')}
</Text>
<ManageBillingButton />
</Group>
</Paper>
)}
<AvailablePlansSection
plans={plans}
currentLicenseInfo={licenseInfo}
@@ -140,12 +140,15 @@ const AdminUsageSection: React.FC = () => {
);
}
const chartData = data?.endpoints?.map((e) => ({ label: e.endpoint, value: e.visits })) || [];
const endpoints = data?.endpoints ?? [];
const chartData = endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
const displayedVisits = data?.endpoints?.reduce((sum, e) => sum + e.visits, 0) || 0;
const displayedVisits = endpoints.reduce((sum, e) => sum + e.visits, 0);
const totalVisits = data?.totalVisits ?? displayedVisits ?? 0;
const totalEndpoints = data?.totalEndpoints ?? endpoints.length ?? 0;
const displayedPercentage = (data?.totalVisits || 0) > 0
? ((displayedVisits / (data?.totalVisits || 1)) * 100).toFixed(1)
const displayedPercentage = totalVisits > 0
? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1)
: '0';
return (
@@ -220,7 +223,7 @@ const AdminUsageSection: React.FC = () => {
{t('usage.stats.totalEndpoints', 'Total Endpoints')}
</Text>
<Text size="lg" fw={600}>
{data.totalEndpoints}
{totalEndpoints}
</Text>
</div>
<div>
@@ -228,7 +231,7 @@ const AdminUsageSection: React.FC = () => {
{t('usage.stats.totalVisits', 'Total Visits')}
</Text>
<Text size="lg" fw={600}>
{data.totalVisits.toLocaleString()}
{totalVisits.toLocaleString()}
</Text>
</div>
<div>
@@ -253,7 +256,7 @@ const AdminUsageSection: React.FC = () => {
{/* Chart and Table */}
<UsageAnalyticsChart data={chartData} />
<UsageAnalyticsTable data={data.endpoints} />
<UsageAnalyticsTable data={endpoints} />
</Stack>
);
};
@@ -27,6 +27,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useNavigate } from 'react-router-dom';
import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton';
import { useLicense } from '@app/contexts/LicenseContext';
@@ -34,6 +35,7 @@ export default function PeopleSection() {
const { t } = useTranslation();
const { config } = useAppConfig();
const { loginEnabled } = useLoginRequired();
const navigate = useNavigate();
const { licenseInfo: globalLicenseInfo } = useLicense();
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
@@ -53,6 +55,24 @@ export default function PeopleSection() {
premiumEnabled: boolean;
totalUsers: number;
} | null>(null);
const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false;
const handleAddMembersClick = () => {
if (!loginEnabled) {
return;
}
if (hasNoSlots) {
navigate('/settings/adminPlan');
return;
}
setInviteModalOpened(true);
};
const addMemberTooltip = !loginEnabled
? t('workspace.people.loginRequired', 'Enable login mode first')
: hasNoSlots
? t('workspace.people.license.noSlotsAvailable', 'No user slots available')
: null;
// Form state for edit user modal
const [editForm, setEditForm] = useState({
@@ -326,9 +346,18 @@ export default function PeopleSection() {
</Text>
{licenseInfo.availableSlots === 0 && (
<Badge color="red" variant="light" size="sm">
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
</Badge>
<Group gap="xs" wrap="nowrap" align="center">
<Badge color="red" variant="light" size="sm">
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
</Badge>
<Button
size="compact-sm"
variant="outline"
onClick={() => navigate('/settings/adminPlan')}
>
{t('workspace.people.actions.upgrade', 'Upgrade')}
</Button>
</Group>
)}
{licenseInfo.grandfatheredUserCount > 0 && (
@@ -369,14 +398,14 @@ export default function PeopleSection() {
style={{ maxWidth: 300 }}
/>
<Tooltip
label={!loginEnabled ? 'Enable login mode first' : t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
label={addMemberTooltip || undefined}
disabled={loginEnabled && (!licenseInfo || licenseInfo.availableSlots > 0)}
position="bottom"
withArrow
>
<Button
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
onClick={() => setInviteModalOpened(true)}
onClick={handleAddMembersClick}
disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
>
{t('workspace.people.addMembers')}
@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from 'react';
import licenseService, { LicenseInfo } from '@app/services/licenseService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { getSimulatedLicenseInfo } from '@app/testing/serverExperienceSimulations';
interface LicenseContextValue {
licenseInfo: LicenseInfo | null;
@@ -57,6 +58,14 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
console.log('[LicenseContext] Fetching license info');
try {
const testInfo = getSimulatedLicenseInfo();
if (testInfo) {
setLicenseInfo(testInfo);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
const info = await licenseService.getLicenseInfo();
@@ -0,0 +1,367 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import apiClient from '@app/services/apiClient';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useAuth } from '@app/auth/UseSession';
import { useLicense } from '@app/contexts/LicenseContext';
import {
getSimulatedAdminUsage,
getSimulatedWauResponse,
} from '@app/testing/serverExperienceSimulations';
const SELF_REPORTED_ADMIN_KEY = 'stirling-self-reported-admin';
const FREE_TIER_LIMIT = 5;
type UserCountSource = 'admin' | 'estimate' | 'unknown';
interface WeeklyActiveUsersResponse {
trackingSince: string;
daysOnline: number;
totalUniqueBrowsers: number;
weeklyActiveUsers: number;
}
interface UserCountState {
totalUsers: number | null;
weeklyActiveUsers: number | null;
loading: boolean;
source: UserCountSource;
lastUpdated: number | null;
error: string | null;
}
export type ServerScenarioKey =
| 'unknown'
| 'licensed'
| 'no-login-user-under-limit-no-license'
| 'no-login-admin-under-limit-no-license'
| 'no-login-user-over-limit-no-license'
| 'no-login-admin-over-limit-no-license'
| 'login-user-under-limit-no-license'
| 'login-admin-under-limit-no-license'
| 'login-user-over-limit-no-license'
| 'login-admin-over-limit-no-license';
export interface ServerExperienceValue {
loginEnabled: boolean;
configIsAdmin: boolean;
effectiveIsAdmin: boolean;
selfReportedAdmin: boolean;
isAuthenticated: boolean;
isNewServer: boolean | null;
isNewUser: boolean | null;
premiumEnabled: boolean | null;
license: string | undefined;
runningProOrHigher: boolean | undefined;
runningEE: boolean | undefined;
hasPaidLicense: boolean;
licenseKeyValid: boolean | null;
licenseLoading: boolean;
licenseInfoAvailable: boolean;
totalUsers: number | null;
weeklyActiveUsers: number | null;
userCountLoading: boolean;
userCountError: string | null;
userCountSource: UserCountSource;
userCountResolved: boolean;
overFreeTierLimit: boolean | null;
freeTierLimit: number;
refreshUserCounts: () => Promise<void>;
setSelfReportedAdmin: (value: boolean) => void;
scenarioKey: ServerScenarioKey;
}
const ServerExperienceContext = createContext<ServerExperienceValue | undefined>(undefined);
function getStoredSelfReportedAdmin(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === 'true';
} catch {
return false;
}
}
function getErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (
typeof error === 'object' &&
error !== null &&
'response' in error &&
typeof (error as any).response?.data?.message === 'string'
) {
return (error as any).response.data.message;
}
if (error instanceof Error) {
return error.message;
}
return 'Unable to load server usage';
}
export function ServerExperienceProvider({ children }: { children: ReactNode }) {
const { config } = useAppConfig();
const { user } = useAuth();
const { licenseInfo, loading: licenseLoading } = useLicense();
const [selfReportedAdmin, setSelfReportedAdminState] = useState<boolean>(getStoredSelfReportedAdmin);
const [userCountState, setUserCountState] = useState<UserCountState>({
totalUsers: null,
weeklyActiveUsers: null,
loading: false,
source: 'unknown',
lastUpdated: null,
error: null,
});
const loginEnabled = config?.enableLogin !== false;
const configIsAdmin = Boolean(config?.isAdmin);
const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
const isAuthenticated = Boolean(user);
const setSelfReportedAdmin = useCallback((value: boolean) => {
setSelfReportedAdminState(value);
if (typeof window === 'undefined') {
return;
}
try {
if (value) {
window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, 'true');
} else {
window.localStorage.removeItem(SELF_REPORTED_ADMIN_KEY);
}
} catch {
// ignore storage failures
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleStorage = (event: StorageEvent) => {
if (event.key === SELF_REPORTED_ADMIN_KEY) {
setSelfReportedAdminState(event.newValue === 'true');
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, []);
useEffect(() => {
if (!config) {
return;
}
if (config.isNewServer && !loginEnabled && !selfReportedAdmin) {
setSelfReportedAdmin(true);
}
}, [config, loginEnabled, selfReportedAdmin, setSelfReportedAdmin]);
const fetchUserCounts = useCallback(async () => {
if (!config) {
return;
}
const shouldUseAdminData = (config.enableLogin ?? true) && config.isAdmin;
const shouldUseEstimate = config.enableLogin === false;
if (!shouldUseAdminData && !shouldUseEstimate) {
setUserCountState((prev) => ({
...prev,
totalUsers: null,
weeklyActiveUsers: null,
loading: false,
source: 'unknown',
error: null,
}));
return;
}
setUserCountState((prev) => ({
...prev,
loading: true,
error: null,
}));
try {
if (shouldUseAdminData) {
const testResponse = getSimulatedAdminUsage();
const responseData =
testResponse ??
(
await apiClient.get<{ totalUsers?: number }>(
'/api/v1/proprietary/ui-data/admin-settings',
{ suppressErrorToast: true } as any,
)
).data;
const totalUsers =
typeof responseData?.totalUsers === 'number' ? responseData.totalUsers : null;
setUserCountState({
totalUsers,
weeklyActiveUsers: null,
loading: false,
source: 'admin',
lastUpdated: Date.now(),
error: null,
});
return;
}
if (shouldUseEstimate) {
const testResponse = getSimulatedWauResponse();
const responseData =
testResponse ??
(
await apiClient.get<WeeklyActiveUsersResponse>('/api/v1/info/wau', {
suppressErrorToast: true,
} as any)
).data;
const weeklyActiveUsers =
typeof responseData?.weeklyActiveUsers === 'number'
? responseData.weeklyActiveUsers
: null;
setUserCountState({
totalUsers: weeklyActiveUsers,
weeklyActiveUsers,
loading: false,
source: 'estimate',
lastUpdated: Date.now(),
error: null,
});
}
} catch (error) {
setUserCountState({
totalUsers: null,
weeklyActiveUsers: null,
loading: false,
source: 'unknown',
lastUpdated: null,
error: getErrorMessage(error),
});
}
}, [config]);
useEffect(() => {
void fetchUserCounts();
}, [fetchUserCounts]);
const refreshUserCounts = useCallback(async () => {
await fetchUserCounts();
}, [fetchUserCounts]);
const hasPaidLicense = useMemo(() => {
return config?.license === 'PRO' || config?.license === 'ENTERPRISE';
}, [config?.license]);
const licenseKeyValid = useMemo(() => {
if (licenseInfo) {
return licenseInfo.hasKey && licenseInfo.enabled;
}
if (config?.premiumEnabled) {
return true;
}
return null;
}, [config?.premiumEnabled, licenseInfo]);
const overFreeTierLimit = useMemo(() => {
if (typeof userCountState.totalUsers !== 'number') {
return null;
}
return userCountState.totalUsers > FREE_TIER_LIMIT;
}, [userCountState.totalUsers]);
const userCountResolved =
!userCountState.loading && userCountState.source !== 'unknown' && userCountState.totalUsers !== null;
const scenarioKey = useMemo<ServerScenarioKey>(() => {
if (hasPaidLicense) {
return 'licensed';
}
if (!userCountResolved || typeof userCountState.totalUsers !== 'number') {
return 'unknown';
}
const overLimit = userCountState.totalUsers > FREE_TIER_LIMIT;
if (!loginEnabled) {
if (selfReportedAdmin) {
return overLimit
? 'no-login-admin-over-limit-no-license'
: 'no-login-admin-under-limit-no-license';
}
return overLimit
? 'no-login-user-over-limit-no-license'
: 'no-login-user-under-limit-no-license';
}
if (configIsAdmin) {
return overLimit
? 'login-admin-over-limit-no-license'
: 'login-admin-under-limit-no-license';
}
return overLimit
? 'login-user-over-limit-no-license'
: 'login-user-under-limit-no-license';
}, [
hasPaidLicense,
userCountResolved,
userCountState.totalUsers,
loginEnabled,
selfReportedAdmin,
configIsAdmin,
]);
const value: ServerExperienceValue = {
loginEnabled,
configIsAdmin,
effectiveIsAdmin,
selfReportedAdmin,
isAuthenticated,
isNewServer: config?.isNewServer ?? null,
isNewUser: config?.isNewUser ?? null,
premiumEnabled: config?.premiumEnabled ?? null,
license: config?.license,
runningProOrHigher: config?.runningProOrHigher,
runningEE: config?.runningEE,
hasPaidLicense,
licenseKeyValid,
licenseLoading,
licenseInfoAvailable: Boolean(licenseInfo),
totalUsers: userCountState.totalUsers,
weeklyActiveUsers: userCountState.weeklyActiveUsers,
userCountLoading: userCountState.loading,
userCountError: userCountState.error,
userCountSource: userCountState.source,
userCountResolved,
overFreeTierLimit,
freeTierLimit: FREE_TIER_LIMIT,
refreshUserCounts,
setSelfReportedAdmin,
scenarioKey,
};
return (
<ServerExperienceContext.Provider value={value}>
{children}
</ServerExperienceContext.Provider>
);
}
export function useServerExperienceContext() {
const context = useContext(ServerExperienceContext);
if (!context) {
throw new Error('useServerExperience must be used within ServerExperienceProvider');
}
return context;
}
@@ -0,0 +1,6 @@
import { useServerExperienceContext } from '@app/contexts/ServerExperienceContext';
export function useServerExperience() {
return useServerExperienceContext();
}
@@ -0,0 +1,202 @@
import type { AppConfig } from '@app/contexts/AppConfigContext';
import type { LicenseInfo } from '@app/services/licenseService';
interface WauResponse {
trackingSince: string;
daysOnline: number;
totalUniqueBrowsers: number;
weeklyActiveUsers: number;
}
interface AdminUsageResponse {
totalUsers?: number;
}
interface SimulationScenario {
/**
* Human-friendly label describing the scenario.
* Keep in sync with the comment map below.
*/
label: string;
appConfig: AppConfig;
wau?: WauResponse;
adminUsage?: AdminUsageResponse;
licenseInfo: LicenseInfo;
}
const DEV_TESTING_MODE = false;
/**
* Scenario index cheat sheet:
* 0 → no-login-user-under-limit (no license)
* 1 → no-login-admin-under-limit (no license)
* 2 → no-login-user-over-limit (no license)
* 3 → no-login-admin-over-limit (no license)
* 4 → login-user-under-limit (no license)
* 5 → login-admin-under-limit (no license)
* 6 → login-user-over-limit (no license)
* 7 → login-admin-over-limit (no license)
*/
const SIMULATION_INDEX = 0;
const FREE_LICENSE_INFO: LicenseInfo = {
licenseType: 'NORMAL',
enabled: false,
maxUsers: 5,
hasKey: false,
};
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: '2.0.0',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
premiumEnabled: false,
runningProOrHigher: false,
runningEE: false,
enableLogin: false,
activeSecurity: false,
languages: [],
contextPath: '/',
license: 'NORMAL',
baseUrl: 'http://localhost',
enableEmailInvites: true,
};
const BASE_LOGIN_CONFIG: AppConfig = {
...BASE_NO_LOGIN_CONFIG,
enableLogin: true,
activeSecurity: true,
};
const SIMULATION_SCENARIOS: SimulationScenario[] = [
{
label: 'no-login-user-under-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-11-18T23:20:12.520884200Z',
daysOnline: 0,
totalUniqueBrowsers: 3,
weeklyActiveUsers: 3,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'no-login-admin-under-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-10-01T00:00:00Z',
daysOnline: 14,
totalUniqueBrowsers: 4,
weeklyActiveUsers: 4,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'no-login-user-over-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-09-01T00:00:00Z',
daysOnline: 30,
totalUniqueBrowsers: 12,
weeklyActiveUsers: 9,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'no-login-admin-over-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-08-15T00:00:00Z',
daysOnline: 45,
totalUniqueBrowsers: 18,
weeklyActiveUsers: 12,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-user-under-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: false,
},
adminUsage: {
totalUsers: 3,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-admin-under-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: true,
},
adminUsage: {
totalUsers: 4,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-user-over-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: false,
},
adminUsage: {
totalUsers: 12,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-admin-over-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: true,
},
adminUsage: {
totalUsers: 57,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
];
function getActiveScenario(): SimulationScenario | null {
if (!DEV_TESTING_MODE) {
return null;
}
const scenario = SIMULATION_SCENARIOS[SIMULATION_INDEX];
if (!scenario) {
console.warn('[Simulation] SIMULATION_INDEX out of range, using live backend.');
return null;
}
console.warn(`[Simulation] Using scenario #${SIMULATION_INDEX} (${scenario.label}).`);
return scenario;
}
export function getSimulatedAppConfig(): AppConfig | null {
return getActiveScenario()?.appConfig ?? null;
}
export function getSimulatedWauResponse(): WauResponse | null {
return getActiveScenario()?.wau ?? null;
}
export function getSimulatedAdminUsage(): AdminUsageResponse | null {
return getActiveScenario()?.adminUsage ?? null;
}
export function getSimulatedLicenseInfo(): LicenseInfo | null {
return getActiveScenario()?.licenseInfo ?? null;
}
export const DEV_TESTING_ENABLED = DEV_TESTING_MODE;