mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
added the different paths and cleaned up some of the files, broke some of the larger ones into different smaller files, hence the scary 38 files changed
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.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: 52px;
|
||||
height: 52px;
|
||||
animation: heroLogoRotate 0.25s ease forwards;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Modal, Button, Group, Stack, ActionIcon } from '@mantine/core';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
|
||||
import { useOs } from '@app/hooks/useOs';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
|
||||
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
|
||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import { SlideConfig } from '@app/components/onboarding/slides/types';
|
||||
import styles from './InitialOnboardingModal.module.css';
|
||||
|
||||
interface InitialOnboardingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
||||
export default function InitialOnboardingModal({ opened, onClose }: InitialOnboardingModalProps) {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { startTour, setShowWelcomeModal, setStartAfterToolModeSelection } = useOnboarding();
|
||||
const [step, setStep] = React.useState(0);
|
||||
const totalSteps = 3;
|
||||
const { config } = useAppConfig();
|
||||
const isAdmin = !!config?.isAdmin;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!opened) setStep(0);
|
||||
}, [opened]);
|
||||
|
||||
const osType = useOs();
|
||||
const os = React.useMemo(() => {
|
||||
switch (osType) {
|
||||
case 'windows':
|
||||
return { label: 'Windows', url: 'https://files.stirlingpdf.com/win-installer.exe' };
|
||||
case 'mac-apple':
|
||||
return { label: 'Mac', url: 'https://files.stirlingpdf.com/mac-installer.dmg' };
|
||||
case 'mac-intel':
|
||||
return { label: 'Mac (Intel)', url: 'https://files.stirlingpdf.com/mac-x86_64-installer.dmg' };
|
||||
case 'linux-x64':
|
||||
case 'linux-arm64':
|
||||
return { label: 'Linux', url: 'https://docs.stirlingpdf.com/Installation/Unix%20Installation/' };
|
||||
// For mobile/unknown, hide OS label and skip opening a URL
|
||||
default:
|
||||
return { label: '', url: '' };
|
||||
}
|
||||
}, [osType]);
|
||||
|
||||
const closeAndMarkSeen = React.useCallback(() => {
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
}
|
||||
onClose();
|
||||
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
|
||||
|
||||
const goNext = () => setStep((s) => Math.min(totalSteps - 1, s + 1));
|
||||
const goPrev = () => setStep((s) => Math.max(0, s - 1));
|
||||
|
||||
// Get slide content from the slide components
|
||||
const slides = React.useMemo<SlideConfig[]>(
|
||||
() => [
|
||||
WelcomeSlide(),
|
||||
DesktopInstallSlide({ osLabel: os.label, osUrl: os.url }),
|
||||
PlanOverviewSlide({ isAdmin }),
|
||||
],
|
||||
[isAdmin, os.label, os.url],
|
||||
);
|
||||
|
||||
const currentSlide = slides[step];
|
||||
|
||||
// Buttons per step
|
||||
const renderButtons = () => {
|
||||
if (step === 0) {
|
||||
return (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={goNext}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Next →
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 1) {
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Group gap={12}>
|
||||
<ActionIcon
|
||||
onClick={goPrev}
|
||||
radius="md"
|
||||
size={40}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={goNext}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</Group>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const downloadUrl = currentSlide.downloadUrl;
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, '_blank', 'noopener');
|
||||
}
|
||||
goNext();
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Download →
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Group gap={12}>
|
||||
<ActionIcon
|
||||
onClick={goPrev}
|
||||
radius="md"
|
||||
size={40}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
closeAndMarkSeen();
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Skip the tour
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Close slides first
|
||||
closeAndMarkSeen();
|
||||
// Ensure the legacy welcome modal state is controlled by our gating
|
||||
setShowWelcomeModal(false);
|
||||
// If the user still needs to choose a tool mode, mark to start the tour after they do
|
||||
if (!preferences.toolPanelModePromptSeen) {
|
||||
setStartAfterToolModeSelection(true);
|
||||
return; // The prompt will show next; tour will be started from there
|
||||
}
|
||||
// Otherwise, start immediately (if not completed previously)
|
||||
if (!preferences.hasCompletedOnboarding) {
|
||||
if (isAdmin) {
|
||||
startTour('admin');
|
||||
} else {
|
||||
startTour('tools');
|
||||
}
|
||||
}
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Show me around →
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={closeAndMarkSeen}
|
||||
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} style={{ background: 'var(--bg-surface)' }}>
|
||||
<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}`}>
|
||||
<div className={styles.heroLogoCircle}>
|
||||
<img src="/branding/StirlingPDFLogoNoTextLight.svg" alt="Stirling logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 24 }}>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
key={`title-${currentSlide.key}`}
|
||||
className={styles.title}
|
||||
style={{
|
||||
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
||||
fontWeight: 600,
|
||||
fontSize: 22,
|
||||
color: 'var(--onboarding-title)',
|
||||
}}
|
||||
>
|
||||
{currentSlide.title}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
||||
fontSize: 16,
|
||||
color: 'var(--onboarding-body)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{/* strong tags should match the title color */}
|
||||
<div
|
||||
key={`body-${currentSlide.key}`}
|
||||
className={styles.bodyCopy}
|
||||
style={{ color: 'inherit' }}
|
||||
>
|
||||
{currentSlide.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{renderButtons()}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
.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: 380px;
|
||||
background: var(--bg-surface, #ffffff);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.securityAlertRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
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,163 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack } from '@mantine/core';
|
||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
|
||||
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 './renderButtons';
|
||||
import styles from './InitialOnboardingModal.module.css';
|
||||
import type { InitialOnboardingModalProps } from './types';
|
||||
import { useInitialOnboardingState } from './useInitialOnboardingState';
|
||||
|
||||
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
|
||||
const flow = useInitialOnboardingState(props);
|
||||
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
state,
|
||||
totalSteps,
|
||||
currentSlide,
|
||||
slideDefinition,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
closeAndMarkSeen,
|
||||
handleButtonAction,
|
||||
handleDownloadIconSelect,
|
||||
devButtons,
|
||||
activeDevScenario,
|
||||
handleDevScenarioClick,
|
||||
} = flow;
|
||||
|
||||
const showDevButtons = devButtons.length > 0;
|
||||
|
||||
const renderHero = () => {
|
||||
if (slideDefinition.hero.type === 'dual-icon') {
|
||||
return (
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<button
|
||||
className={`${styles.iconButton} ${state.selectedDownloadIcon === 'new' ? styles.iconButtonSelected : ''}`}
|
||||
onClick={() => handleDownloadIconSelect('new')}
|
||||
aria-label="Select new icon version"
|
||||
>
|
||||
<img src="/branding/StirlingLogo.svg" alt="Stirling new icon" className={styles.downloadIcon} />
|
||||
</button>
|
||||
{state.selectedDownloadIcon === 'new' && <div className={styles.iconLabel}>Modern Icon</div>}
|
||||
</div>
|
||||
<div className={styles.iconWrapper}>
|
||||
<button
|
||||
className={`${styles.iconButton} ${state.selectedDownloadIcon === 'classic' ? styles.iconButtonSelected : ''}`}
|
||||
onClick={() => handleDownloadIconSelect('classic')}
|
||||
aria-label="Select classic icon version"
|
||||
>
|
||||
<img src="/branding/StirlingLogoLegacy.svg" alt="Stirling classic icon" className={styles.downloadIcon} />
|
||||
</button>
|
||||
{state.selectedDownloadIcon === 'classic' && <div className={styles.iconLabel}>Classic Icon</div>}
|
||||
</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="/branding/StirlingPDFLogoNoTextLightHC.svg" alt="Stirling logo" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDevOverlay = () => {
|
||||
if (!showDevButtons) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.devOverlay}>
|
||||
{devButtons.map((btn) => (
|
||||
<button
|
||||
key={btn.label}
|
||||
type="button"
|
||||
onClick={() => handleDevScenarioClick(btn)}
|
||||
title={btn.overLimit ? 'Set simulated users to 57' : 'Set simulated users to 3'}
|
||||
className={`${styles.devButton} ${activeDevScenario === btn.label ? styles.devButtonActive : ''}`}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={props.opened}
|
||||
onClose={closeAndMarkSeen}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={1001}
|
||||
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>
|
||||
{renderDevOverlay()}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Button, Group, ActionIcon } from '@mantine/core';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { LicenseNotice } from '@app/components/onboarding/slides/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 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) => {
|
||||
if (
|
||||
button.type === 'button' &&
|
||||
slideDefinition.id === 'server-license' &&
|
||||
button.action === 'see-plans' &&
|
||||
licenseNotice.isOverLimit
|
||||
) {
|
||||
return 'Upgrade now →';
|
||||
}
|
||||
return button.label ?? '';
|
||||
};
|
||||
|
||||
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,23 @@
|
||||
import type { LicenseNotice } from '@app/components/onboarding/slides/types';
|
||||
|
||||
export interface InitialOnboardingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
|
||||
onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void;
|
||||
}
|
||||
|
||||
export interface OnboardingState {
|
||||
step: number;
|
||||
selectedDownloadIcon: 'new' | 'classic';
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
selfReportedAdmin: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_STATE: OnboardingState = {
|
||||
step: 0,
|
||||
selectedDownloadIcon: 'new',
|
||||
selectedRole: null,
|
||||
selfReportedAdmin: false,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
export type DevFlow = 'no-login' | 'login-admin' | 'login-user';
|
||||
|
||||
export interface DevScenarioButton {
|
||||
label: string;
|
||||
flow: DevFlow;
|
||||
overLimit: boolean;
|
||||
}
|
||||
|
||||
export interface DevOverrides {
|
||||
enableLogin?: boolean;
|
||||
isAdmin?: boolean;
|
||||
licenseUserCount?: number | null;
|
||||
}
|
||||
|
||||
interface UseDevScenariosOptions {
|
||||
opened: boolean;
|
||||
isDevMode: boolean;
|
||||
onApplyScenario: (payload: {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
selfReportedAdmin: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function useDevScenarios({ opened, isDevMode, onApplyScenario }: UseDevScenariosOptions) {
|
||||
const [devOverrides, setDevOverrides] = useState<DevOverrides | null>(null);
|
||||
const [activeDevScenario, setActiveDevScenario] = useState<string | null>(null);
|
||||
|
||||
const devButtons: DevScenarioButton[] = useMemo(() => {
|
||||
if (!isDevMode || !opened) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: 'no-login', flow: 'no-login', overLimit: false },
|
||||
{ label: 'admin-login', flow: 'login-admin', overLimit: false },
|
||||
{ label: 'admin 57', flow: 'login-admin', overLimit: true },
|
||||
{ label: 'user-login', flow: 'login-user', overLimit: false },
|
||||
];
|
||||
}, [isDevMode, opened]);
|
||||
|
||||
const handleDevScenarioClick = useCallback(
|
||||
(scenario: DevScenarioButton) => {
|
||||
if (!isDevMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { flow, overLimit, label } = scenario;
|
||||
const overrides: DevOverrides = {};
|
||||
let newSelectedRole: 'admin' | 'user' | null = null;
|
||||
let newSelfReportedAdmin = false;
|
||||
|
||||
switch (flow) {
|
||||
case 'no-login':
|
||||
overrides.enableLogin = false;
|
||||
overrides.isAdmin = false;
|
||||
newSelfReportedAdmin = true;
|
||||
newSelectedRole = 'admin';
|
||||
break;
|
||||
case 'login-admin':
|
||||
overrides.enableLogin = true;
|
||||
overrides.isAdmin = true;
|
||||
newSelectedRole = 'admin';
|
||||
break;
|
||||
case 'login-user':
|
||||
default:
|
||||
overrides.enableLogin = true;
|
||||
overrides.isAdmin = false;
|
||||
newSelectedRole = 'user';
|
||||
break;
|
||||
}
|
||||
|
||||
overrides.licenseUserCount = overLimit ? 57 : 3;
|
||||
|
||||
setDevOverrides(overrides);
|
||||
setActiveDevScenario(label);
|
||||
onApplyScenario({
|
||||
selectedRole: newSelectedRole,
|
||||
selfReportedAdmin: newSelfReportedAdmin,
|
||||
});
|
||||
},
|
||||
[isDevMode, onApplyScenario],
|
||||
);
|
||||
|
||||
return {
|
||||
devButtons,
|
||||
activeDevScenario,
|
||||
handleDevScenarioClick,
|
||||
devOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useOs } from '@app/hooks/useOs';
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
type ButtonAction,
|
||||
type FlowState,
|
||||
type SlideId,
|
||||
} from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { LicenseNotice } from '@app/components/onboarding/slides/types';
|
||||
import { resolveFlow } from './flowResolver';
|
||||
import { useLicenseInfo } from './useLicenseInfo';
|
||||
import { useDevScenarios } from './useDevScenarios';
|
||||
import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from './types';
|
||||
|
||||
const FREE_TIER_LIMIT = 5;
|
||||
|
||||
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;
|
||||
handleDownloadIconSelect: (icon: 'new' | 'classic') => void;
|
||||
devButtons: ReturnType<typeof useDevScenarios>['devButtons'];
|
||||
activeDevScenario: ReturnType<typeof useDevScenarios>['activeDevScenario'];
|
||||
handleDevScenarioClick: ReturnType<typeof useDevScenarios>['handleDevScenarioClick'];
|
||||
}
|
||||
|
||||
export function useInitialOnboardingState({
|
||||
opened,
|
||||
onClose,
|
||||
onRequestServerLicense,
|
||||
onLicenseNoticeUpdate,
|
||||
}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { startTour } = useOnboarding();
|
||||
const { config } = useAppConfig();
|
||||
const osType = useOs();
|
||||
const isDevMode = import.meta.env.MODE === 'development';
|
||||
|
||||
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) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedRole: role,
|
||||
selfReportedAdmin: role === 'admin',
|
||||
}));
|
||||
|
||||
if (role === 'admin') {
|
||||
window?.localStorage?.setItem('stirling-self-reported-admin', 'true');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeAndMarkSeen = useCallback(() => {
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
}
|
||||
onClose();
|
||||
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
|
||||
|
||||
const handleDevScenarioApply = useCallback(
|
||||
({
|
||||
selectedRole,
|
||||
selfReportedAdmin,
|
||||
}: {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
selfReportedAdmin: boolean;
|
||||
}) => {
|
||||
setState({
|
||||
...DEFAULT_STATE,
|
||||
selectedRole,
|
||||
selfReportedAdmin,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { devButtons, activeDevScenario, handleDevScenarioClick, devOverrides } = useDevScenarios({
|
||||
opened,
|
||||
isDevMode,
|
||||
onApplyScenario: handleDevScenarioApply,
|
||||
});
|
||||
|
||||
const isAdmin = !!config?.isAdmin;
|
||||
const enableLogin = config?.enableLogin ?? true;
|
||||
|
||||
const effectiveEnableLogin = devOverrides?.enableLogin ?? enableLogin;
|
||||
const effectiveIsAdmin = devOverrides?.isAdmin ?? isAdmin;
|
||||
|
||||
const licenseUserCountFromApi = useLicenseInfo({
|
||||
opened,
|
||||
shouldFetch: effectiveEnableLogin && effectiveIsAdmin && !devOverrides,
|
||||
});
|
||||
|
||||
const effectiveLicenseUserCount =
|
||||
devOverrides?.licenseUserCount ?? licenseUserCountFromApi ?? null;
|
||||
|
||||
const os = useMemo(() => {
|
||||
switch (osType) {
|
||||
case 'windows':
|
||||
return { label: 'Windows', url: 'https://files.stirlingpdf.com/win-installer.exe' };
|
||||
case 'mac-apple':
|
||||
return { label: 'Mac', url: 'https://files.stirlingpdf.com/mac-installer.dmg' };
|
||||
case 'mac-intel':
|
||||
return { label: 'Mac (Intel)', url: 'https://files.stirlingpdf.com/mac-x86_64-installer.dmg' };
|
||||
case 'linux-x64':
|
||||
case 'linux-arm64':
|
||||
return { label: 'Linux', url: 'https://docs.stirlingpdf.com/Installation/Unix%20Installation/' };
|
||||
default:
|
||||
return { label: '', url: '' };
|
||||
}
|
||||
}, [osType]);
|
||||
|
||||
const { ids: flowSlideIds, type: flowType } = resolveFlow(
|
||||
effectiveEnableLogin,
|
||||
effectiveIsAdmin,
|
||||
state.selfReportedAdmin,
|
||||
);
|
||||
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 licenseNotice = useMemo<LicenseNotice>(
|
||||
() => ({
|
||||
totalUsers: effectiveLicenseUserCount,
|
||||
freeTierLimit: FREE_TIER_LIMIT,
|
||||
isOverLimit:
|
||||
effectiveLicenseUserCount != null && effectiveLicenseUserCount > FREE_TIER_LIMIT,
|
||||
}),
|
||||
[effectiveLicenseUserCount],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLicenseNoticeUpdate?.(licenseNotice);
|
||||
}, [licenseNotice, onLicenseNoticeUpdate]);
|
||||
|
||||
const currentSlide = slideDefinition.createSlide({
|
||||
osLabel: os.label,
|
||||
osUrl: os.url,
|
||||
selectedRole: state.selectedRole,
|
||||
onRoleSelect: handleRoleSelect,
|
||||
licenseNotice,
|
||||
});
|
||||
|
||||
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 =
|
||||
state.selectedDownloadIcon === 'new'
|
||||
? os.url
|
||||
: state.selectedDownloadIcon === 'classic'
|
||||
? 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':
|
||||
onRequestServerLicense?.({
|
||||
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') {
|
||||
onRequestServerLicense?.({
|
||||
deferUntilTourComplete: true,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
}
|
||||
launchTour(launchMode, { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
case 'skip-to-license':
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
onRequestServerLicense?.({
|
||||
deferUntilTourComplete: false,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'see-plans':
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
closeAndMarkSeen,
|
||||
currentSlide,
|
||||
effectiveIsAdmin,
|
||||
flowType,
|
||||
goNext,
|
||||
goPrev,
|
||||
launchTour,
|
||||
onRequestServerLicense,
|
||||
os.url,
|
||||
state.selectedDownloadIcon,
|
||||
state.selectedRole,
|
||||
state.selfReportedAdmin,
|
||||
updatePreference,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDownloadIconSelect = useCallback((icon: 'new' | 'classic') => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedDownloadIcon: icon,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const flowState: FlowState = { selectedRole: state.selectedRole };
|
||||
|
||||
return {
|
||||
state,
|
||||
totalSteps,
|
||||
slideDefinition,
|
||||
currentSlide,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
closeAndMarkSeen,
|
||||
handleButtonAction,
|
||||
handleDownloadIconSelect,
|
||||
devButtons,
|
||||
activeDevScenario,
|
||||
handleDevScenarioClick,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface UseLicenseInfoOptions {
|
||||
opened: boolean;
|
||||
shouldFetch: boolean;
|
||||
}
|
||||
|
||||
export function useLicenseInfo({ opened, shouldFetch }: UseLicenseInfoOptions) {
|
||||
const [licenseUserCount, setLicenseUserCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldFetch) {
|
||||
setLicenseUserCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const fetchLicenseInfo = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<{ totalUsers?: number }>(
|
||||
'/api/v1/proprietary/ui-data/admin-settings',
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
} as any,
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
const totalUsers = response.data?.totalUsers;
|
||||
setLicenseUserCount(typeof totalUsers === 'number' ? totalUsers : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[onboarding] failed to fetch license information', error);
|
||||
if (!cancelled) {
|
||||
setLicenseUserCount(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLicenseInfo();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [opened, shouldFetch]);
|
||||
|
||||
return licenseUserCount;
|
||||
}
|
||||
|
||||
@@ -1,117 +1,26 @@
|
||||
import React 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 './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 CheckIcon from '@mui/icons-material/Check';
|
||||
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
|
||||
import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
|
||||
// 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;
|
||||
}
|
||||
import { useOnboardingFlow } from './hooks/useOnboardingFlow';
|
||||
import { createUserStepsConfig } from './userStepsConfig';
|
||||
import { createAdminStepsConfig } from './adminStepsConfig';
|
||||
import { removeAllGlows } from './tourGlow';
|
||||
import TourContent from './TourContent';
|
||||
|
||||
export default function OnboardingTour() {
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
// useAuth is provided in proprietary/desktop builds; in environments without Auth, this will be no-op
|
||||
let session: any = null;
|
||||
try {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
session = useAuth()?.session ?? null;
|
||||
} catch {
|
||||
// Auth provider not available in this build; treat as always authenticated
|
||||
session = {} as any;
|
||||
}
|
||||
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding();
|
||||
const flow = useOnboardingFlow();
|
||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||
|
||||
// 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,
|
||||
@@ -134,258 +43,76 @@ export default function OnboardingTour() {
|
||||
scrollNavToSection,
|
||||
} = useAdminTourOrchestration();
|
||||
|
||||
// Define steps as object keyed by enum - TypeScript ensures all keys are present
|
||||
const stepsConfig: Record<TourStep, StepType> = {
|
||||
[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,
|
||||
},
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!flow.isTourOpen) {
|
||||
removeAllGlows();
|
||||
}
|
||||
return () => removeAllGlows();
|
||||
}, [flow.isTourOpen]);
|
||||
|
||||
// Define admin tour steps
|
||||
const adminStepsConfig: Record<AdminTourStep, StepType> = {
|
||||
[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();
|
||||
},
|
||||
},
|
||||
};
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
// Select steps based on tour type
|
||||
const steps = tourType === 'admin'
|
||||
? Object.values(adminStepsConfig)
|
||||
: Object.values(stepsConfig);
|
||||
const adminStepsConfig = useMemo(
|
||||
() =>
|
||||
createAdminStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveAdminState,
|
||||
openConfigModal,
|
||||
navigateToSection,
|
||||
scrollNavToSection,
|
||||
},
|
||||
}),
|
||||
[navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
|
||||
);
|
||||
|
||||
const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: {
|
||||
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[];
|
||||
@@ -393,12 +120,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));
|
||||
}
|
||||
@@ -406,46 +133,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();
|
||||
};
|
||||
|
||||
const loginEnabled = !!config?.enableLogin;
|
||||
const isAuthenticated = !!session;
|
||||
const shouldShowIntro = !preferences.hasSeenIntroOnboarding && (!loginEnabled || isAuthenticated);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InitialOnboardingModal
|
||||
opened={shouldShowIntro}
|
||||
onClose={() => {
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TourWelcomeModal
|
||||
opened={showWelcomeModal}
|
||||
onStartTour={() => {
|
||||
setShowWelcomeModal(false);
|
||||
startTour();
|
||||
}}
|
||||
onMaybeLater={() => {
|
||||
setShowWelcomeModal(false);
|
||||
}}
|
||||
onDontShowAgain={() => {
|
||||
setShowWelcomeModal(false);
|
||||
completeTour();
|
||||
}}
|
||||
/>
|
||||
<InitialOnboardingModal {...flow.initialModalProps} />
|
||||
<ToolPanelModePrompt onComplete={flow.handleToolPromptComplete} />
|
||||
<TourProvider
|
||||
key={tourType}
|
||||
key={flow.tourType}
|
||||
steps={steps}
|
||||
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
|
||||
maskClassName={flow.maskClassName}
|
||||
onClickClose={handleCloseTour}
|
||||
onClickMask={advanceTour}
|
||||
onClickHighlighted={(e, clickProps) => {
|
||||
@@ -453,13 +156,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);
|
||||
}
|
||||
@@ -512,22 +212,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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
112
frontend/src/core/components/onboarding/ServerLicenseModal.tsx
Normal file
112
frontend/src/core/components/onboarding/ServerLicenseModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { Modal, Button, Group, Stack } from '@mantine/core';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||
import { LicenseNotice } from '@app/components/onboarding/slides/types';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
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 slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]);
|
||||
const primaryLabel = licenseNotice.isOverLimit ? 'Upgrade now →' : 'See Plans →';
|
||||
|
||||
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="/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}>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button styles={primaryStyles} onClick={handleSeePlans}>
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
22
frontend/src/core/components/onboarding/TourContent.tsx
Normal file
22
frontend/src/core/components/onboarding/TourContent.tsx
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
133
frontend/src/core/components/onboarding/adminStepsConfig.ts
Normal file
133
frontend/src/core/components/onboarding/adminStepsConfig.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { StepType } from '@reactour/tour';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { AdminTourStep } from './tourSteps';
|
||||
import { addGlowToElements, removeAllGlows } from './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,237 @@
|
||||
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 { useAuth } from '@app/auth/UseSession';
|
||||
import type { LicenseNotice } from '@app/components/onboarding/slides/types';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function useOnboardingFlow() {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
const { showCookieConsent, isReady: isCookieConsentReady } = useCookieConsentContext();
|
||||
const { completeTour, tourType, isOpen } = useOnboarding();
|
||||
let session: any = null;
|
||||
try {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
session = useAuth()?.session ?? null;
|
||||
} catch {
|
||||
session = {} as any;
|
||||
}
|
||||
|
||||
const loginEnabled = !!config?.enableLogin;
|
||||
const isAuthenticated = !!session;
|
||||
const shouldShowIntro = !preferences.hasSeenIntroOnboarding && (!loginEnabled || isAuthenticated);
|
||||
const isAdminUser = !!config?.isAdmin;
|
||||
|
||||
const [licenseNotice, setLicenseNotice] = useState<LicenseNotice>({
|
||||
totalUsers: null,
|
||||
freeTierLimit: 5,
|
||||
isOverLimit: 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 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;
|
||||
}
|
||||
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;
|
||||
});
|
||||
},
|
||||
[isAdminUser],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
cookieBannerPending &&
|
||||
isCookieConsentReady &&
|
||||
serverLicenseIntent === 'idle' &&
|
||||
!isServerLicenseOpen &&
|
||||
toolPromptCompleted
|
||||
) {
|
||||
maybeShowCookieBanner();
|
||||
}
|
||||
}, [
|
||||
cookieBannerPending,
|
||||
isCookieConsentReady,
|
||||
isServerLicenseOpen,
|
||||
serverLicenseIntent,
|
||||
toolPromptCompleted,
|
||||
maybeShowCookieBanner,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEligibleAdmin = isAdminUser || serverLicenseSource === 'self-reported';
|
||||
if (
|
||||
introWasOpenRef.current &&
|
||||
!shouldShowIntro &&
|
||||
isEligibleAdmin &&
|
||||
toolPromptCompleted &&
|
||||
!hasShownServerLicense &&
|
||||
serverLicenseIntent === 'idle'
|
||||
) {
|
||||
setServerLicenseIntent('pending');
|
||||
}
|
||||
introWasOpenRef.current = shouldShowIntro;
|
||||
}, [
|
||||
hasShownServerLicense,
|
||||
isAdminUser,
|
||||
serverLicenseIntent,
|
||||
shouldShowIntro,
|
||||
serverLicenseSource,
|
||||
toolPromptCompleted,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEligibleAdmin = isAdminUser || serverLicenseSource === 'self-reported';
|
||||
if (
|
||||
serverLicenseIntent !== 'idle' &&
|
||||
!shouldShowIntro &&
|
||||
!isOpen &&
|
||||
!isServerLicenseOpen &&
|
||||
isEligibleAdmin &&
|
||||
toolPromptCompleted
|
||||
) {
|
||||
setIsServerLicenseOpen(true);
|
||||
setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle');
|
||||
}
|
||||
}, [
|
||||
isAdminUser,
|
||||
isOpen,
|
||||
isServerLicenseOpen,
|
||||
serverLicenseIntent,
|
||||
shouldShowIntro,
|
||||
serverLicenseSource,
|
||||
toolPromptCompleted,
|
||||
]);
|
||||
|
||||
const handleServerLicenseClose = useCallback(() => {
|
||||
setIsServerLicenseOpen(false);
|
||||
setHasShownServerLicense(true);
|
||||
setServerLicenseIntent('idle');
|
||||
setServerLicenseSource(null);
|
||||
maybeShowCookieBanner();
|
||||
}, [maybeShowCookieBanner]);
|
||||
|
||||
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,
|
||||
}),
|
||||
[handleServerLicenseClose, isServerLicenseOpen, licenseNotice],
|
||||
);
|
||||
|
||||
return {
|
||||
tourType,
|
||||
isTourOpen: isOpen,
|
||||
maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined,
|
||||
initialModalProps,
|
||||
handleToolPromptComplete,
|
||||
serverLicenseModalProps,
|
||||
handleTourCompletion,
|
||||
};
|
||||
}
|
||||
|
||||
197
frontend/src/core/components/onboarding/onboardingFlowConfig.ts
Normal file
197
frontend/src/core/components/onboarding/onboardingFlowConfig.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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/components/onboarding/slides/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 SlideFactoryParams {
|
||||
osLabel: string;
|
||||
osUrl: string;
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
onRoleSelect: (role: 'admin' | 'user' | null) => void;
|
||||
licenseNotice?: LicenseNotice;
|
||||
}
|
||||
|
||||
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: 'Next →',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'next',
|
||||
},
|
||||
],
|
||||
},
|
||||
'desktop-install': {
|
||||
id: 'desktop-install',
|
||||
createSlide: ({ osLabel, osUrl }) => DesktopInstallSlide({ osLabel, osUrl }),
|
||||
hero: { type: 'dual-icon' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'desktop-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'desktop-skip',
|
||||
type: 'button',
|
||||
label: 'Skip for now',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'next',
|
||||
},
|
||||
{
|
||||
key: 'desktop-download',
|
||||
type: 'button',
|
||||
label: '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: 'Back',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'security-next',
|
||||
type: 'button',
|
||||
label: 'Next →',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'security-next',
|
||||
disabledWhen: (state) => !state.selectedRole,
|
||||
},
|
||||
],
|
||||
},
|
||||
'admin-overview': {
|
||||
id: 'admin-overview',
|
||||
createSlide: ({ licenseNotice }) => PlanOverviewSlide({ isAdmin: true, licenseNotice }),
|
||||
hero: { type: 'diamond' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'admin-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'admin-show',
|
||||
type: 'button',
|
||||
label: 'Show me around',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'launch-admin',
|
||||
},
|
||||
{
|
||||
key: 'admin-skip',
|
||||
type: 'button',
|
||||
label: 'Skip the tour',
|
||||
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: 'Skip for now',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'close',
|
||||
},
|
||||
{
|
||||
key: 'license-see-plans',
|
||||
type: 'button',
|
||||
label: 'See Plans →',
|
||||
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[],
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,37 @@
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
background-size: 180% 180%;
|
||||
animation: gradientShift 18s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.heroActive {}
|
||||
.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;
|
||||
@@ -22,6 +48,7 @@
|
||||
animation-duration: var(--circle-duration, 15s);
|
||||
animation-delay: var(--circle-delay, 0s);
|
||||
will-change: transform;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
|
||||
@@ -20,19 +20,59 @@ export default function AnimatedSlideBackground({
|
||||
isActive,
|
||||
slideKey,
|
||||
}: AnimatedSlideBackgroundComponentProps) {
|
||||
const gradientStyle = React.useMemo(
|
||||
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, ${gradientStops[0]}, ${gradientStops[1]})`,
|
||||
backgroundImage: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
|
||||
}),
|
||||
[gradientStops],
|
||||
[currentGradient],
|
||||
);
|
||||
|
||||
const prevGradientStyle = prevGradient
|
||||
? {
|
||||
backgroundImage: `linear-gradient(135deg, ${prevGradient[0]}, ${prevGradient[1]})`,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.hero} ${isActive ? styles.heroActive : ''}`.trim()}
|
||||
style={gradientStyle}
|
||||
key={slideKey}
|
||||
>
|
||||
<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;
|
||||
|
||||
@@ -65,7 +105,7 @@ export default function AnimatedSlideBackground({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${slideKey}-circle-${index}`}
|
||||
key={`circle-${index}-${position}`}
|
||||
className={styles.circle}
|
||||
style={circleStyle}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { SlideConfig } from './types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig';
|
||||
|
||||
interface DesktopInstallSlideProps {
|
||||
osLabel: string;
|
||||
@@ -20,29 +21,7 @@ export default function DesktopInstallSlide({ osLabel, osUrl }: DesktopInstallSl
|
||||
downloadUrl: osUrl,
|
||||
background: {
|
||||
gradientStops: ['#2563EB', '#0EA5E9'],
|
||||
circles: [
|
||||
{
|
||||
position: 'bottom-left',
|
||||
size: 260,
|
||||
color: 'rgba(255, 255, 255, 0.2)',
|
||||
opacity: 0.88,
|
||||
amplitude: 24,
|
||||
duration: 11,
|
||||
offsetX: 16,
|
||||
offsetY: 12,
|
||||
},
|
||||
{
|
||||
position: 'top-right',
|
||||
size: 300,
|
||||
color: 'rgba(28, 155, 235, 0.34)',
|
||||
opacity: 0.86,
|
||||
amplitude: 28,
|
||||
duration: 12,
|
||||
delay: 1,
|
||||
offsetX: 20,
|
||||
offsetY: 16,
|
||||
},
|
||||
],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,48 +1,35 @@
|
||||
import React from 'react';
|
||||
import { SlideConfig } from './types';
|
||||
import { SlideConfig, LicenseNotice } from './types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig';
|
||||
|
||||
interface PlanOverviewSlideProps {
|
||||
isAdmin: boolean;
|
||||
licenseNotice?: LicenseNotice;
|
||||
}
|
||||
|
||||
export default function PlanOverviewSlide({ isAdmin }: PlanOverviewSlideProps): SlideConfig {
|
||||
const DEFAULT_FREE_TIER_LIMIT = 5;
|
||||
|
||||
export default function PlanOverviewSlide({ isAdmin, licenseNotice }: PlanOverviewSlideProps): SlideConfig {
|
||||
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
|
||||
|
||||
const adminBody = (
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
);
|
||||
|
||||
return {
|
||||
key: isAdmin ? 'admin-overview' : 'plan-overview',
|
||||
title: isAdmin ? 'Admin Overview' : 'Plan Overview',
|
||||
body: isAdmin ? (
|
||||
body: isAdmin ? adminBody : (
|
||||
<span>
|
||||
As an admin, you can manage users, configure settings, and monitor server health. The first 5 people on your server get to use Stirling free of charge.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
For the next <strong>30 days</strong>, you'll enjoy <strong>unlimited Pro access</strong> to the Reader and the Editor. Afterwards, you can continue with the Reader for free or upgrade to keep the Editor too.
|
||||
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>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#F97316', '#EF4444'],
|
||||
circles: [
|
||||
{
|
||||
position: 'bottom-left',
|
||||
size: 260,
|
||||
color: 'rgba(255, 255, 255, 0.25)',
|
||||
opacity: 0.9,
|
||||
amplitude: 26,
|
||||
duration: 11,
|
||||
offsetX: 18,
|
||||
offsetY: 12,
|
||||
},
|
||||
{
|
||||
position: 'top-right',
|
||||
size: 300,
|
||||
color: 'rgba(251, 191, 36, 0.4)',
|
||||
opacity: 0.9,
|
||||
amplitude: 30,
|
||||
duration: 12,
|
||||
delay: 1.4,
|
||||
offsetX: 24,
|
||||
offsetY: 18,
|
||||
},
|
||||
],
|
||||
gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Select } from '@mantine/core';
|
||||
import styles from '../InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
import { SlideConfig } from './types';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig';
|
||||
|
||||
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>Oops! Your Stirling server doesn't have an admin yet.</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,45 @@
|
||||
import React from 'react';
|
||||
import { SlideConfig, LicenseNotice } from './types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig';
|
||||
|
||||
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 ? 'Server License Needed' : 'Server License';
|
||||
const key = isOverLimit ? 'server-license-over-limit' : 'server-license';
|
||||
|
||||
const body = isOverLimit ? (
|
||||
<span>
|
||||
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 - unlimited seats, PDF text editing, and full admin control for $99/server/mo.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Our licensing permits up to <strong>{freeTierLimit}</strong> users for free per server. To scale uninterrupted
|
||||
and access our new PDF text editing tool, we recommend the Stirling Server plan - full editing and unlimited
|
||||
seats for $99/server/mo.
|
||||
</span>
|
||||
);
|
||||
|
||||
return {
|
||||
key,
|
||||
title,
|
||||
body,
|
||||
background: {
|
||||
gradientStops: isOverLimit ? ['#F472B6', '#8B5CF6'] : ['#F97316', '#F59E0B'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import React from 'react';
|
||||
import { SlideConfig } from './types';
|
||||
import styles from '../InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from './unifiedBackgroundConfig';
|
||||
|
||||
export default function WelcomeSlide(): SlideConfig {
|
||||
return {
|
||||
key: 'welcome',
|
||||
title: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||
<span className={styles.welcomeTitleContainer}>
|
||||
Welcome to Stirling
|
||||
<span
|
||||
style={{
|
||||
background: '#DBEFFF',
|
||||
color: '#2A4BFF',
|
||||
padding: '4px 12px',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span className={styles.v2Badge}>
|
||||
V2
|
||||
</span>
|
||||
</span>
|
||||
@@ -28,29 +21,7 @@ export default function WelcomeSlide(): SlideConfig {
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#7C3AED', '#EC4899'],
|
||||
circles: [
|
||||
{
|
||||
position: 'bottom-left',
|
||||
size: 260,
|
||||
color: 'rgba(255, 255, 255, 0.25)',
|
||||
opacity: 0.9,
|
||||
amplitude: 24,
|
||||
duration: 11,
|
||||
offsetX: 18,
|
||||
offsetY: 14,
|
||||
},
|
||||
{
|
||||
position: 'top-right',
|
||||
size: 300,
|
||||
color: 'rgba(196, 181, 253, 0.4)',
|
||||
opacity: 0.9,
|
||||
amplitude: 28,
|
||||
duration: 12,
|
||||
delay: 1.2,
|
||||
offsetX: 24,
|
||||
offsetY: 18,
|
||||
},
|
||||
],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,3 +25,9 @@ export interface SlideConfig {
|
||||
background: AnimatedSlideBackgroundProps;
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
export interface LicenseNotice {
|
||||
totalUsers: number | null;
|
||||
freeTierLimit: number;
|
||||
isOverLimit: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AnimatedCircleConfig } from './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,
|
||||
},
|
||||
];
|
||||
|
||||
18
frontend/src/core/components/onboarding/tourGlow.ts
Normal file
18
frontend/src/core/components/onboarding/tourGlow.ts
Normal file
@@ -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'));
|
||||
};
|
||||
|
||||
33
frontend/src/core/components/onboarding/tourSteps.ts
Normal file
33
frontend/src/core/components/onboarding/tourSteps.ts
Normal file
@@ -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,
|
||||
}
|
||||
|
||||
173
frontend/src/core/components/onboarding/userStepsConfig.ts
Normal file
173
frontend/src/core/components/onboarding/userStepsConfig.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { StepType } from '@reactour/tour';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { TourStep } from './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,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;
|
||||
|
||||
@@ -268,7 +268,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<div
|
||||
key={buttonConfig.id}
|
||||
data-tour="help-button"
|
||||
onClick={() => startTour('tools')}
|
||||
onClick={() =>
|
||||
startTour('tools', {
|
||||
source: 'quick-access-help-button',
|
||||
metadata: { entry: 'direct' },
|
||||
})
|
||||
}
|
||||
>
|
||||
{renderNavButton(buttonConfig, index)}
|
||||
</div>
|
||||
@@ -285,7 +290,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
|
||||
onClick={() => startTour('tools')}
|
||||
onClick={() =>
|
||||
startTour('tools', {
|
||||
source: 'quick-access-help-menu',
|
||||
metadata: { entry: 'menu-tools' },
|
||||
})
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
@@ -298,7 +308,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
|
||||
onClick={() => startTour('admin')}
|
||||
onClick={() =>
|
||||
startTour('admin', {
|
||||
source: 'quick-access-help-menu',
|
||||
metadata: { entry: 'menu-admin' },
|
||||
})
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
|
||||
@@ -107,7 +107,10 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={preferences.defaultToolPanelMode}
|
||||
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
|
||||
onChange={(val: string) => {
|
||||
updatePreference('defaultToolPanelMode', val as ToolPanelMode);
|
||||
updatePreference('hasSelectedToolPanelMode', true);
|
||||
}}
|
||||
data={[
|
||||
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
|
||||
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
|
||||
|
||||
@@ -5,14 +5,23 @@ 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 { DEFAULT_TOOL_PANEL_MODE, 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, setShowWelcomeModal } = useOnboarding();
|
||||
const {
|
||||
startTour,
|
||||
startAfterToolModeSelection,
|
||||
setStartAfterToolModeSelection,
|
||||
pendingTourRequest,
|
||||
} = useOnboarding();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const { config } = useAppConfig();
|
||||
const isAdmin = !!config?.isAdmin;
|
||||
@@ -26,27 +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);
|
||||
|
||||
// If the user requested the tour after completing this prompt, start it now
|
||||
if (startAfterToolModeSelection && !preferences.hasCompletedOnboarding) {
|
||||
setShowWelcomeModal(false);
|
||||
setStartAfterToolModeSelection(false);
|
||||
if (isAdmin) {
|
||||
startTour('admin');
|
||||
} else {
|
||||
startTour('tools');
|
||||
}
|
||||
}
|
||||
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 (
|
||||
|
||||
42
frontend/src/core/contexts/CookieConsentContext.tsx
Normal file
42
frontend/src/core/contexts/CookieConsentContext.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
}
|
||||
|
||||
const CookieConsentContext = createContext<CookieConsentContextValue | undefined>(undefined);
|
||||
|
||||
export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { config } = useAppConfig();
|
||||
const analyticsEnabled = config?.enableAnalytics === true;
|
||||
const {
|
||||
showCookieConsent,
|
||||
showCookiePreferences,
|
||||
isInitialized,
|
||||
} = useCookieConsent({ analyticsEnabled });
|
||||
|
||||
const value = useMemo<CookieConsentContextValue>(() => ({
|
||||
isReady: analyticsEnabled && isInitialized,
|
||||
showCookieConsent,
|
||||
showCookiePreferences,
|
||||
}), [analyticsEnabled, 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,48 +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 [startAfterToolModeSelection, setStartAfterToolModeSelection] = useState(false);
|
||||
const shouldShow = useShouldShowWelcomeModal();
|
||||
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);
|
||||
}, []);
|
||||
@@ -59,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={{
|
||||
@@ -70,10 +139,10 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
closeTour,
|
||||
completeTour,
|
||||
resetTour,
|
||||
showWelcomeModal,
|
||||
setShowWelcomeModal,
|
||||
startAfterToolModeSelection,
|
||||
setStartAfterToolModeSelection,
|
||||
pendingTourRequest,
|
||||
clearPendingTourRequest,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -145,6 +145,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]);
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ interface CookieConsentConfig {
|
||||
}
|
||||
|
||||
export const useCookieConsent = ({
|
||||
analyticsEnabled = false
|
||||
analyticsEnabled = false,
|
||||
}: CookieConsentConfig = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
@@ -34,10 +34,6 @@ export const useCookieConsent = ({
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -116,7 +112,7 @@ export const useCookieConsent = ({
|
||||
// Initialize cookie consent with full configuration
|
||||
try {
|
||||
window.CookieConsent.run({
|
||||
autoShow: true,
|
||||
autoShow: false,
|
||||
hideFromBots: false,
|
||||
guiOptions: {
|
||||
consentModal: {
|
||||
@@ -205,11 +201,6 @@ export const useCookieConsent = ({
|
||||
}
|
||||
});
|
||||
|
||||
// Force show after initialization
|
||||
setTimeout(() => {
|
||||
window.CookieConsent?.show();
|
||||
}, 200);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing CookieConsent:', error);
|
||||
}
|
||||
@@ -237,11 +228,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) {
|
||||
@@ -251,7 +248,9 @@ export const useCookieConsent = ({
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showCookieConsent,
|
||||
showCookiePreferences,
|
||||
isServiceAccepted
|
||||
isServiceAccepted,
|
||||
isInitialized
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,9 +7,11 @@ export interface UserPreferences {
|
||||
defaultToolPanelMode: ToolPanelMode;
|
||||
theme: ThemeMode;
|
||||
toolPanelModePromptSeen: boolean;
|
||||
hasSelectedToolPanelMode: boolean;
|
||||
showLegacyToolDescriptions: boolean;
|
||||
hasCompletedOnboarding: boolean;
|
||||
hasSeenIntroOnboarding: boolean;
|
||||
hasSeenCookieBanner: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
@@ -18,9 +20,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,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf_preferences';
|
||||
|
||||
Reference in New Issue
Block a user